| author | Eric Roberts
<eric@devl.me> 2026-09-15 15:14:26 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-09-15 17:38:31 UTC |
| parent | e5ce4bee1a8204bc6e95f0838aae7546951476f6 |
| .oxlintrc.json | +0 | -8 |
| Makefile | +0 | -1 |
| npm/package.json | +1 | -1 |
| playwright.config.ts | +0 | -57 |
| playwright/server.mjs | +0 | -55 |
| test/fast-auth.spec.ts | +98 | -100 |
| test/socket-bridge.mts | +161 | -0 |
| vitest.config.mts | +25 | -6 |
diff --git a/.oxlintrc.json b/.oxlintrc.json index 8248ac5..2d60a7c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,12 +5,4 @@ "rules": { "eslint/no-undef": "warn" }, - "overrides": [ - { - "files": ["playwright.config.ts"], - "env": { - "node": true, - } - } - ] } diff --git a/Makefile b/Makefile index b9299b4..0a3d3a1 100644 --- a/Makefile +++ b/Makefile @@ -85,7 +85,6 @@ playwright/.cache/sqlite-worker1.js: npm $(RM) $@.mjs.bak browser-tests: playwright/.cache/borogove.js playwright/.cache/sqlite-wasm.js playwright/.cache/sqlite-worker1.js - npx playwright test cd npm && npm run test:vitest-browser cpp/libborogove.dso: diff --git a/npm/package.json b/npm/package.json index 26fd6be..6617be6 100644 --- a/npm/package.json +++ b/npm/package.json @@ -38,12 +38,12 @@ } }, "devDependencies": { - "@playwright/test": "1.60.0", "@sqlite.org/sqlite-wasm": "^3.53.0-build1", "@vitest/browser": "^5.0.0", "@vitest/browser-playwright": "^5.0.0", "oxlint": "^1.82.0", "oxfmt": "^0.67.0", + "playwright": "1.60.0", "typescript": "^5.4.5", "vitest": "^5.0.0" } diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index 656f03a..0000000 --- a/playwright.config.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// import dotenv from "dotenv"; -// import path from "path"; -// dotenv.config({ path: path.resolve(__dirname, ".env") }); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - timeout: 260000, - testDir: "./test", - testMatch: ["**/fast-auth.spec.ts"], - webServer: { - command: "node playwright/server.mjs", - url: "http://127.0.0.1:49276", - }, - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "dot", - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - baseURL: "http://127.0.0.1:49276", - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "on-first-retry", - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - - { - name: "firefox", - use: { ...devices["Desktop Firefox"] }, - }, - - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, - ], -}); diff --git a/playwright/server.mjs b/playwright/server.mjs deleted file mode 100644 index 4bdf07c..0000000 --- a/playwright/server.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import { createReadStream } from "node:fs"; -import { createServer } from "node:http"; - -const server = createServer((request, response) => { - const pathname = new URL(request.url ?? "/", "http://localhost").pathname; - - switch (pathname) { - case "/": - return response.end(""); - - case "/idb": - return response.end( - `<script type="module"> - import * as borogove from "/borogove.js"; - window.borogove = borogove; - </script>`, - ); - - case "/sqlite": - return response.end( - `<script type="module"> - import * as borogove from "/borogove.js"; - import * as sqlite from "/sqlite-wasm.js"; - - window.sqliteWorker1Url = new URL("/sqlite-worker1.js", location.href); - window.borogove = borogove; - window.sqlite = sqlite; - </script>`, - ); - - case "/borogove.js": - return sendScript(response, "borogove.js"); - - case "/sqlite-wasm.js": - return sendScript(response, "sqlite-wasm.js"); - - case "/sqlite-worker1.js": - return sendScript(response, "sqlite-worker1.js"); - - default: - response.writeHead(404); - response.end("Not found"); - } -}); - -function sendScript(response, asset) { - response.writeHead(200, { - "Content-Type": "text/javascript; charset=utf-8", - }); - createReadStream(new URL(`./.cache/${asset}`, import.meta.url)).pipe( - response, - ); -} - -server.listen(49276, ["::1", "127.0.0.1"]); diff --git a/test/fast-auth.spec.ts b/test/fast-auth.spec.ts index 8886eea..aad1737 100644 --- a/test/fast-auth.spec.ts +++ b/test/fast-auth.spec.ts @@ -1,21 +1,24 @@ -import { expect, test } from "@playwright/test"; -import fs from "fs"; +import { expect, test } from "vitest"; +import { commands } from "vitest/browser"; import xml from "@xmpp/xml"; import { parse } from "ltx"; +import * as borogove from "../playwright/.cache/borogove.js"; +import type {} from "./socket-bridge.mts"; const BIND2 = "urn:xmpp:bind:0"; const SASL2 = "urn:xmpp:sasl:2"; const FRAMING = "urn:ietf:params:xml:ns:xmpp-framing"; const SASL = "urn:ietf:params:xml:ns:xmpp-sasl"; const STREAM = "http://etherx.jabber.org/streams"; -const BOROGOVE_CODE = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + +const websocketUrl = "ws://127.0.0.1/xmpp"; const HOST_META = xml( "XRD", { xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0" }, xml("Link", { rel: "urn:xmpp:alt-connections:websocket", - href: "ws://127.0.0.1/xmpp", + href: websocketUrl, }), ).toString(); const OPEN = xml("open", { @@ -51,104 +54,99 @@ const NOT_AUTHORIZED = xml( ).toString(); const CLOSE = xml("close", { xmlns: FRAMING }).toString(); -test.beforeEach(async ({ page }) => { - await page.route("https://localhost/", (route) => - route.fulfill({ body: "<html></html>" }), - ); - await page.route("https://127.0.0.1/.well-known/host-meta", (route) => - route.fulfill({ - body: HOST_META, - contentType: "application/xrd+xml", - }), - ); -}); +async function nextStanza(connection: string, name: string, namespace: string) { + const event = await commands.socketNextEvent(); + expect(event).toMatchObject({ type: "message", connection }); + if (event.type !== "message") throw new Error("Expected a socket message"); + const stanza = parse(event.message); + expect(stanza.is(name, namespace), event.message).toBe(true); + return stanza; +} + +async function nextConnection() { + const event = await commands.socketNextEvent(); + expect(event.type).toBe("connection"); + return event.connection; +} -test("a rejected unexpired FAST token uses a non-empty password before reconnecting", async ({ - page, -}) => { - let connectionCount = 0; - let resolveSocketClosed: () => void; - const socketClosed = new Promise<void>((resolve) => { - resolveSocketClosed = resolve; +test("a rejected unexpired FAST token uses a non-empty password before reconnecting", async () => { + const persistence = new borogove.persistence.Dummy(); + persistence.getLogin = async () => ({ + clientId: "test-client", + displayName: "Test", + fastCount: 0, + token: JSON.stringify({ + token: "persisted secret", + expiry: "2099-01-01T00:00:00Z", + mechanism: "HT-SHA-256-NONE", + }), }); - await page.routeWebSocket("ws://127.0.0.1/xmpp", (socket) => { - connectionCount++; - socket.onMessage((message: string) => { - const stanza = parse(message); - expect(stanza.is("open", FRAMING)).toBe(true); - if (connectionCount === 2) { - socket.onMessage((message: string) => { - const stanza = parse(message); - expect(stanza.is("close", FRAMING)).toBe(true); - socket.send(CLOSE); - }); - socket.send(OPEN); - socket.send(FEATURES); - return; - } - socket.onMessage((message: string) => { - const stanza = parse(message); - expect(stanza.is("authenticate", SASL2)).toBe(true); - expect(stanza.attrs.mechanism).toBe("HT-SHA-256-NONE"); - expect(stanza.getChildText("initial-response")).toBe( - "dGVzdGVyANlEKT65Z6grrZHbeSjhb05VNRnDs6X1T3Wh0pHQg7cv", - ); - socket.onMessage((message: string) => { - const stanza = parse(message); - expect(stanza.is("authenticate", SASL2)).toBe(true); - expect(stanza.attrs.mechanism).toBe("PLAIN"); - const [, username, password] = atob( - stanza.getChildText("initial-response"), - ).split("\0"); - expect(username).toBe("tester"); - expect(password).not.toBe(""); - expect(password).not.toBe("persisted secret"); - socket.send(NOT_AUTHORIZED); - }); - socket.send(NOT_AUTHORIZED); - }); - socket.send(OPEN); - socket.send(FEATURES); - }); - socket.onClose(() => resolveSocketClosed()); + persistence.getStreamManagement = async () => ({ + sortId: "a ", + sm: null, + }); + persistence.getChats = async () => []; + persistence.getChatsUnreadDetails = async () => []; + const client = new borogove.Client("tester@127.0.0.1", persistence); + let passwordRequested = false; + client.addPasswordNeededListener(() => { + passwordRequested = true; }); - await page.goto("https://localhost/"); - await page.evaluate( - async ({ code }) => { - const moduleUrl = URL.createObjectURL( - new Blob([code], { type: "text/javascript" }), - ); - const borogove = await import(moduleUrl); - const persistence = new borogove.persistence.Dummy(); - persistence.getLogin = async () => ({ - clientId: "test-client", - displayName: "Test", - fastCount: 0, - token: JSON.stringify({ - token: "persisted secret", - expiry: "2099-01-01T00:00:00Z", - mechanism: "HT-SHA-256-NONE", - }), - }); - persistence.getStreamManagement = async () => ({ - sortId: "a ", - sm: null, - }); - persistence.getChats = async () => []; - persistence.getChatsUnreadDetails = async () => []; - const client = new borogove.Client("tester@127.0.0.1", persistence); - client.stream.debug = false; - const passwordRequested = Promise.withResolvers<void>(); - client.addPasswordNeededListener(() => { - passwordRequested.resolve(); - }); - client.start(); - await passwordRequested.promise; + try { + await commands.socketSetup({ + websocketUrls: [websocketUrl], + httpResponses: [ + { + url: "https://127.0.0.1/.well-known/host-meta", + body: HOST_META, + contentType: "application/xrd+xml", + headers: { "Access-Control-Allow-Origin": "*" }, + }, + ], + }); + client.start(); + const first = await nextConnection(); + await nextStanza(first, "open", FRAMING); + await commands.socketSend(first, OPEN); + await commands.socketSend(first, FEATURES); + const fast = await nextStanza(first, "authenticate", SASL2); + expect(fast.attrs.mechanism).toBe("HT-SHA-256-NONE"); + expect(fast.getChildText("initial-response")).toBe( + "dGVzdGVyANlEKT65Z6grrZHbeSjhb05VNRnDs6X1T3Wh0pHQg7cv", + ); + await commands.socketSend(first, NOT_AUTHORIZED); + const plain = await nextStanza(first, "authenticate", SASL2); + expect(plain.attrs.mechanism).toBe("PLAIN"); + const [, username, password] = atob( + plain.getChildText("initial-response"), + ).split("\0"); + expect(username).toBe("tester"); + expect(password).toBeTruthy(); + expect(password).not.toBe("persisted secret"); + await commands.socketSend(first, NOT_AUTHORIZED); + const second = await nextConnection(); + expect(second).not.toBe(first); + await nextStanza(second, "open", FRAMING); + await commands.socketSend(second, OPEN); + await commands.socketSend(second, FEATURES); + await expect.poll(() => passwordRequested).toBe(true); + client.stream.disconnect(); + await nextStanza(second, "close", FRAMING); + await commands.socketSend(second, CLOSE); + expect(await commands.socketNextEvent()).toEqual({ + type: "close", + connection: second, + }); + expect(await commands.socketFinish()).toEqual({ + connectionCount: 2, + events: [], + }); + } finally { + try { client.stream.disconnect(); - }, - { code: BOROGOVE_CODE }, - ); - await socketClosed; - expect(connectionCount).toBe(2); -}); + } finally { + await commands.socketFinish(); + } + } +}, 30000); diff --git a/test/socket-bridge.mts b/test/socket-bridge.mts new file mode 100644 index 0000000..4cdd304 --- /dev/null +++ b/test/socket-bridge.mts @@ -0,0 +1,161 @@ +import { playwright, defineBrowserCommand } from "@vitest/browser-playwright"; +import type { Page, WebSocketRoute } from "playwright"; + +type SocketEvent = + | { type: "connection" | "close"; connection: string } + | { type: "message"; connection: string; message: string }; +interface HttpResponse { + url: string; + body: string; + contentType: string; + headers?: Record<string, string>; +} +interface SocketSetup { + websocketUrls: string[]; + httpResponses: HttpResponse[]; +} +interface Bridge { + websocketUrls: Set<string>; + sockets: Map<string, WebSocketRoute>; + events: SocketEvent[]; + responses: HttpResponse[]; + pending?: { + resolve: (event: SocketEvent) => void; + reject: (error: Error) => void; + }; +} +const bridges = new WeakMap<Page, Bridge>(); +function bridgeFor(page: Page) { + const bridge = bridges.get(page); + if (!bridge) throw new Error("Socket bridge is not set up"); + return bridge; +} +function enqueue(bridge: Bridge, event: SocketEvent) { + if (bridge.pending) bridge.pending.resolve(event); + else bridge.events.push(event); +} + +// WebSocket interception uses an init script and must precede navigation. +export function socketPlaywright() { + const provider = playwright(); + const createProvider = provider.providerFactory; + provider.providerFactory = (project) => { + const instance = createProvider(project); + const openPage = instance.openPage.bind(instance); + instance.openPage = async (sessionId, url, options) => { + await openPage(sessionId, "about:blank", options); + const { page } = instance.getCommandsContext(sessionId) as { page: Page }; + await page.routeWebSocket( + () => true, + (socket) => { + const bridge = bridges.get(page); + if (!bridge?.websocketUrls.has(socket.url())) { + socket.connectToServer(); + return; + } + const connection = crypto.randomUUID(); + bridge.sockets.set(connection, socket); + enqueue(bridge, { type: "connection", connection }); + socket.onMessage((message) => { + enqueue(bridge, { + type: "message", + connection, + message: message.toString(), + }); + }); + socket.onClose(() => enqueue(bridge, { type: "close", connection })); + }, + ); + await page.goto(url, { timeout: 0 }); + }; + return instance; + }; + return provider; +} + +async function finish(page: Page) { + const bridge = bridges.get(page); + if (!bridge) return { connectionCount: 0, events: [] as SocketEvent[] }; + bridges.delete(page); + bridge.pending?.reject( + new Error("Socket bridge closed while waiting for an event"), + ); + const result = { + connectionCount: bridge.sockets.size, + events: [...bridge.events], + }; + try { + await Promise.all( + [...bridge.sockets.values()].map((socket) => socket.close()), + ); + } finally { + await Promise.all( + bridge.responses.map((response) => page.unroute(response.url)), + ); + bridge.events.length = 0; + bridge.sockets.clear(); + } + return result; +} + +export const socketCommands = { + socketSetup: defineBrowserCommand( + async ({ page }, { websocketUrls, httpResponses }: SocketSetup) => { + await finish(page); + bridges.set(page, { + websocketUrls: new Set(websocketUrls), + sockets: new Map(), + events: [], + responses: httpResponses, + }); + for (const { url, ...response } of httpResponses) { + await page.route(url, (route) => route.fulfill(response)); + } + }, + ), + socketNextEvent: defineBrowserCommand(({ page }): Promise<SocketEvent> => { + const bridge = bridgeFor(page); + if (bridge.pending) + throw new Error("Only one socket event read may be pending"); + const event = bridge.events.shift(); + if (event) return Promise.resolve(event); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + bridge.pending = undefined; + reject(new Error("Timed out after 5000ms waiting for a socket event")); + }, 5000); + bridge.pending = { + resolve: (event) => { + clearTimeout(timer); + bridge.pending = undefined; + resolve(event); + }, + reject: (error) => { + clearTimeout(timer); + bridge.pending = undefined; + reject(error); + }, + }; + }); + }), + socketSend: defineBrowserCommand( + ({ page }, connection: string, message: string) => { + const socket = bridgeFor(page).sockets.get(connection); + if (!socket) throw new Error(`Unknown socket connection: ${connection}`); + socket.send(message); + }, + ), + socketFinish: defineBrowserCommand(({ page }) => finish(page)), +}; + +declare module "vitest/browser" { + interface BrowserCommands { + socketSetup: (options: SocketSetup) => Promise<void>; + socketNextEvent: () => Promise<SocketEvent>; + socketSend: (connection: string, message: string) => Promise<void>; + socketFinish: () => Promise<{ + connectionCount: number; + events: SocketEvent[]; + }>; + } +} diff --git a/vitest.config.mts b/vitest.config.mts index c006a39..7ddf26c 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,9 +1,11 @@ import { defineConfig } from "vitest/config"; -import { playwright } from "@vitest/browser-playwright"; import { createReadStream } from "node:fs"; +import { socketCommands, socketPlaywright } from "./test/socket-bridge.mts"; + export default defineConfig({ root: "..", + optimizeDeps: { include: ["@xmpp/xml", "ltx"] }, plugins: [ { name: "serve-sqlite-worker", @@ -18,21 +20,38 @@ export default defineConfig({ }, ], test: { - include: ["test/idb.spec.ts", "test/sqlite.spec.ts"], + silent: "passed-only", + include: [ + "test/idb.spec.ts", + "test/sqlite.spec.ts", + "test/fast-auth.spec.ts", + ], browser: { enabled: true, - provider: playwright(), + provider: socketPlaywright(), + commands: socketCommands, headless: true, instances: [ { browser: "chromium", - include: ["test/idb.spec.ts", "test/sqlite.spec.ts"], + include: [ + "test/idb.spec.ts", + "test/sqlite.spec.ts", + "test/fast-auth.spec.ts", + ], }, { browser: "firefox", - include: ["test/idb.spec.ts", "test/sqlite.spec.ts"], + include: [ + "test/idb.spec.ts", + "test/sqlite.spec.ts", + "test/fast-auth.spec.ts", + ], + }, + { + browser: "webkit", + include: ["test/idb.spec.ts", "test/fast-auth.spec.ts"], }, - { browser: "webkit", include: ["test/idb.spec.ts"] }, ], }, },