git » sdk » commit 9dca68c

Move fast-auth playwright test to vitest

author Eric Roberts
2026-09-15 15:14:26 UTC
committer Stephen Paul Weber
2026-09-15 17:38:31 UTC
parent e5ce4bee1a8204bc6e95f0838aae7546951476f6

Move fast-auth playwright test to vitest

.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"] },
 			],
 		},
 	},