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(); + } +});