@specific.dev/spectest 0.102.0 → 0.104.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.
@@ -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
+ }
package/src/index.ts CHANGED
@@ -78,6 +78,9 @@ export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js
78
78
  export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
79
79
 
80
80
  export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
81
+ export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
82
+ export type { FakeWebSocket, FakeWebSocketHandlers } from "./fake-websocket.js";
83
+ import type { FakeWebSocketHandlers } from "./fake-websocket.js";
81
84
 
82
85
  import type { Browser, BrowserOptions } from "./browser.js";
83
86
 
@@ -2171,6 +2174,11 @@ export interface FakeDefinition<
2171
2174
  * also applies to state/helper factories and helper implementations.
2172
2175
  */
2173
2176
  handler: (req: Request, state: S, ctx: FakeContext) => Response | Promise<Response>;
2177
+ /** Optional WebSocket handshake on the same hostnames as HTTP. Return a
2178
+ * Response to reject, or connection callbacks to accept. Callbacks share
2179
+ * this fake's state and run uninstrumented, in order per connection. */
2180
+ websocket?: (req: Request, state: S, ctx: FakeContext) =>
2181
+ FakeWebSocketHandlers | Response | Promise<FakeWebSocketHandlers | Response>;
2174
2182
  /**
2175
2183
  * Build the helpers exposed at `ctx.fakes.<name>` — a record of
2176
2184
  * **functions** that read or mutate the fake's `state` (received here)
@@ -2239,6 +2247,9 @@ export function defineFake<
2239
2247
  if (typeof opts.handler !== "function") {
2240
2248
  throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
2241
2249
  }
2250
+ if (opts.websocket !== undefined && typeof opts.websocket !== "function") {
2251
+ throw new Error(`defineFake(${opts.name}): \`websocket\` must be a function`);
2252
+ }
2242
2253
  return opts;
2243
2254
  }
2244
2255
 
@@ -0,0 +1,143 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { prepareMicrophoneFixture } from "./harness/microphone-fixture.js";
6
+ import { acquirePersistentBrowser, openMobileBackend, type MobileBackend } from "./browser.js";
7
+
8
+ let dir: string;
9
+ function wav(frequency: number, seconds = 1): Buffer {
10
+ const rate = 48000;
11
+ const size = Math.floor(rate * seconds) * 2;
12
+ const out = Buffer.alloc(44 + size);
13
+ out.write("RIFF"); out.writeUInt32LE(36 + size, 4); out.write("WAVEfmt ", 8);
14
+ out.writeUInt32LE(16, 16); out.writeUInt16LE(1, 20); out.writeUInt16LE(1, 22);
15
+ out.writeUInt32LE(rate, 24); out.writeUInt32LE(rate * 2, 28);
16
+ out.writeUInt16LE(2, 32); out.writeUInt16LE(16, 34);
17
+ out.write("data", 36); out.writeUInt32LE(size, 40);
18
+ for (let i = 0; i < size / 2; i++) out.writeInt16LE(Math.round(16000 * Math.sin(2 * Math.PI * frequency * i / rate)), 44 + i * 2);
19
+ return out;
20
+ }
21
+ beforeAll(async () => {
22
+ dir = await mkdtemp(join(tmpdir(), "spectest-audio-test-"));
23
+ await writeFile(join(dir, "440%name.wav"), wav(440));
24
+ await writeFile(join(dir, "880.wav"), wav(880));
25
+ await writeFile(join(dir, "invalid.wav"), "not audio");
26
+ });
27
+ afterAll(async () => { await rm(dir, { recursive: true, force: true }); });
28
+
29
+ test("stages WAV bytes under an immutable safe path and distinguishes looping", async () => {
30
+ for (const loop of [false, true]) {
31
+ const fixture = await prepareMicrophoneFixture({ path: join(dir, "440%name.wav"), loop });
32
+ const path = fixture.arg.split("=")[1]!.replace(/%noloop$/, "");
33
+ expect(path).not.toContain("%");
34
+ const staged = await readFile(path);
35
+ if (loop) expect(staged).toEqual(wav(440));
36
+ else {
37
+ expect(staged.subarray(44, wav(440).length)).toEqual(wav(440).subarray(44));
38
+ expect(staged.subarray(wav(440).length).every(byte => byte === 0)).toBe(true);
39
+ }
40
+ expect(fixture.arg.endsWith("%noloop")).toBe(!loop);
41
+ await fixture.cleanup();
42
+ expect(await Bun.file(path).exists()).toBe(false);
43
+ }
44
+ });
45
+
46
+ test("missing and invalid audio fail before Chromium launches", async () => {
47
+ await expect(prepareMicrophoneFixture({ path: join(dir, "missing.wav") })).rejects.toThrow("microphone fixture");
48
+ await expect(prepareMicrophoneFixture({ path: join(dir, "invalid.wav") })).rejects.toThrow("16-bit PCM");
49
+ await expect(prepareMicrophoneFixture({ path: "" })).rejects.toThrow("microphone.path");
50
+ });
51
+
52
+ async function capture(browser: MobileBackend): Promise<void> {
53
+ await browser.silentRead(page => page.evaluate(async () => {
54
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: {
55
+ echoCancellation: false, noiseSuppression: false, autoGainControl: false,
56
+ } });
57
+ const context = new AudioContext();
58
+ const analyser = context.createAnalyser();
59
+ analyser.fftSize = 4096;
60
+ context.createMediaStreamSource(stream).connect(analyser);
61
+ await context.resume();
62
+ (globalThis as any).__mic = { stream, context, analyser };
63
+ }));
64
+ }
65
+
66
+ async function peak(browser: MobileBackend): Promise<number> {
67
+ return browser.silentRead(page => page.evaluate(async () => {
68
+ const { context, analyser } = (globalThis as any).__mic;
69
+ const bins = new Float32Array(analyser.frequencyBinCount);
70
+ for (let i = 0; i < 100; i++) {
71
+ analyser.getFloatFrequencyData(bins);
72
+ const max = Math.max(...bins);
73
+ if (max > -40) return bins.indexOf(max) * context.sampleRate / analyser.fftSize;
74
+ await new Promise(resolve => setTimeout(resolve, 20));
75
+ }
76
+ throw new Error("microphone produced no audible fixture");
77
+ }));
78
+ }
79
+
80
+ describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome"))("native Chromium microphone", () => {
81
+ let server: ReturnType<typeof Bun.serve>;
82
+ beforeAll(() => { server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<html>microphone</html>", { headers: { "content-type": "text/html" } }) }); });
83
+ afterAll(() => server.stop(true));
84
+
85
+ test("captures the fixture through native tracks and MediaRecorder", async () => {
86
+ const browser = await openMobileBackend({ url: server.url.href, microphone: { path: join(dir, "440%name.wav"), loop: true } });
87
+ try {
88
+ await capture(browser);
89
+ expect(await peak(browser)).toBeCloseTo(440, -1);
90
+ const result = await browser.silentRead(page => page.evaluate(async () => {
91
+ const { stream } = (globalThis as any).__mic;
92
+ const recorder = new MediaRecorder(stream);
93
+ const chunks: Blob[] = [];
94
+ recorder.ondataavailable = e => chunks.push(e.data);
95
+ const stopped = new Promise<void>(resolve => { recorder.onstop = () => resolve(); });
96
+ recorder.start();
97
+ await new Promise(resolve => setTimeout(resolve, 150));
98
+ recorder.stop(); await stopped;
99
+ const track = stream.getAudioTracks()[0];
100
+ const before = track.readyState;
101
+ track.stop();
102
+ return { before, after: track.readyState, size: new Blob(chunks).size };
103
+ }));
104
+ expect(result.before).toBe("live");
105
+ expect(result.after).toBe("ended");
106
+ expect(result.size).toBeGreaterThan(0);
107
+ } finally { await browser.close(); }
108
+ }, 20000);
109
+
110
+ test("named sessions have isolated fixtures and reattachment preserves the original source", async () => {
111
+ const a = await acquirePersistentBrowser("audio-a", { url: server.url.href, microphone: { path: join(dir, "440%name.wav"), loop: true } });
112
+ const b = await acquirePersistentBrowser("audio-b", { url: server.url.href, microphone: { path: join(dir, "880.wav"), loop: true } });
113
+ try {
114
+ await Promise.all([capture(a.browser), capture(b.browser)]);
115
+ expect(await peak(a.browser)).toBeCloseTo(440, -1);
116
+ expect(await peak(b.browser)).toBeCloseTo(880, -1);
117
+ await a.detach();
118
+ const attached = await acquirePersistentBrowser("audio-a");
119
+ expect(attached.attached).toBe(true);
120
+ expect(await peak(attached.browser)).toBeCloseTo(440, -1);
121
+ await attached.detach();
122
+ } finally { await Promise.all([a.browser.close(), b.browser.close()]); }
123
+ }, 20000);
124
+
125
+ test("single playback becomes silence without ending the media track", async () => {
126
+ const browser = await openMobileBackend({ url: server.url.href, microphone: { path: join(dir, "440%name.wav") } });
127
+ try {
128
+ await capture(browser);
129
+ expect(await peak(browser)).toBeCloseTo(440, -1);
130
+ const result = await browser.silentRead(page => page.evaluate(async () => {
131
+ const { stream, analyser } = (globalThis as any).__mic;
132
+ const samples = new Float32Array(analyser.fftSize);
133
+ for (let i = 0; i < 100; i++) {
134
+ await new Promise(resolve => setTimeout(resolve, 30));
135
+ analyser.getFloatTimeDomainData(samples);
136
+ if (Math.max(...samples.map(Math.abs)) < 0.001) return stream.getAudioTracks()[0].readyState;
137
+ }
138
+ throw new Error("single playback did not become silent");
139
+ }));
140
+ expect(result).toBe("live");
141
+ } finally { await browser.close(); }
142
+ }, 20000);
143
+ });