@specific.dev/spectest 0.102.0 → 0.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/browser.ts CHANGED
@@ -35,6 +35,8 @@ import { fileURLToPath } from "node:url";
35
35
 
36
36
  import { generateId } from "./ids.js";
37
37
  import { rawFetch } from "./harness/raw-fetch.js";
38
+ import { prepareMicrophoneFixture, type MicrophoneFixture } from "./harness/microphone-fixture.js";
39
+ export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
38
40
  import { DESKTOP_INPUT_SYNC_SCRIPT } from "./desktop-recorder.js";
39
41
  import type { DesktopChrome } from "./desktop.js";
40
42
  import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
@@ -91,6 +93,10 @@ export interface BrowserOptions {
91
93
  frame?: "browser" | "mobile";
92
94
  /** Initial URL to navigate to before the constructor returns. */
93
95
  url?: string;
96
+ /** Feed a WAV fixture through Chromium's native microphone capture.
97
+ * Set on the session's first creation; inherited sessions keep their source.
98
+ * Fixture sessions use a dedicated Chromium process for audio isolation. */
99
+ microphone?: MicrophoneFixture;
94
100
  /**
95
101
  * Script installed (before the session's first navigation) to run in every
96
102
  * document loaded from now on, BEFORE the document's own scripts — the
@@ -406,8 +412,8 @@ export interface MobileBackend extends Browser {
406
412
  // What the app gets is Chromium's own synthetic devices: the microphone is a
407
413
  // generated beep pattern (a live, real MediaStreamTrack — MediaRecorder,
408
414
  // WebRTC and AudioContext analysis all work on it), the camera a rolling
409
- // test pattern at 640x480. There is deliberately NO way to feed a recording
410
- // in; this is device availability, not audio fixtures.
415
+ // test pattern at 640x480. A microphone fixture adds Chromium's file-capture
416
+ // flag in a dedicated process; getUserMedia and track behavior stay native.
411
417
  //
412
418
  // --use-fake-device-for-media-stream: the synthetic camera + microphone.
413
419
  // Also what makes `enumerateDevices()` return anything at all.
@@ -442,14 +448,16 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
442
448
  // Shared Chromium (playwright-core)
443
449
  // ────────────────────────────────────────────────────────────────────────
444
450
 
445
- // One Chromium per daemon, launched lazily on first view creation and kept
446
- // for the daemon's lifetime. It rides snapshots: the process, playwright's
451
+ // Default sessions share one Chromium per daemon, launched lazily and kept
452
+ // for the daemon's lifetime. Fixture sessions own a process because the audio
453
+ // source is a process-wide flag. Both ride snapshots: the process, playwright's
447
454
  // pipe connection to it, and every open page freeze into the VM snapshot
448
455
  // and resume in each fork (like dockerd and the daemon itself). Profile
449
456
  // state — cookies, localStorage — is per-Chromium, and the in-VM CA trust
450
457
  // comes from the HOME-scoped NSS user DB, so neither cares that playwright
451
458
  // runs a temp --user-data-dir.
452
459
  let PW_BROWSER: PlaywrightBrowser | null = null;
460
+ const VIEW_CLEANUPS = new WeakMap<BrowserContext, () => Promise<void>>();
453
461
  // Every view gets its OWN context, desktop included. A context is the cookie
454
462
  // jar + storage, so one-context-per-view is what makes `close()` mean what
455
463
  // the docs promise: the browsing session is destroyed and the next
@@ -502,20 +510,46 @@ async function newViewContext(
502
510
  width: number,
503
511
  height: number,
504
512
  device: DevicePreset | null,
513
+ microphone?: MicrophoneFixture,
505
514
  ): Promise<BrowserContext> {
506
- const browser = await ensurePlaywrightBrowser();
507
- const context = await browser.newContext(
508
- device
509
- ? deviceContextOptions(device)
510
- : {
511
- viewport: { width, height },
512
- // Reduced-motion for replay fidelity — see deviceContextOptions.
513
- reducedMotion: "reduce",
514
- },
515
- );
516
- context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
517
- context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
518
- return context;
515
+ const fixture = microphone === undefined ? undefined : await prepareMicrophoneFixture(microphone);
516
+ let browser: PlaywrightBrowser | undefined;
517
+ try {
518
+ browser = fixture ? await chromium.launch({
519
+ executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
520
+ }) : await ensurePlaywrightBrowser();
521
+ const context = await browser.newContext(
522
+ device
523
+ ? deviceContextOptions(device)
524
+ : {
525
+ viewport: { width, height },
526
+ // Reduced-motion for replay fidelity — see deviceContextOptions.
527
+ reducedMotion: "reduce",
528
+ },
529
+ );
530
+ context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
531
+ context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
532
+ if (fixture) {
533
+ const owned = browser;
534
+ VIEW_CLEANUPS.set(context, async () => {
535
+ try { await owned.close(); } finally { await fixture.cleanup(); }
536
+ });
537
+ }
538
+ return context;
539
+ } catch (err) {
540
+ if (fixture) {
541
+ try { await browser?.close(); } finally { await fixture.cleanup(); }
542
+ }
543
+ throw err;
544
+ }
545
+ }
546
+
547
+ async function closeViewContext(context: BrowserContext): Promise<void> {
548
+ try { await context.close(); } finally {
549
+ const cleanup = VIEW_CLEANUPS.get(context);
550
+ VIEW_CLEANUPS.delete(context);
551
+ await cleanup?.();
552
+ }
519
553
  }
520
554
 
521
555
  /**
@@ -1315,7 +1349,7 @@ export async function openMobileBackend(
1315
1349
  // views (in the shared desktop context), and a mobile view needs its own
1316
1350
  // emulated context anyway.
1317
1351
  const pooled =
1318
- !device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
1352
+ !device && opts.microphone === undefined && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
1319
1353
  let holder: ViewHolder;
1320
1354
  if (pooled) {
1321
1355
  holder = {
@@ -1331,7 +1365,7 @@ export async function openMobileBackend(
1331
1365
  initScripts: [],
1332
1366
  };
1333
1367
  } else {
1334
- holder = await newHolder(wantW, wantH, device);
1368
+ holder = await newHolder(wantW, wantH, device, opts.microphone);
1335
1369
  }
1336
1370
 
1337
1371
  const { backend } = buildBackend(holder, opts.recorder ?? null, {
@@ -1341,11 +1375,14 @@ export async function openMobileBackend(
1341
1375
  // We deliberately don't forward `opts.url` to the constructor — going
1342
1376
  // through our own `navigate()` keeps the recorder log uniform (one
1343
1377
  // event per navigation, with timing) and drains rrweb after the load.
1344
- if (opts.initScript !== undefined) await installInitScript(holder, opts.initScript);
1345
- if (opts.url !== undefined) {
1346
- await backend.goto(opts.url);
1378
+ try {
1379
+ if (opts.initScript !== undefined) await installInitScript(holder, opts.initScript);
1380
+ if (opts.url !== undefined) await backend.goto(opts.url);
1381
+ return backend;
1382
+ } catch (err) {
1383
+ await backend.close();
1384
+ throw err;
1347
1385
  }
1348
- return backend;
1349
1386
  }
1350
1387
 
1351
1388
  // ────────────────────────────────────────────────────────────────────────
@@ -1400,9 +1437,14 @@ async function newHolder(
1400
1437
  width: number,
1401
1438
  height: number,
1402
1439
  device: DevicePreset | null,
1440
+ microphone?: MicrophoneFixture,
1403
1441
  ): Promise<ViewHolder> {
1404
- const context = await newViewContext(width, height, device);
1405
- const spawned = await spawnPage(context);
1442
+ const context = await newViewContext(width, height, device, microphone);
1443
+ let spawned: SpawnedPage;
1444
+ try { spawned = await spawnPage(context); } catch (err) {
1445
+ await closeViewContext(context);
1446
+ throw err;
1447
+ }
1406
1448
  const holder: ViewHolder = {
1407
1449
  context,
1408
1450
  page: spawned.page,
@@ -1497,6 +1539,7 @@ export async function acquirePersistentBrowser(
1497
1539
  device ? device.viewport.width : opts.width ?? 1280,
1498
1540
  device ? device.viewport.height : opts.height ?? 720,
1499
1541
  device,
1542
+ opts.microphone,
1500
1543
  );
1501
1544
  SHARED_BROWSERS.set(name, holder);
1502
1545
  }
@@ -2163,7 +2206,7 @@ function buildBackend(
2163
2206
  // The context, not the page, holds the cookies and storage — closing
2164
2207
  // only the page would leave the next session signed in as this one.
2165
2208
  try {
2166
- if (buildOpts.ownsContext !== false) await holder.context.close();
2209
+ if (buildOpts.ownsContext !== false) await closeViewContext(holder.context);
2167
2210
  } catch {
2168
2211
  /* context already gone (browser died) */
2169
2212
  }
