diff --git a/.github/workflows/test-smokes.yml b/.github/workflows/test-smokes.yml
index 416948f5fbc..51f3b66809e 100644
--- a/.github/workflows/test-smokes.yml
+++ b/.github/workflows/test-smokes.yml
@@ -83,6 +83,15 @@ jobs:
with:
node-version: 20
+ - name: Cache multiplex server node_modules
+ if: ${{ runner.os != 'Windows' || github.event_name == 'schedule' }}
+ uses: actions/cache@v4
+ with:
+ path: tests/integration/playwright/multiplex-server/node_modules
+ key: ${{ runner.os }}-multiplex-server-${{ hashFiles('tests/integration/playwright/multiplex-server/package.json') }}
+ restore-keys: |
+ ${{ runner.os }}-multiplex-server-
+
- name: Install node dependencies
if: ${{ runner.os != 'Windows' || github.event_name == 'schedule' }}
run: yarn
diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md
index 28530a7a51b..302a91367ff 100644
--- a/news/changelog-1.9.md
+++ b/news/changelog-1.9.md
@@ -4,6 +4,7 @@ All changes included in 1.9:
- ([#13396](https://github.com/quarto-dev/quarto-cli/issues/13396)): Fix `quarto publish connect` regression.
- ([#13441](https://github.com/quarto-dev/quarto-cli/pull/13441)): Catch `undefined` exceptions in Pandoc failure to avoid spurious error message.
+- ([#13046](https://github.com/quarto-dev/quarto-cli/issues/13046)): Use new url for multiplex socket.io server as default for `format: revealjs` and `revealjs.multiplex: true`.
## Dependencies
diff --git a/src/format/reveal/format-reveal-multiplex.ts b/src/format/reveal/format-reveal-multiplex.ts
index e057d4feff8..27866a8c8eb 100644
--- a/src/format/reveal/format-reveal-multiplex.ts
+++ b/src/format/reveal/format-reveal-multiplex.ts
@@ -104,7 +104,7 @@ interface RevealMultiplexToken {
url: string;
}
-const kDefaultMultiplexUrl = "https://reveal-multiplex.glitch.me/";
+const kDefaultMultiplexUrl = "https://multiplex.up.railway.app/";
async function revealMultiplexToken(
format: Format,
diff --git a/src/resources/editor/tools/vs-code.mjs b/src/resources/editor/tools/vs-code.mjs
index 8dcac7632df..04755b04362 100644
--- a/src/resources/editor/tools/vs-code.mjs
+++ b/src/resources/editor/tools/vs-code.mjs
@@ -19300,7 +19300,7 @@ var require_yaml_intelligence_resources = __commonJS({
properties: {
url: {
string: {
- default: "https://reveal-multiplex.glitch.me/",
+ default: "https://multiplex.up.railway.app/",
description: "Multiplex token server (defaults to Reveal-hosted server)\n"
}
},
diff --git a/src/resources/editor/tools/yaml/web-worker.js b/src/resources/editor/tools/yaml/web-worker.js
index 40b135e23cb..69d6f967893 100644
--- a/src/resources/editor/tools/yaml/web-worker.js
+++ b/src/resources/editor/tools/yaml/web-worker.js
@@ -19301,7 +19301,7 @@ try {
properties: {
url: {
string: {
- default: "https://reveal-multiplex.glitch.me/",
+ default: "https://multiplex.up.railway.app/",
description: "Multiplex token server (defaults to Reveal-hosted server)\n"
}
},
diff --git a/src/resources/editor/tools/yaml/yaml-intelligence-resources.json b/src/resources/editor/tools/yaml/yaml-intelligence-resources.json
index cbc9e3f7b9f..3b75cffb5c0 100644
--- a/src/resources/editor/tools/yaml/yaml-intelligence-resources.json
+++ b/src/resources/editor/tools/yaml/yaml-intelligence-resources.json
@@ -12272,7 +12272,7 @@
"properties": {
"url": {
"string": {
- "default": "https://reveal-multiplex.glitch.me/",
+ "default": "https://multiplex.up.railway.app/",
"description": "Multiplex token server (defaults to Reveal-hosted server)\n"
}
},
diff --git a/src/resources/formats/revealjs/plugins/multiplex/plugin.yml b/src/resources/formats/revealjs/plugins/multiplex/plugin.yml
index 9ccda633341..456365db606 100644
--- a/src/resources/formats/revealjs/plugins/multiplex/plugin.yml
+++ b/src/resources/formats/revealjs/plugins/multiplex/plugin.yml
@@ -5,4 +5,4 @@ config:
multiplex:
secret: null
id: null
- url: "https://reveal-multiplex.glitch.me/"
+ url: "https://multiplex.up.railway.app/"
diff --git a/src/resources/schema/document-reveal-tools.yml b/src/resources/schema/document-reveal-tools.yml
index a7d7f8195cf..888b6db3681 100644
--- a/src/resources/schema/document-reveal-tools.yml
+++ b/src/resources/schema/document-reveal-tools.yml
@@ -87,7 +87,7 @@
properties:
url:
string:
- default: https://reveal-multiplex.glitch.me/
+ default: https://multiplex.up.railway.app/
description: |
Multiplex token server (defaults to Reveal-hosted server)
id:
diff --git a/tests/docs/playwright/revealjs/multiplex.qmd b/tests/docs/playwright/revealjs/multiplex.qmd
new file mode 100644
index 00000000000..6182b8f0d68
--- /dev/null
+++ b/tests/docs/playwright/revealjs/multiplex.qmd
@@ -0,0 +1,34 @@
+---
+title: "Multiplex Test Presentation"
+format:
+ revealjs:
+ multiplex:
+ url: "http://127.0.0.1:1948/"
+ # setting secret and id is required to avoid having multiplex server running at render time
+ secret: "c04998070a4ec17940ab3c52101daefd"
+ id: "52d3aedefbff55dbe54e1fa5229df99b849a33951c7c09afb6bcf7e6f33233c7"
+---
+
+## Slide 1 {#slide-1}
+
+This is the first slide.
+
+## Slide 2 {#slide-2}
+
+This is the second slide with some content.
+
+## Slide 3 {#slide-3}
+
+### Fragment Test
+
+::: {.fragment}
+Fragment 1
+:::
+
+::: {.fragment}
+Fragment 2
+:::
+
+## Slide 4 {#slide-4}
+
+Final slide with more content.
diff --git a/tests/integration/playwright-tests.test.ts b/tests/integration/playwright-tests.test.ts
index 3f4abaf340f..0381420ee41 100644
--- a/tests/integration/playwright-tests.test.ts
+++ b/tests/integration/playwright-tests.test.ts
@@ -16,6 +16,8 @@ import { execProcess } from "../../src/core/process.ts";
import { quartoDevCmd } from "../utils.ts";
import { fail } from "testing/asserts";
import { isWindows } from "../../src/deno_ral/platform.ts";
+import { join } from "../../src/deno_ral/path.ts";
+import { existsSync } from "../../src/deno_ral/fs.ts";
async function fullInit() {
await initYamlIntelligenceResourcesFromFilesystem();
@@ -30,6 +32,19 @@ const globOutput = Deno.args.length
setInitializer(fullInit);
await initState();
+// Install multiplex server dependencies if needed
+const multiplexServerPath = "integration/playwright/multiplex-server";
+const multiplexNodeModules = join(multiplexServerPath, "node_modules");
+if (!existsSync(multiplexNodeModules)) {
+ console.log("Installing multiplex server dependencies...");
+ await execProcess({
+ cmd: isWindows ? "npm.cmd" : "npm",
+ args: ["install", "--loglevel=error"],
+ cwd: multiplexServerPath,
+ });
+ console.log("Multiplex server dependencies installed.");
+}
+
// const promises = [];
const fileNames: string[] = [];
const extraOpts = [
@@ -52,7 +67,7 @@ for (const { path: fileName } of globOutput) {
// mediabag inspection if we don't wait all renders
// individually. This is very slow..
await execProcess({
- cmd: quartoDevCmd(),
+ cmd: quartoDevCmd(),
args: ["render", input, ...options],
});
fileNames.push(fileName);
diff --git a/tests/integration/playwright/multiplex-server/.gitignore b/tests/integration/playwright/multiplex-server/.gitignore
new file mode 100644
index 00000000000..504afef81fb
--- /dev/null
+++ b/tests/integration/playwright/multiplex-server/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+package-lock.json
diff --git a/tests/integration/playwright/multiplex-server/README.md b/tests/integration/playwright/multiplex-server/README.md
new file mode 100644
index 00000000000..8b55b36d387
--- /dev/null
+++ b/tests/integration/playwright/multiplex-server/README.md
@@ -0,0 +1,34 @@
+# RevealJS Multiplex Server (Local Test Instance)
+
+This is a local instance of the [reveal/multiplex](https://github.com/reveal/multiplex) server used for testing Quarto's RevealJS multiplex feature.
+
+## Purpose
+
+This server enables testing of the multiplex presentation feature without relying on external services. It:
+
+- Generates tokens (secret/socketId pairs)
+- Manages socket.io connections between master and client presentations
+- Broadcasts presentation state changes from master to clients
+
+## Usage
+
+The server is automatically started by Playwright's `webServer` configuration when running tests. It listens on `http://localhost:1948` by default.
+
+## Installation
+
+Dependencies are automatically installed by `tests/integration/playwright-tests.test.ts` before running Playwright tests.
+
+Manual installation:
+```bash
+npm install
+```
+
+## Running Manually
+
+```bash
+npm start
+```
+
+## Attribution
+
+Server code is from the [reveal/multiplex](https://github.com/reveal/multiplex) repository.
diff --git a/tests/integration/playwright/multiplex-server/index.js b/tests/integration/playwright/multiplex-server/index.js
new file mode 100644
index 00000000000..762f8eea38a
--- /dev/null
+++ b/tests/integration/playwright/multiplex-server/index.js
@@ -0,0 +1,76 @@
+let http = require("http");
+let express = require("express");
+let cors = require("cors");
+let fs = require("fs");
+let io = require("socket.io");
+let crypto = require("crypto");
+
+let app = express();
+let staticDir = express.static;
+
+app.use(cors()); // enable cors for all origins
+
+let server = http.createServer(app);
+
+let socketsIO = io(server, {
+ cors: {
+ origin: "*",
+ methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ },
+});
+
+let opts = {
+ port: process.env.PORT || 1948,
+ baseDir: process.cwd(),
+};
+
+socketsIO.on("connection", (socket) => {
+ console.debug("Connection opened");
+ socket.on("multiplex-statechanged", (data) => {
+ if (
+ typeof data.secret == "undefined" ||
+ data.secret == null ||
+ data.secret === ""
+ )
+ return;
+ if (createHash(data.secret) === data.socketId) {
+ console.debug("Broadcasting state change");
+ data.secret = null;
+ socket.broadcast.emit(data.socketId, data);
+ } else {
+ console.warn("Secret and socketId do not match");
+ }
+ });
+});
+
+app.use(express.static(opts.baseDir));
+
+app.get("/", (req, res) => {
+ res.writeHead(200, { "Content-Type": "text/html" });
+
+ let stream = fs.createReadStream(opts.baseDir + "/index.html");
+ stream.on("error", (error) => {
+ res.write(
+ '
reveal.js multiplex server.
Generate token'
+ );
+ res.end();
+ });
+ stream.on("open", () => {
+ stream.pipe(res);
+ });
+});
+
+app.get("/token", (req, res) => {
+ let secret = crypto.randomBytes(16).toString("hex");
+ res.send({ secret: secret, socketId: createHash(secret) });
+});
+
+let createHash = (secret) => {
+ let hash = crypto.createHash("sha256").update(secret);
+ return hash.digest("hex");
+};
+
+// Open the listening port
+server.listen(opts.port || null);
+
+console.log(`reveal.js: Multiplex running on port: ${opts.port}`);
diff --git a/tests/integration/playwright/multiplex-server/package.json b/tests/integration/playwright/multiplex-server/package.json
new file mode 100644
index 00000000000..36751ea5ed4
--- /dev/null
+++ b/tests/integration/playwright/multiplex-server/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "reveal-multiplex",
+ "version": "0.1.0",
+ "description": "reveal.js multiplex plugin - local test server",
+ "homepage": "https://revealjs.com",
+ "scripts": {
+ "start": "node index.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "dependencies": {
+ "cors": "^2.8.5",
+ "express": "~4.17.1",
+ "mustache": "~4.0.0",
+ "socket.io": "^2.5.0"
+ },
+ "license": "MIT"
+}
diff --git a/tests/integration/playwright/playwright.config.ts b/tests/integration/playwright/playwright.config.ts
index 8b244a4f306..d300619dd1d 100644
--- a/tests/integration/playwright/playwright.config.ts
+++ b/tests/integration/playwright/playwright.config.ts
@@ -94,10 +94,21 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
/* We use python for this but we could also try using another tool */
- webServer: {
- command: 'uv run python -m http.server 8080',
- url: 'http://127.0.0.1:8080',
- reuseExistingServer: !isCI,
- cwd: '../../docs/playwright',
- },
+ webServer: [
+ {
+ // HTTP server for rendered HTML files
+ command: 'uv run python -m http.server 8080',
+ url: 'http://127.0.0.1:8080',
+ reuseExistingServer: !isCI,
+ cwd: '../../docs/playwright',
+ },
+ {
+ // Socket.IO multiplex server for RevealJS
+ command: 'npm start',
+ url: 'http://127.0.0.1:1948',
+ reuseExistingServer: !isCI,
+ cwd: './multiplex-server',
+ timeout: 10000,
+ }
+ ],
});
diff --git a/tests/integration/playwright/tests/revealjs-multiplex.spec.ts b/tests/integration/playwright/tests/revealjs-multiplex.spec.ts
new file mode 100644
index 00000000000..43fa3294f01
--- /dev/null
+++ b/tests/integration/playwright/tests/revealjs-multiplex.spec.ts
@@ -0,0 +1,119 @@
+import { test, expect, Page, Browser, BrowserContext } from '@playwright/test';
+
+async function getRevealState(page: Page) {
+ return await page.evaluate(() => {
+ const reveal = (window as any).Reveal;
+ return reveal ? reveal.getState() : null;
+ });
+}
+
+const SOCKET_PROPAGATION_DELAY = 500;
+
+async function createMultiplexPage(browser: Browser, url: string) {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.goto(url);
+ return { context, page };
+}
+
+/**
+ * Helper to create both master and client pages for multiplex testing
+ */
+async function createMultiplexPages(browser: Browser) {
+ const master = await createMultiplexPage(browser, './revealjs/multiplex-speaker.html');
+ const client = await createMultiplexPage(browser, './revealjs/multiplex.html');
+ return { master, client };
+}
+
+async function expectSlidePositions(
+ master: Page,
+ client: Page,
+ expectedMasterH: number,
+ expectedClientH: number,
+ expectedMasterV?: number,
+ expectedClientV?: number
+) {
+ const masterState = await getRevealState(master);
+ const clientState = await getRevealState(client);
+
+ expect(masterState.indexh).toBe(expectedMasterH);
+ expect(clientState.indexh).toBe(expectedClientH);
+
+ if (expectedMasterV !== undefined) {
+ expect(masterState.indexv).toBe(expectedMasterV);
+ }
+ if (expectedClientV !== undefined) {
+ expect(clientState.indexv).toBe(expectedClientV);
+ }
+}
+
+async function expectFragmentPositions(
+ master: Page,
+ client: Page,
+ expectedMasterF: number,
+ expectedClientF: number
+) {
+ const masterState = await getRevealState(master);
+ const clientState = await getRevealState(client);
+
+ expect(masterState.indexf).toBe(expectedMasterF);
+ expect(clientState.indexf).toBe(expectedClientF);
+}
+
+/**
+ * Test the multiplex feature where a master presentation controls client presentations.
+ * Tests: slide synchronization, fragment synchronization, and unidirectional control.
+ */
+test('multiplex: master controls client, fragments sync, and client cannot control master', async ({ browser }) => {
+ const { master, client } = await createMultiplexPages(browser);
+
+ try {
+ // Both should start on title slide
+ await expectSlidePositions(master.page, client.page, 0, 0, 0, 0);
+
+ // Test 1: Basic slide synchronization
+ await master.page.keyboard.press('ArrowRight');
+ await master.page.waitForTimeout(SOCKET_PROPAGATION_DELAY);
+ await expectSlidePositions(master.page, client.page, 1, 1);
+
+ await master.page.keyboard.press('ArrowRight');
+ await master.page.waitForTimeout(SOCKET_PROPAGATION_DELAY);
+ await expectSlidePositions(master.page, client.page, 2, 2);
+
+ // Test 2: Fragment synchronization on slide 3
+ await master.page.keyboard.press('ArrowRight'); // slide 2 -> slide 3
+ await master.page.waitForTimeout(SOCKET_PROPAGATION_DELAY);
+ await expectSlidePositions(master.page, client.page, 3, 3);
+
+ // Show first fragment
+ await master.page.keyboard.press('ArrowRight');
+ await master.page.waitForTimeout(SOCKET_PROPAGATION_DELAY);
+ await expectFragmentPositions(master.page, client.page, 0, 0);
+
+ // Show second fragment
+ await master.page.keyboard.press('ArrowRight');
+ await master.page.waitForTimeout(SOCKET_PROPAGATION_DELAY);
+ await expectFragmentPositions(master.page, client.page, 1, 1);
+
+ // Test 3: Client cannot control master
+ // Navigate back to title slide
+ await master.page.goto('./revealjs/multiplex-speaker.html#/');
+ await master.page.waitForTimeout(SOCKET_PROPAGATION_DELAY);
+ await expectSlidePositions(master.page, client.page, 0, 0);
+
+ // Client tries to navigate (should not affect master)
+ await client.page.keyboard.press('ArrowRight');
+ await client.page.waitForTimeout(SOCKET_PROPAGATION_DELAY);
+ await expectSlidePositions(master.page, client.page, 0, 1);
+
+ // Master overrides client's position
+ await master.page.keyboard.press('ArrowRight');
+ await master.page.keyboard.press('ArrowRight');
+ await master.page.waitForTimeout(SOCKET_PROPAGATION_DELAY);
+ await expectSlidePositions(master.page, client.page, 2, 2);
+
+ } finally {
+ await master.context.close();
+ await client.context.close();
+ }
+});