package/src/daemon.ts CHANGED
@@ -91,6 +91,7 @@ import {
91
91
  unionDockerignore,
92
92
  } from "./harness/build-context.js";
93
93
  import { startServices } from "./harness/service-graph.js";
94
+ import { dispatchFake } from "./harness/fake-websocket.js";
94
95
  import {
95
96
  casesMetadata as catalogueCases,
96
97
  groupsMetadata as catalogueGroups,
@@ -2458,17 +2459,7 @@ async function dispatchIngress(
2458
2459
  // The real answer — a fake handled in-process, or the proxied container.
2459
2460
  const upstream = async (current: Request): Promise<Response> => {
2460
2461
  if (route.kind === "fake") {
2461
- try {
2462
- return await runUninstrumented(() =>
2463
- route.fake.def.handler(current, route.fake.state, FAKE_CTX),
2464
- );
2465
- } catch (err) {
2466
- const e = err as Error;
2467
- return new Response(
2468
- `spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`,
2469
- { status: 500, headers: { "content-type": "text/plain" } },
2470
- );
2471
- }
2462
+ return dispatchFake(current, server, route.fake.def, route.fake.state, FAKE_CTX);
2472
2463
  }
2473
2464
  return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
2474
2465
  };
@@ -0,0 +1,17 @@
1
+ /** A connection accepted by a fake. Text and binary messages retain their type. */
2
+ export interface FakeWebSocket {
3
+ readonly readyState: number;
4
+ send(message: string | Uint8Array | ArrayBuffer): void;
5
+ close(code?: number, reason?: string): void;
6
+ }
7
+
8
+ /** Returned by a fake's websocket handshake callback to accept a connection.
9
+ * Callbacks run in order per connection, including async callbacks. Capture
10
+ * fake state and connection-local state in the handshake callback's closure. */
11
+ export interface FakeWebSocketHandlers {
12
+ /** An offered Sec-WebSocket-Protocol to select, if the client requires one. */
13
+ protocol?: string;
14
+ open?(socket: FakeWebSocket): void | Promise<void>;
15
+ message?(socket: FakeWebSocket, message: string | Uint8Array): void | Promise<void>;
16
+ close?(socket: FakeWebSocket, code: number, reason: string): void | Promise<void>;
17
+ }
@@ -0,0 +1,54 @@
1
+ import type WebSocket from "ws";
2
+ import type { FakeWebSocket, FakeWebSocketHandlers } from "../fake-websocket.js";
3
+
4
+ const MAX_BUFFER = 16 * 1024 * 1024;
5
+ const MAX_PENDING = 1024;
6
+
7
+ /** Serialize async fake callbacks without allowing a slow handler or peer to
8
+ * retain unbounded audio. A handler failure closes just this connection. */
9
+ export function attachFakeWebSocket(ws: WebSocket, handlers: FakeWebSocketHandlers): void {
10
+ let tail = Promise.resolve();
11
+ let pending = 0;
12
+ let bytes = 0;
13
+ let failed = false;
14
+ const socket: FakeWebSocket = {
15
+ get readyState() { return ws.readyState; },
16
+ send(message) {
17
+ if (ws.readyState !== 1) throw new Error("WebSocket is not open");
18
+ const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
19
+ if (ws.bufferedAmount + size > MAX_BUFFER) {
20
+ failed = true;
21
+ ws.close(1009, "fake output buffer exceeded");
22
+ throw new Error("fake WebSocket output buffer exceeded");
23
+ }
24
+ ws.send(message);
25
+ },
26
+ close: (code, reason) => ws.close(code, reason),
27
+ };
28
+ const fail = (err: unknown) => {
29
+ failed = true;
30
+ console.error("[spectest] fake WebSocket callback failed:", err);
31
+ try { ws.close(1011, "fake handler failed"); } catch { ws.terminate(); }
32
+ };
33
+ const enqueue = (fn: () => void | Promise<void>, size = 0, closing = false) => {
34
+ if (failed && !closing) return;
35
+ if (!closing && (pending >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
36
+ failed = true;
37
+ ws.close(1009, "fake input buffer exceeded");
38
+ return;
39
+ }
40
+ pending++;
41
+ bytes += size;
42
+ tail = tail.then(async () => {
43
+ if (!failed || closing) await fn();
44
+ }).catch(fail).finally(() => { pending--; bytes -= size; });
45
+ };
46
+ ws.on("message", (data, binary) => {
47
+ const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
48
+ const message = binary ? new Uint8Array(buffer) : buffer.toString();
49
+ enqueue(() => handlers.message?.(socket, message), buffer.length);
50
+ });
51
+ ws.on("close", (code, reason) => enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true));
52
+ ws.on("error", fail);
53
+ enqueue(() => handlers.open?.(socket));
54
+ }
@@ -0,0 +1,219 @@
1
+ import { afterEach, expect, test, spyOn } from "bun:test";
2
+ import { once } from "node:events";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { execFileSync } from "node:child_process";
8
+ import { defineFake, type FakeContext, type FakeDefinition } from "../index.js";
9
+ import { dispatchFake } from "./fake-websocket.js";
10
+ import { startIngressServer, type IngressServer } from "./ingress-server.js";
11
+ import { currentInstrumentationScope } from "./instrumentation-scope.js";
12
+ import { startTlsTerminator } from "./tls-terminator.js";
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const WS = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
16
+ const servers: IngressServer[] = [];
17
+ afterEach(() => { for (const s of servers.splice(0)) s.stop(true); });
18
+
19
+ async function serve<S>(def: FakeDefinition<S, any>, state: S) {
20
+ const server = await startIngressServer({ port: 0, hostname: "127.0.0.1",
21
+ fetch: (req, server) => dispatchFake(req, server, def, state, {} as FakeContext),
22
+ });
23
+ servers.push(server);
24
+ return `127.0.0.1:${server.port}`;
25
+ }
26
+
27
+ function client(url: string, protocols?: string[], options?: import("ws").ClientOptions) {
28
+ const ws = new WS(url.startsWith("wss://") ? url : `ws://${url}`, protocols, options);
29
+ const messages: { data: Buffer; binary: boolean }[] = [];
30
+ const waiting: ((value: { data: Buffer; binary: boolean }) => void)[] = [];
31
+ ws.on("message", (data, binary) => {
32
+ const value = { data: Buffer.from(data as Buffer), binary };
33
+ const next = waiting.shift();
34
+ if (next) next(value); else messages.push(value);
35
+ });
36
+ const open = once(ws, "open");
37
+ const closed = once(ws, "close");
38
+ return { ws, open, closed, next: () => messages.length ? Promise.resolve(messages.shift()!) :
39
+ new Promise<{ data: Buffer; binary: boolean }>(resolve => waiting.push(resolve)) };
40
+ }
41
+
42
+ test("HTTP and WebSocket share state, helpers, ordered async callbacks and binary frames", async () => {
43
+ const state = { events: [] as string[], closed: "" };
44
+ const def = defineFake({
45
+ name: "speech", hostnames: ["speech.test"],
46
+ state: () => state,
47
+ handler: (_req, state) => Response.json(state.events),
48
+ helpers: ({ state }) => ({ events: () => [...state.events] }),
49
+ websocket: async (req, state) => {
50
+ expect(new URL(req.url).pathname).toBe("/realtime");
51
+ expect(currentInstrumentationScope()).toBeNull();
52
+ return {
53
+ protocol: "audio",
54
+ async open(ws) {
55
+ await Bun.sleep(10);
56
+ state.events.push("open");
57
+ ws.send("ready");
58
+ },
59
+ async message(ws, message) {
60
+ await Bun.sleep(5);
61
+ expect(currentInstrumentationScope()).toBeNull();
62
+ state.events.push(typeof message === "string" ? message : "binary");
63
+ ws.send(message);
64
+ },
65
+ close(_ws, code, reason) { state.closed = `${code}:${reason}`; },
66
+ };
67
+ },
68
+ });
69
+ const addr = await serve(def, state);
70
+ const c = client(`${addr}/realtime`, ["other", "audio"]);
71
+ await c.open;
72
+ expect(c.ws.protocol).toBe("audio");
73
+ c.ws.send("first");
74
+ c.ws.send(new Uint8Array([0, 128, 255]));
75
+ expect((await c.next()).data.toString()).toBe("ready");
76
+ expect((await c.next()).data.toString()).toBe("first");
77
+ expect(await c.next()).toEqual({ data: Buffer.from([0, 128, 255]), binary: true });
78
+ expect(await (await fetch(`http://${addr}/events`)).json()).toEqual(["open", "first", "binary"]);
79
+ const helpers = await def.helpers!({ name: "speech", state, ctx: {} as FakeContext });
80
+ expect(helpers.events()).toEqual(["open", "first", "binary"]);
81
+ c.ws.close(4001, "finished");
82
+ await c.closed;
83
+ for (let i = 0; !state.closed && i < 50; i++) await Bun.sleep(2);
84
+ expect(state.closed).toBe("4001:finished");
85
+ });
86
+
87
+ test("handshake can reject before upgrade with an HTTP status and body", async () => {
88
+ const addr = await serve(defineFake({ name: "auth", hostnames: ["auth.test"],
89
+ handler: () => new Response("http"),
90
+ websocket: req => req.headers.get("authorization") === "Bearer valid"
91
+ ? {} : new Response("token required", { status: 401 }),
92
+ }), undefined);
93
+ const ws = new WS(`ws://${addr}/realtime`);
94
+ const [req, response] = await once(ws, "unexpected-response");
95
+ const chunks: Buffer[] = [];
96
+ for await (const chunk of response) chunks.push(Buffer.from(chunk));
97
+ expect(response.statusCode).toBe(401);
98
+ expect(Buffer.concat(chunks).toString()).toBe("token required");
99
+ req.destroy();
100
+ });
101
+
102
+ test("an unoffered protocol fails the handshake", async () => {
103
+ const addr = await serve(defineFake({ name: "bad", hostnames: ["bad.test"],
104
+ handler: () => new Response("http"), websocket: () => ({ protocol: "unoffered" }),
105
+ }), undefined);
106
+ const ws = new WS(`ws://${addr}`, ["offered"]);
107
+ const [req, response] = await once(ws, "unexpected-response");
108
+ expect(response.statusCode).toBe(500);
109
+ response.resume();
110
+ req.destroy();
111
+ });
112
+
113
+ test("a throwing async callback closes its connection and later connections still work", async () => {
114
+ const errors = spyOn(console, "error").mockImplementation(() => {});
115
+ try {
116
+ const addr = await serve(defineFake({ name: "errors", hostnames: ["errors.test"],
117
+ handler: () => new Response("healthy"),
118
+ websocket: () => ({ async message(ws, message) {
119
+ if (message === "fail") throw new Error("intentional failure");
120
+ ws.send(message);
121
+ } }),
122
+ }), undefined);
123
+ const c = client(addr);
124
+ await c.open;
125
+ c.ws.send("fail");
126
+ const [code] = await c.closed;
127
+ expect(code).toBe(1011);
128
+ const next = client(addr);
129
+ await next.open;
130
+ next.ws.send("works");
131
+ expect((await next.next()).data.toString()).toBe("works");
132
+ next.ws.close();
133
+ await next.closed;
134
+ expect(errors).toHaveBeenCalled();
135
+ } finally { errors.mockRestore(); }
136
+ });
137
+
138
+ test("HTTP-only fakes retain their existing handling of upgrade requests", async () => {
139
+ const addr = await serve(defineFake({ name: "http", hostnames: ["http.test"],
140
+ handler: () => new Response("no realtime", { status: 404 }),
141
+ }), undefined);
142
+ const ws = new WS(`ws://${addr}`);
143
+ const [req, response] = await once(ws, "unexpected-response");
144
+ expect(response.statusCode).toBe(404);
145
+ response.resume();
146
+ req.destroy();
147
+ });
148
+
149
+ test("WSS uses the shared TLS terminator with real certificate verification", async () => {
150
+ const dir = mkdtempSync(join(tmpdir(), "spectest-fake-wss-"));
151
+ let tls: Awaited<ReturnType<typeof startTlsTerminator>> | undefined;
152
+ try {
153
+ execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
154
+ "-subj", "/CN=speech.test", "-addext", "subjectAltName=DNS:speech.test",
155
+ "-keyout", join(dir, "key.pem"), "-out", join(dir, "cert.pem")], { stdio: "ignore" });
156
+ const leaf = { key: readFileSync(join(dir, "key.pem"), "utf8"), cert: readFileSync(join(dir, "cert.pem"), "utf8") };
157
+ const addr = await serve(defineFake({ name: "tls", hostnames: ["speech.test"],
158
+ handler: () => new Response("http"),
159
+ websocket: req => {
160
+ expect(req.headers.get("host")).toBe("speech.test");
161
+ expect(req.headers.get("cookie")).toBe("session=valid");
162
+ return { open(ws) { ws.send("secure"); }, message(ws, message) { ws.send(message); } };
163
+ },
164
+ }), undefined);
165
+ tls = await startTlsTerminator({ port: 0, hostname: "127.0.0.1", upstreamPort: Number(addr.split(":")[1]), certFor: () => leaf });
166
+ const c = client(`wss://127.0.0.1:${tls.port}`, undefined, {
167
+ servername: "speech.test", ca: leaf.cert, headers: { host: "speech.test", cookie: "session=valid" },
168
+ });
169
+ await c.open;
170
+ expect((await c.next()).data.toString()).toBe("secure");
171
+ c.ws.send(new Uint8Array([1, 2, 3]));
172
+ expect(await c.next()).toEqual({ data: Buffer.from([1, 2, 3]), binary: true });
173
+ c.ws.close(); await c.closed;
174
+ } finally { tls?.close(); rmSync(dir, { recursive: true, force: true }); }
175
+ });
176
+
177
+ test("each handshake owns separate connection-local state", async () => {
178
+ const addr = await serve(defineFake({ name: "sessions", hostnames: ["speech.test"],
179
+ handler: () => new Response("http"), websocket: () => {
180
+ let sequence = 0;
181
+ return { message(ws) { ws.send(String(++sequence)); } };
182
+ },
183
+ }), undefined);
184
+ const a = client(addr), b = client(addr);
185
+ await Promise.all([a.open, b.open]);
186
+ a.ws.send("next"); a.ws.send("next"); b.ws.send("next");
187
+ expect((await a.next()).data.toString()).toBe("1");
188
+ expect((await a.next()).data.toString()).toBe("2");
189
+ expect((await b.next()).data.toString()).toBe("1");
190
+ a.ws.close(); b.ws.close(); await Promise.all([a.closed, b.closed]);
191
+ });
192
+
193
+ test("oversized fake output closes with 1009 instead of buffering it", async () => {
194
+ const errors = spyOn(console, "error").mockImplementation(() => {});
195
+ try {
196
+ const addr = await serve(defineFake({ name: "bounded", hostnames: ["bounded.test"],
197
+ handler: () => new Response("http"),
198
+ websocket: () => ({ message(ws) { ws.send(new Uint8Array(16 * 1024 * 1024 + 1)); } }),
199
+ }), undefined);
200
+ const c = client(addr);
201
+ await c.open;
202
+ c.ws.send("start");
203
+ expect((await c.closed)[0]).toBe(1009);
204
+ } finally { errors.mockRestore(); }
205
+ });
206
+
207
+ test("a slow callback cannot accumulate unlimited pending messages", async () => {
208
+ let release!: () => void;
209
+ const blocked = new Promise<void>(resolve => { release = resolve; });
210
+ try {
211
+ const addr = await serve(defineFake({ name: "slow", hostnames: ["slow.test"],
212
+ handler: () => new Response("http"), websocket: () => ({ open: () => blocked }),
213
+ }), undefined);
214
+ const c = client(addr);
215
+ await c.open;
216
+ for (let i = 0; i < 1100; i++) c.ws.send("queued");
217
+ expect((await c.closed)[0]).toBe(1009);
218
+ } finally { release(); }
219
+ });
@@ -0,0 +1,39 @@
1
+ import type { FakeWebSocketHandlers } from "../fake-websocket.js";
2
+ import type { FakeContext, FakeDefinition } from "../index.js";
3
+ import type { IngressServer } from "./ingress-server.js";
4
+ import { runUninstrumented } from "./instrumentation-scope.js";
5
+ import { parseSubprotocols } from "./ws-bridge.js";
6
+
7
+ /** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
8
+ export async function dispatchFake<S>(
9
+ req: Request,
10
+ server: IngressServer,
11
+ def: FakeDefinition<S, any>,
12
+ state: S,
13
+ ctx: FakeContext,
14
+ ): Promise<Response> {
15
+ try {
16
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
17
+ return await runUninstrumented(() => def.handler(req, state, ctx));
18
+ }
19
+ const accepted = await runUninstrumented(() => def.websocket!(req, state, ctx));
20
+ if (accepted instanceof Response) return accepted;
21
+ if (!accepted || typeof accepted !== "object") throw new Error("websocket must return handlers or a Response");
22
+ if (accepted.protocol !== undefined && !parseSubprotocols(req.headers.get("sec-websocket-protocol")).includes(accepted.protocol)) {
23
+ throw new Error("websocket selected a protocol the client did not offer");
24
+ }
25
+ const handlers: FakeWebSocketHandlers = {
26
+ open: socket => runUninstrumented(() => accepted.open?.(socket)),
27
+ message: (socket, message) => runUninstrumented(() => accepted.message?.(socket, message)),
28
+ close: (socket, code, reason) => runUninstrumented(() => accepted.close?.(socket, code, reason)),
29
+ };
30
+ return server.upgrade(req, {
31
+ handlers,
32
+ headers: accepted.protocol ? { "sec-websocket-protocol": accepted.protocol } : undefined,
33
+ }) ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
34
+ } catch (err) {
35
+ return new Response(`spectest-daemon: fake ${def.name} threw: ${err instanceof Error ? err.message : String(err)}\n`, {
36
+ status: 500, headers: { "content-type": "text/plain" },
37
+ });
38
+ }
39
+ }
@@ -5,6 +5,8 @@ import type { Socket } from "node:net";
5
5
  import { createRequire } from "node:module";
6
6
  import { dirname, join } from "node:path";
7
7
  import { WS_BRIDGE_HANDLERS, type WsBridgeData } from "./ws-bridge.js";
8
+ import type { FakeWebSocketHandlers } from "../fake-websocket.js";
9
+ import { attachFakeWebSocket } from "./fake-websocket-connection.js";
8
10
 
9
11
  // Load the installed implementation, not Bun's built-in `ws` replacement.
10
12
  // On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
@@ -16,7 +18,7 @@ export interface IngressServer {
16
18
  port: number;
17
19
  stop(force?: boolean): void;
18
20
  requestIP(req: Request): { address: string; port: number } | undefined;
19
- upgrade(req: Request, options: { data: WsBridgeData; headers?: Record<string, string> }): boolean;
21
+ upgrade(req: Request, options: ({ data: WsBridgeData } | { handlers: FakeWebSocketHandlers }) & { headers?: Record<string, string> }): boolean;
20
22
  }
21
23
 
22
24
  /**
@@ -42,6 +44,10 @@ export async function startIngressServer(options: {
42
44
  // the client's first offer on its behalf.
43
45
  handleProtocols: (_offered, req) => protocols.get(req) || false,
44
46
  });
47
+ const fakeWebsockets = new WebSocketServer({
48
+ noServer: true, maxPayload: 16 * 1024 * 1024,
49
+ handleProtocols: (_offered, req) => protocols.get(req) || false,
50
+ });
45
51
  const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
46
52
  void dispatch(req, res);
47
53
  });
@@ -58,10 +64,12 @@ export async function startIngressServer(options: {
58
64
  stop(force = false) {
59
65
  if (force) {
60
66
  for (const ws of websockets.clients) ws.terminate();
67
+ for (const ws of fakeWebsockets.clients) ws.terminate();
61
68
  for (const socket of sockets) socket.destroy();
62
69
  server.closeAllConnections();
63
70
  }
64
71
  websockets.close();
72
+ fakeWebsockets.close();
65
73
  server.close();
66
74
  },
67
75
  requestIP(req) {
@@ -73,8 +81,13 @@ export async function startIngressServer(options: {
73
81
  const pending = upgrades.get(req);
74
82
  if (!pending || pending.upgraded || pending.socket.destroyed) return false;
75
83
  protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
76
- websockets.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
84
+ const target = "handlers" in opts ? fakeWebsockets : websockets;
85
+ target.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
77
86
  pending.upgraded = true;
87
+ if ("handlers" in opts) {
88
+ attachFakeWebSocket(ws, opts.handlers);
89
+ return;
90
+ }
78
91
  const client = {
79
92
  data: opts.data,
80
93
  send: (data: string | ArrayBuffer | Uint8Array) => ws.send(data),
@@ -0,0 +1,71 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { resolveExistingProjectPath } from "../project-files.js";
5
+
6
+ export interface MicrophoneFixture {
7
+ /** WAV file relative to the repository root (absolute paths also work).
8
+ * Use 16-bit PCM mono or stereo audio. */
9
+ path: string;
10
+ /** Repeat the recording. Default false: play once, then supply silence. */
11
+ loop?: boolean;
12
+ }
13
+
14
+ /** Stage immutable bytes under a safe filename: Chromium interprets a `%`
15
+ * suffix as playback options, so a project filename cannot be passed verbatim. */
16
+ export async function prepareMicrophoneFixture(fixture: MicrophoneFixture): Promise<{
17
+ arg: string;
18
+ cleanup(): Promise<void>;
19
+ }> {
20
+ if (!fixture || typeof fixture.path !== "string" || !fixture.path.trim()) {
21
+ throw new Error("microphone.path must name a WAV fixture");
22
+ }
23
+ if (fixture.loop !== undefined && typeof fixture.loop !== "boolean") throw new Error("microphone.loop must be a boolean");
24
+ const source = resolveExistingProjectPath(fixture.path, "microphone fixture");
25
+ const bytes = await readFile(source);
26
+ let pcm = false;
27
+ let audio = false;
28
+ let dataAt = 0;
29
+ let dataSize = 0;
30
+ let bytesPerFrame = 0;
31
+ let sampleRate = 0;
32
+ if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WAVE") {
33
+ for (let at = 12; at + 8 <= bytes.length;) {
34
+ const size = bytes.readUInt32LE(at + 4);
35
+ const end = at + 8 + size;
36
+ if (end > bytes.length) break;
37
+ const chunk = bytes.toString("ascii", at, at + 4);
38
+ if (chunk === "fmt " && size >= 16) {
39
+ sampleRate = bytes.readUInt32LE(at + 12);
40
+ bytesPerFrame = bytes.readUInt16LE(at + 10) * 2;
41
+ pcm = bytes.readUInt16LE(at + 8) === 1 && [1, 2].includes(bytes.readUInt16LE(at + 10)) &&
42
+ sampleRate >= 8000 && sampleRate <= 192000 && bytes.readUInt16LE(at + 22) === 16 &&
43
+ bytes.readUInt16LE(at + 20) === bytesPerFrame;
44
+ }
45
+ if (chunk === "data" && size > 0) { audio = true; dataAt = at; dataSize = size; }
46
+ at = end + (size % 2);
47
+ }
48
+ }
49
+ if (!pcm || !audio || dataSize % bytesPerFrame !== 0) throw new Error(`microphone fixture "${fixture.path}" must be a non-empty 16-bit PCM mono or stereo WAV (8–192 kHz)`);
50
+ // Chromium's FileSource returns zero at EOF without clearing the previous
51
+ // capture buffer. Finish with silence so single playback cannot repeat the
52
+ // final speech fragment indefinitely. Leave looping recordings untouched.
53
+ let staged = bytes;
54
+ if (!fixture.loop) {
55
+ const silence = Buffer.alloc(Math.ceil(sampleRate / 4) * bytesPerFrame);
56
+ const end = dataAt + 8 + dataSize;
57
+ staged = Buffer.concat([bytes.subarray(0, end), silence, bytes.subarray(end)]);
58
+ staged.writeUInt32LE(staged.length - 8, 4);
59
+ staged.writeUInt32LE(dataSize + silence.length, dataAt + 4);
60
+ }
61
+ const dir = await mkdtemp(join(tmpdir(), "spectest-microphone-"));
62
+ const cleanup = () => rm(dir, { recursive: true, force: true });
63
+ try {
64
+ const file = join(dir, "audio.wav");
65
+ await writeFile(file, staged);
66
+ return { arg: `--use-file-for-fake-audio-capture=${file}${fixture.loop ? "" : "%noloop"}`, cleanup };
67
+ } catch (err) {
68
+ await cleanup();
69
+ throw err;
70
+ }
71
+ }