@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.
- package/README.md +46 -0
- package/dist/browser.d.ts +14 -1
- package/dist/browser.js +89 -31
- package/dist/daemon.js +2 -7
- package/dist/fake-websocket.d.ts +16 -0
- package/dist/fake-websocket.js +1 -0
- package/dist/harness/fake-websocket-connection.d.ts +5 -0
- package/dist/harness/fake-websocket-connection.js +58 -0
- package/dist/harness/fake-websocket.d.ts +4 -0
- package/dist/harness/fake-websocket.js +32 -0
- package/dist/harness/ingress-server.d.ts +5 -1
- package/dist/harness/ingress-server.js +14 -1
- package/dist/harness/microphone-fixture.d.ts +13 -0
- package/dist/harness/microphone-fixture.js +67 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
- package/src/browser-timezone.test.ts +105 -0
- package/src/browser.ts +83 -30
- package/src/daemon.ts +2 -11
- package/src/fake-websocket.ts +17 -0
- package/src/harness/fake-websocket-connection.ts +54 -0
- package/src/harness/fake-websocket.test.ts +219 -0
- package/src/harness/fake-websocket.ts +39 -0
- package/src/harness/ingress-server.ts +15 -2
- package/src/harness/microphone-fixture.ts +71 -0
- package/src/index.ts +11 -0
- package/src/microphone-fixture.test.ts +143 -0
package/README.md
CHANGED
|
@@ -20,6 +20,52 @@ Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
|
20
20
|
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
|
21
21
|
`examples/google-pubsub` demonstrates this using public SDK APIs.
|
|
22
22
|
|
|
23
|
+
|
|
24
|
+
## Microphone fixtures and WebSocket fakes (0.103.0)
|
|
25
|
+
|
|
26
|
+
Supply a 16-bit PCM mono/stereo WAV when creating a browser session:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const browser = await ctx.browser({
|
|
30
|
+
microphone: { path: "spectest/tests/fixtures/speech.wav" },
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The microphone uses Chromium's native capture pipeline. It plays once and then
|
|
35
|
+
supplies silence; add `loop: true` to repeat. Paths are relative to the repository
|
|
36
|
+
root. Set the fixture on the first call for that browser session; reattaching
|
|
37
|
+
preserves its source. Fixture sessions use a dedicated Chromium process to keep
|
|
38
|
+
named browsers' audio isolated.
|
|
39
|
+
|
|
40
|
+
A fake can serve HTTP and WebSockets on the same hostname, sharing state and
|
|
41
|
+
helpers:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const speech = defineFake({
|
|
45
|
+
name: "speech",
|
|
46
|
+
hostnames: ["speech.example.com"],
|
|
47
|
+
state: () => ({ messages: [] as string[] }),
|
|
48
|
+
handler: () => Response.json({ ready: true }),
|
|
49
|
+
websocket(req, state) {
|
|
50
|
+
if (new URL(req.url).pathname !== "/realtime") {
|
|
51
|
+
return new Response("Not found", { status: 404 });
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
message(socket, message) {
|
|
55
|
+
if (typeof message === "string") state.messages.push(message);
|
|
56
|
+
socket.send(message);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
helpers: ({ state }) => ({ messages: () => [...state.messages] }),
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The handshake returns a rejection `Response` or callbacks (`open`, `message`,
|
|
65
|
+
`close`), with an optional selected `protocol`. Callbacks may be async and run
|
|
66
|
+
in order per connection. WS and trusted WSS support text/binary messages;
|
|
67
|
+
observe traffic through the fake's helpers.
|
|
68
|
+
|
|
23
69
|
## HTTP assertions
|
|
24
70
|
|
|
25
71
|
`ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
2
|
+
export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
1
3
|
import type { DesktopChrome } from "./desktop.js";
|
|
2
4
|
import type { Wrapped } from "./inspect.js";
|
|
3
5
|
import type { UrlPattern } from "./url-match.js";
|
|
@@ -20,6 +22,17 @@ export interface BrowserOptions {
|
|
|
20
22
|
frame?: "browser" | "mobile";
|
|
21
23
|
/** Initial URL to navigate to before the constructor returns. */
|
|
22
24
|
url?: string;
|
|
25
|
+
/** IANA timezone, e.g. "Europe/Stockholm". Defaults to the host timezone.
|
|
26
|
+
* Set on first creation; inherited sessions keep their original timezone. */
|
|
27
|
+
timezone?: string;
|
|
28
|
+
/** Locale, e.g. "sv-SE", for navigator.language, Accept-Language and
|
|
29
|
+
* date/number formatting. Omit to use the browser's default locale.
|
|
30
|
+
* Set on first creation; inherited sessions keep their original locale. */
|
|
31
|
+
locale?: string;
|
|
32
|
+
/** Feed a WAV fixture through Chromium's native microphone capture.
|
|
33
|
+
* Set on the session's first creation; inherited sessions keep their source.
|
|
34
|
+
* Fixture sessions use a dedicated Chromium process for audio isolation. */
|
|
35
|
+
microphone?: MicrophoneFixture;
|
|
23
36
|
/**
|
|
24
37
|
* Script installed (before the session's first navigation) to run in every
|
|
25
38
|
* document loaded from now on, BEFORE the document's own scripts — the
|
|
@@ -290,7 +303,7 @@ export interface SafeAreaInsets {
|
|
|
290
303
|
* Pre-open `n` views into the pool (called by the daemon at the end of
|
|
291
304
|
* /bootstrap, before the warm-template snapshot is captured). Only
|
|
292
305
|
* default-viewport views are pooled — `openBrowser` with a custom
|
|
293
|
-
* width/height bypasses the pool.
|
|
306
|
+
* width/height, timezone, locale or microphone bypasses the pool.
|
|
294
307
|
*/
|
|
295
308
|
export declare function prewarmViewPool(n?: number): Promise<void>;
|
|
296
309
|
/**
|
package/dist/browser.js
CHANGED
|
@@ -33,6 +33,7 @@ import path from "node:path";
|
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
import { generateId } from "./ids.js";
|
|
35
35
|
import { rawFetch } from "./harness/raw-fetch.js";
|
|
36
|
+
import { prepareMicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
36
37
|
import { DESKTOP_INPUT_SYNC_SCRIPT } from "./desktop-recorder.js";
|
|
37
38
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
38
39
|
import { wrap } from "./inspect.js";
|
|
@@ -74,8 +75,8 @@ function base64ByteLength(b64) {
|
|
|
74
75
|
// What the app gets is Chromium's own synthetic devices: the microphone is a
|
|
75
76
|
// generated beep pattern (a live, real MediaStreamTrack — MediaRecorder,
|
|
76
77
|
// WebRTC and AudioContext analysis all work on it), the camera a rolling
|
|
77
|
-
// test pattern at 640x480.
|
|
78
|
-
// in;
|
|
78
|
+
// test pattern at 640x480. A microphone fixture adds Chromium's file-capture
|
|
79
|
+
// flag in a dedicated process; getUserMedia and track behavior stay native.
|
|
79
80
|
//
|
|
80
81
|
// --use-fake-device-for-media-stream: the synthetic camera + microphone.
|
|
81
82
|
// Also what makes `enumerateDevices()` return anything at all.
|
|
@@ -106,14 +107,16 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
|
106
107
|
// ────────────────────────────────────────────────────────────────────────
|
|
107
108
|
// Shared Chromium (playwright-core)
|
|
108
109
|
// ────────────────────────────────────────────────────────────────────────
|
|
109
|
-
//
|
|
110
|
-
// for the daemon's lifetime.
|
|
110
|
+
// Default sessions share one Chromium per daemon, launched lazily and kept
|
|
111
|
+
// for the daemon's lifetime. Fixture sessions own a process because the audio
|
|
112
|
+
// source is a process-wide flag. Both ride snapshots: the process, playwright's
|
|
111
113
|
// pipe connection to it, and every open page freeze into the VM snapshot
|
|
112
114
|
// and resume in each fork (like dockerd and the daemon itself). Profile
|
|
113
115
|
// state — cookies, localStorage — is per-Chromium, and the in-VM CA trust
|
|
114
116
|
// comes from the HOME-scoped NSS user DB, so neither cares that playwright
|
|
115
117
|
// runs a temp --user-data-dir.
|
|
116
118
|
let PW_BROWSER = null;
|
|
119
|
+
const VIEW_CLEANUPS = new WeakMap();
|
|
117
120
|
// Every view gets its OWN context, desktop included. A context is the cookie
|
|
118
121
|
// jar + storage, so one-context-per-view is what makes `close()` mean what
|
|
119
122
|
// the docs promise: the browsing session is destroyed and the next
|
|
@@ -157,18 +160,60 @@ function deviceContextOptions(d) {
|
|
|
157
160
|
}
|
|
158
161
|
/** Create the context one view lives in — device emulation when the view is
|
|
159
162
|
* phone-emulated, a plain viewport otherwise. */
|
|
160
|
-
async function newViewContext(width, height, device) {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
async function newViewContext(width, height, device, opts = {}) {
|
|
164
|
+
const fixture = opts.microphone === undefined ? undefined : await prepareMicrophoneFixture(opts.microphone);
|
|
165
|
+
let browser;
|
|
166
|
+
try {
|
|
167
|
+
browser = fixture ? await chromium.launch({
|
|
168
|
+
executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
|
|
169
|
+
}) : await ensurePlaywrightBrowser();
|
|
170
|
+
const context = await browser.newContext({
|
|
171
|
+
...(device
|
|
172
|
+
? deviceContextOptions(device)
|
|
173
|
+
: {
|
|
174
|
+
viewport: { width, height },
|
|
175
|
+
// Reduced-motion for replay fidelity — see deviceContextOptions.
|
|
176
|
+
reducedMotion: "reduce",
|
|
177
|
+
}),
|
|
178
|
+
timezoneId: opts.timezone,
|
|
179
|
+
locale: opts.locale,
|
|
168
180
|
});
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
181
|
+
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
182
|
+
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
183
|
+
if (fixture) {
|
|
184
|
+
const owned = browser;
|
|
185
|
+
VIEW_CLEANUPS.set(context, async () => {
|
|
186
|
+
try {
|
|
187
|
+
await owned.close();
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await fixture.cleanup();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return context;
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (fixture) {
|
|
198
|
+
try {
|
|
199
|
+
await browser?.close();
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await fixture.cleanup();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function closeViewContext(context) {
|
|
209
|
+
try {
|
|
210
|
+
await context.close();
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
const cleanup = VIEW_CLEANUPS.get(context);
|
|
214
|
+
VIEW_CLEANUPS.delete(context);
|
|
215
|
+
await cleanup?.();
|
|
216
|
+
}
|
|
172
217
|
}
|
|
173
218
|
/**
|
|
174
219
|
* Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
|
|
@@ -838,7 +883,7 @@ async function createView(width, height) {
|
|
|
838
883
|
* Pre-open `n` views into the pool (called by the daemon at the end of
|
|
839
884
|
* /bootstrap, before the warm-template snapshot is captured). Only
|
|
840
885
|
* default-viewport views are pooled — `openBrowser` with a custom
|
|
841
|
-
* width/height bypasses the pool.
|
|
886
|
+
* width/height, timezone, locale or microphone bypasses the pool.
|
|
842
887
|
*/
|
|
843
888
|
export async function prewarmViewPool(n = 1) {
|
|
844
889
|
for (let i = 0; i < n; i++) {
|
|
@@ -869,10 +914,11 @@ export async function openMobileBackend(opts = {}) {
|
|
|
869
914
|
const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
|
|
870
915
|
const wantW = device ? device.viewport.width : opts.width ?? 1280;
|
|
871
916
|
const wantH = device ? device.viewport.height : opts.height ?? 720;
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
917
|
+
// The pool holds only default-desktop contexts. Context creation options
|
|
918
|
+
// such as timezone and locale require a fresh context.
|
|
919
|
+
const pooled = !device && opts.microphone === undefined && opts.timezone === undefined &&
|
|
920
|
+
opts.locale === undefined && wantW === 1280 && wantH === 720
|
|
921
|
+
? VIEW_POOL.pop() : undefined;
|
|
876
922
|
let holder;
|
|
877
923
|
if (pooled) {
|
|
878
924
|
holder = {
|
|
@@ -889,7 +935,7 @@ export async function openMobileBackend(opts = {}) {
|
|
|
889
935
|
};
|
|
890
936
|
}
|
|
891
937
|
else {
|
|
892
|
-
holder = await newHolder(wantW, wantH, device);
|
|
938
|
+
holder = await newHolder(wantW, wantH, device, opts);
|
|
893
939
|
}
|
|
894
940
|
const { backend } = buildBackend(holder, opts.recorder ?? null, {
|
|
895
941
|
persistent: false,
|
|
@@ -897,12 +943,17 @@ export async function openMobileBackend(opts = {}) {
|
|
|
897
943
|
// We deliberately don't forward `opts.url` to the constructor — going
|
|
898
944
|
// through our own `navigate()` keeps the recorder log uniform (one
|
|
899
945
|
// event per navigation, with timing) and drains rrweb after the load.
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
946
|
+
try {
|
|
947
|
+
if (opts.initScript !== undefined)
|
|
948
|
+
await installInitScript(holder, opts.initScript);
|
|
949
|
+
if (opts.url !== undefined)
|
|
950
|
+
await backend.goto(opts.url);
|
|
951
|
+
return backend;
|
|
952
|
+
}
|
|
953
|
+
catch (err) {
|
|
954
|
+
await backend.close();
|
|
955
|
+
throw err;
|
|
904
956
|
}
|
|
905
|
-
return backend;
|
|
906
957
|
}
|
|
907
958
|
// ────────────────────────────────────────────────────────────────────────
|
|
908
959
|
// Persistent sessions (the default behind ctx.browser / ctx.mobile)
|
|
@@ -946,9 +997,16 @@ function checkSessionCap(kind, live, name) {
|
|
|
946
997
|
`Each named session is a live browser captured in every snapshot from here on — name them for the ` +
|
|
947
998
|
`roles under test (e.g. "buyer"/"seller") rather than per row of data, or close() the ones you're done with.`);
|
|
948
999
|
}
|
|
949
|
-
async function newHolder(width, height, device) {
|
|
950
|
-
const context = await newViewContext(width, height, device);
|
|
951
|
-
|
|
1000
|
+
async function newHolder(width, height, device, opts = {}) {
|
|
1001
|
+
const context = await newViewContext(width, height, device, opts);
|
|
1002
|
+
let spawned;
|
|
1003
|
+
try {
|
|
1004
|
+
spawned = await spawnPage(context);
|
|
1005
|
+
}
|
|
1006
|
+
catch (err) {
|
|
1007
|
+
await closeViewContext(context);
|
|
1008
|
+
throw err;
|
|
1009
|
+
}
|
|
952
1010
|
const holder = {
|
|
953
1011
|
context,
|
|
954
1012
|
page: spawned.page,
|
|
@@ -1020,7 +1078,7 @@ export async function acquirePersistentBrowser(name = "", opts = {}) {
|
|
|
1020
1078
|
}
|
|
1021
1079
|
else {
|
|
1022
1080
|
checkSessionCap("browser", SHARED_BROWSERS.size, name);
|
|
1023
|
-
holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device);
|
|
1081
|
+
holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device, opts);
|
|
1024
1082
|
SHARED_BROWSERS.set(name, holder);
|
|
1025
1083
|
}
|
|
1026
1084
|
const view = holder;
|
|
@@ -1617,7 +1675,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1617
1675
|
// only the page would leave the next session signed in as this one.
|
|
1618
1676
|
try {
|
|
1619
1677
|
if (buildOpts.ownsContext !== false)
|
|
1620
|
-
await holder.context
|
|
1678
|
+
await closeViewContext(holder.context);
|
|
1621
1679
|
}
|
|
1622
1680
|
catch {
|
|
1623
1681
|
/* context already gone (browser died) */
|
package/dist/daemon.js
CHANGED
|
@@ -41,6 +41,7 @@ import { isDesktopApp } from "./desktop.js";
|
|
|
41
41
|
// each easy to restate subtly differently.
|
|
42
42
|
import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
|
|
43
43
|
import { startServices } from "./harness/service-graph.js";
|
|
44
|
+
import { dispatchFake } from "./harness/fake-websocket.js";
|
|
44
45
|
import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
|
|
45
46
|
import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
46
47
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
@@ -2000,13 +2001,7 @@ server, byHost, listenerLabel, proto) {
|
|
|
2000
2001
|
// The real answer — a fake handled in-process, or the proxied container.
|
|
2001
2002
|
const upstream = async (current) => {
|
|
2002
2003
|
if (route.kind === "fake") {
|
|
2003
|
-
|
|
2004
|
-
return await runUninstrumented(() => route.fake.def.handler(current, route.fake.state, FAKE_CTX));
|
|
2005
|
-
}
|
|
2006
|
-
catch (err) {
|
|
2007
|
-
const e = err;
|
|
2008
|
-
return new Response(`spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
|
|
2009
|
-
}
|
|
2004
|
+
return dispatchFake(current, server, route.fake.def, route.fake.state, FAKE_CTX);
|
|
2010
2005
|
}
|
|
2011
2006
|
return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
|
|
2012
2007
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
/** Returned by a fake's websocket handshake callback to accept a connection.
|
|
8
|
+
* Callbacks run in order per connection, including async callbacks. Capture
|
|
9
|
+
* fake state and connection-local state in the handshake callback's closure. */
|
|
10
|
+
export interface FakeWebSocketHandlers {
|
|
11
|
+
/** An offered Sec-WebSocket-Protocol to select, if the client requires one. */
|
|
12
|
+
protocol?: string;
|
|
13
|
+
open?(socket: FakeWebSocket): void | Promise<void>;
|
|
14
|
+
message?(socket: FakeWebSocket, message: string | Uint8Array): void | Promise<void>;
|
|
15
|
+
close?(socket: FakeWebSocket, code: number, reason: string): void | Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type WebSocket from "ws";
|
|
2
|
+
import type { FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
3
|
+
/** Serialize async fake callbacks without allowing a slow handler or peer to
|
|
4
|
+
* retain unbounded audio. A handler failure closes just this connection. */
|
|
5
|
+
export declare function attachFakeWebSocket(ws: WebSocket, handlers: FakeWebSocketHandlers): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
2
|
+
const MAX_PENDING = 1024;
|
|
3
|
+
/** Serialize async fake callbacks without allowing a slow handler or peer to
|
|
4
|
+
* retain unbounded audio. A handler failure closes just this connection. */
|
|
5
|
+
export function attachFakeWebSocket(ws, handlers) {
|
|
6
|
+
let tail = Promise.resolve();
|
|
7
|
+
let pending = 0;
|
|
8
|
+
let bytes = 0;
|
|
9
|
+
let failed = false;
|
|
10
|
+
const socket = {
|
|
11
|
+
get readyState() { return ws.readyState; },
|
|
12
|
+
send(message) {
|
|
13
|
+
if (ws.readyState !== 1)
|
|
14
|
+
throw new Error("WebSocket is not open");
|
|
15
|
+
const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
16
|
+
if (ws.bufferedAmount + size > MAX_BUFFER) {
|
|
17
|
+
failed = true;
|
|
18
|
+
ws.close(1009, "fake output buffer exceeded");
|
|
19
|
+
throw new Error("fake WebSocket output buffer exceeded");
|
|
20
|
+
}
|
|
21
|
+
ws.send(message);
|
|
22
|
+
},
|
|
23
|
+
close: (code, reason) => ws.close(code, reason),
|
|
24
|
+
};
|
|
25
|
+
const fail = (err) => {
|
|
26
|
+
failed = true;
|
|
27
|
+
console.error("[spectest] fake WebSocket callback failed:", err);
|
|
28
|
+
try {
|
|
29
|
+
ws.close(1011, "fake handler failed");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
ws.terminate();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const enqueue = (fn, size = 0, closing = false) => {
|
|
36
|
+
if (failed && !closing)
|
|
37
|
+
return;
|
|
38
|
+
if (!closing && (pending >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
|
|
39
|
+
failed = true;
|
|
40
|
+
ws.close(1009, "fake input buffer exceeded");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
pending++;
|
|
44
|
+
bytes += size;
|
|
45
|
+
tail = tail.then(async () => {
|
|
46
|
+
if (!failed || closing)
|
|
47
|
+
await fn();
|
|
48
|
+
}).catch(fail).finally(() => { pending--; bytes -= size; });
|
|
49
|
+
};
|
|
50
|
+
ws.on("message", (data, binary) => {
|
|
51
|
+
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
|
|
52
|
+
const message = binary ? new Uint8Array(buffer) : buffer.toString();
|
|
53
|
+
enqueue(() => handlers.message?.(socket, message), buffer.length);
|
|
54
|
+
});
|
|
55
|
+
ws.on("close", (code, reason) => enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true));
|
|
56
|
+
ws.on("error", fail);
|
|
57
|
+
enqueue(() => handlers.open?.(socket));
|
|
58
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FakeContext, FakeDefinition } from "../index.js";
|
|
2
|
+
import type { IngressServer } from "./ingress-server.js";
|
|
3
|
+
/** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
|
|
4
|
+
export declare function dispatchFake<S>(req: Request, server: IngressServer, def: FakeDefinition<S, any>, state: S, ctx: FakeContext): Promise<Response>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { runUninstrumented } from "./instrumentation-scope.js";
|
|
2
|
+
import { parseSubprotocols } from "./ws-bridge.js";
|
|
3
|
+
/** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
|
|
4
|
+
export async function dispatchFake(req, server, def, state, ctx) {
|
|
5
|
+
try {
|
|
6
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
|
|
7
|
+
return await runUninstrumented(() => def.handler(req, state, ctx));
|
|
8
|
+
}
|
|
9
|
+
const accepted = await runUninstrumented(() => def.websocket(req, state, ctx));
|
|
10
|
+
if (accepted instanceof Response)
|
|
11
|
+
return accepted;
|
|
12
|
+
if (!accepted || typeof accepted !== "object")
|
|
13
|
+
throw new Error("websocket must return handlers or a Response");
|
|
14
|
+
if (accepted.protocol !== undefined && !parseSubprotocols(req.headers.get("sec-websocket-protocol")).includes(accepted.protocol)) {
|
|
15
|
+
throw new Error("websocket selected a protocol the client did not offer");
|
|
16
|
+
}
|
|
17
|
+
const handlers = {
|
|
18
|
+
open: socket => runUninstrumented(() => accepted.open?.(socket)),
|
|
19
|
+
message: (socket, message) => runUninstrumented(() => accepted.message?.(socket, message)),
|
|
20
|
+
close: (socket, code, reason) => runUninstrumented(() => accepted.close?.(socket, code, reason)),
|
|
21
|
+
};
|
|
22
|
+
return server.upgrade(req, {
|
|
23
|
+
handlers,
|
|
24
|
+
headers: accepted.protocol ? { "sec-websocket-protocol": accepted.protocol } : undefined,
|
|
25
|
+
}) ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return new Response(`spectest-daemon: fake ${def.name} threw: ${err instanceof Error ? err.message : String(err)}\n`, {
|
|
29
|
+
status: 500, headers: { "content-type": "text/plain" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type WsBridgeData } from "./ws-bridge.js";
|
|
2
|
+
import type { FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
2
3
|
export interface IngressServer {
|
|
3
4
|
port: number;
|
|
4
5
|
stop(force?: boolean): void;
|
|
@@ -6,8 +7,11 @@ export interface IngressServer {
|
|
|
6
7
|
address: string;
|
|
7
8
|
port: number;
|
|
8
9
|
} | undefined;
|
|
9
|
-
upgrade(req: Request, options: {
|
|
10
|
+
upgrade(req: Request, options: ({
|
|
10
11
|
data: WsBridgeData;
|
|
12
|
+
} | {
|
|
13
|
+
handlers: FakeWebSocketHandlers;
|
|
14
|
+
}) & {
|
|
11
15
|
headers?: Record<string, string>;
|
|
12
16
|
}): boolean;
|
|
13
17
|
}
|
|
@@ -4,6 +4,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { WS_BRIDGE_HANDLERS } from "./ws-bridge.js";
|
|
7
|
+
import { attachFakeWebSocket } from "./fake-websocket-connection.js";
|
|
7
8
|
// Load the installed implementation, not Bun's built-in `ws` replacement.
|
|
8
9
|
// On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
|
|
9
10
|
// asynchronous upstream handshake. The package uses the ordinary socket API.
|
|
@@ -27,6 +28,10 @@ export async function startIngressServer(options) {
|
|
|
27
28
|
// the client's first offer on its behalf.
|
|
28
29
|
handleProtocols: (_offered, req) => protocols.get(req) || false,
|
|
29
30
|
});
|
|
31
|
+
const fakeWebsockets = new WebSocketServer({
|
|
32
|
+
noServer: true, maxPayload: 16 * 1024 * 1024,
|
|
33
|
+
handleProtocols: (_offered, req) => protocols.get(req) || false,
|
|
34
|
+
});
|
|
30
35
|
const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
|
|
31
36
|
void dispatch(req, res);
|
|
32
37
|
});
|
|
@@ -43,11 +48,14 @@ export async function startIngressServer(options) {
|
|
|
43
48
|
if (force) {
|
|
44
49
|
for (const ws of websockets.clients)
|
|
45
50
|
ws.terminate();
|
|
51
|
+
for (const ws of fakeWebsockets.clients)
|
|
52
|
+
ws.terminate();
|
|
46
53
|
for (const socket of sockets)
|
|
47
54
|
socket.destroy();
|
|
48
55
|
server.closeAllConnections();
|
|
49
56
|
}
|
|
50
57
|
websockets.close();
|
|
58
|
+
fakeWebsockets.close();
|
|
51
59
|
server.close();
|
|
52
60
|
},
|
|
53
61
|
requestIP(req) {
|
|
@@ -60,8 +68,13 @@ export async function startIngressServer(options) {
|
|
|
60
68
|
if (!pending || pending.upgraded || pending.socket.destroyed)
|
|
61
69
|
return false;
|
|
62
70
|
protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
|
|
63
|
-
|
|
71
|
+
const target = "handlers" in opts ? fakeWebsockets : websockets;
|
|
72
|
+
target.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
|
|
64
73
|
pending.upgraded = true;
|
|
74
|
+
if ("handlers" in opts) {
|
|
75
|
+
attachFakeWebSocket(ws, opts.handlers);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
65
78
|
const client = {
|
|
66
79
|
data: opts.data,
|
|
67
80
|
send: (data) => ws.send(data),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface MicrophoneFixture {
|
|
2
|
+
/** WAV file relative to the repository root (absolute paths also work).
|
|
3
|
+
* Use 16-bit PCM mono or stereo audio. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Repeat the recording. Default false: play once, then supply silence. */
|
|
6
|
+
loop?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Stage immutable bytes under a safe filename: Chromium interprets a `%`
|
|
9
|
+
* suffix as playback options, so a project filename cannot be passed verbatim. */
|
|
10
|
+
export declare function prepareMicrophoneFixture(fixture: MicrophoneFixture): Promise<{
|
|
11
|
+
arg: string;
|
|
12
|
+
cleanup(): Promise<void>;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
/** Stage immutable bytes under a safe filename: Chromium interprets a `%`
|
|
6
|
+
* suffix as playback options, so a project filename cannot be passed verbatim. */
|
|
7
|
+
export async function prepareMicrophoneFixture(fixture) {
|
|
8
|
+
if (!fixture || typeof fixture.path !== "string" || !fixture.path.trim()) {
|
|
9
|
+
throw new Error("microphone.path must name a WAV fixture");
|
|
10
|
+
}
|
|
11
|
+
if (fixture.loop !== undefined && typeof fixture.loop !== "boolean")
|
|
12
|
+
throw new Error("microphone.loop must be a boolean");
|
|
13
|
+
const source = resolveExistingProjectPath(fixture.path, "microphone fixture");
|
|
14
|
+
const bytes = await readFile(source);
|
|
15
|
+
let pcm = false;
|
|
16
|
+
let audio = false;
|
|
17
|
+
let dataAt = 0;
|
|
18
|
+
let dataSize = 0;
|
|
19
|
+
let bytesPerFrame = 0;
|
|
20
|
+
let sampleRate = 0;
|
|
21
|
+
if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WAVE") {
|
|
22
|
+
for (let at = 12; at + 8 <= bytes.length;) {
|
|
23
|
+
const size = bytes.readUInt32LE(at + 4);
|
|
24
|
+
const end = at + 8 + size;
|
|
25
|
+
if (end > bytes.length)
|
|
26
|
+
break;
|
|
27
|
+
const chunk = bytes.toString("ascii", at, at + 4);
|
|
28
|
+
if (chunk === "fmt " && size >= 16) {
|
|
29
|
+
sampleRate = bytes.readUInt32LE(at + 12);
|
|
30
|
+
bytesPerFrame = bytes.readUInt16LE(at + 10) * 2;
|
|
31
|
+
pcm = bytes.readUInt16LE(at + 8) === 1 && [1, 2].includes(bytes.readUInt16LE(at + 10)) &&
|
|
32
|
+
sampleRate >= 8000 && sampleRate <= 192000 && bytes.readUInt16LE(at + 22) === 16 &&
|
|
33
|
+
bytes.readUInt16LE(at + 20) === bytesPerFrame;
|
|
34
|
+
}
|
|
35
|
+
if (chunk === "data" && size > 0) {
|
|
36
|
+
audio = true;
|
|
37
|
+
dataAt = at;
|
|
38
|
+
dataSize = size;
|
|
39
|
+
}
|
|
40
|
+
at = end + (size % 2);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!pcm || !audio || dataSize % bytesPerFrame !== 0)
|
|
44
|
+
throw new Error(`microphone fixture "${fixture.path}" must be a non-empty 16-bit PCM mono or stereo WAV (8–192 kHz)`);
|
|
45
|
+
// Chromium's FileSource returns zero at EOF without clearing the previous
|
|
46
|
+
// capture buffer. Finish with silence so single playback cannot repeat the
|
|
47
|
+
// final speech fragment indefinitely. Leave looping recordings untouched.
|
|
48
|
+
let staged = bytes;
|
|
49
|
+
if (!fixture.loop) {
|
|
50
|
+
const silence = Buffer.alloc(Math.ceil(sampleRate / 4) * bytesPerFrame);
|
|
51
|
+
const end = dataAt + 8 + dataSize;
|
|
52
|
+
staged = Buffer.concat([bytes.subarray(0, end), silence, bytes.subarray(end)]);
|
|
53
|
+
staged.writeUInt32LE(staged.length - 8, 4);
|
|
54
|
+
staged.writeUInt32LE(dataSize + silence.length, dataAt + 4);
|
|
55
|
+
}
|
|
56
|
+
const dir = await mkdtemp(join(tmpdir(), "spectest-microphone-"));
|
|
57
|
+
const cleanup = () => rm(dir, { recursive: true, force: true });
|
|
58
|
+
try {
|
|
59
|
+
const file = join(dir, "audio.wav");
|
|
60
|
+
await writeFile(file, staged);
|
|
61
|
+
return { arg: `--use-file-for-fake-audio-capture=${file}${fixture.loop ? "" : "%noloop"}`, cleanup };
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
await cleanup();
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
|
|
|
11
11
|
export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
|
|
12
12
|
export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
|
|
13
13
|
export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
|
|
14
|
+
export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
15
|
+
export type { FakeWebSocket, FakeWebSocketHandlers } from "./fake-websocket.js";
|
|
16
|
+
import type { FakeWebSocketHandlers } from "./fake-websocket.js";
|
|
14
17
|
import type { Browser, BrowserOptions } from "./browser.js";
|
|
15
18
|
export { McpHttpError, McpRpcError, McpAuthDeniedError, type Mcp, type McpOptions, type McpAuthorization, type McpChallenge, type McpIdentity, type McpToolResult, type McpToolInfo, type McpResourceInfo, type McpResourceContents, type McpPromptInfo, type McpPromptResult, type McpContent, type McpNotification, type McpSamplingRequest, type McpSamplingResult, type AuthorizeOptions, type AuthorizationServerInfo, } from "./mcp.js";
|
|
16
19
|
import type { Mcp, McpOptions } from "./mcp.js";
|
|
@@ -1483,6 +1486,10 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1483
1486
|
* also applies to state/helper factories and helper implementations.
|
|
1484
1487
|
*/
|
|
1485
1488
|
handler: (req: Request, state: S, ctx: FakeContext) => Response | Promise<Response>;
|
|
1489
|
+
/** Optional WebSocket handshake on the same hostnames as HTTP. Return a
|
|
1490
|
+
* Response to reject, or connection callbacks to accept. Callbacks share
|
|
1491
|
+
* this fake's state and run uninstrumented, in order per connection. */
|
|
1492
|
+
websocket?: (req: Request, state: S, ctx: FakeContext) => FakeWebSocketHandlers | Response | Promise<FakeWebSocketHandlers | Response>;
|
|
1486
1493
|
/**
|
|
1487
1494
|
* Build the helpers exposed at `ctx.fakes.<name>` — a record of
|
|
1488
1495
|
* **functions** that read or mutate the fake's `state` (received here)
|
package/dist/index.js
CHANGED
|
@@ -414,6 +414,9 @@ export function defineFake(opts) {
|
|
|
414
414
|
if (typeof opts.handler !== "function") {
|
|
415
415
|
throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
|
|
416
416
|
}
|
|
417
|
+
if (opts.websocket !== undefined && typeof opts.websocket !== "function") {
|
|
418
|
+
throw new Error(`defineFake(${opts.name}): \`websocket\` must be a function`);
|
|
419
|
+
}
|
|
417
420
|
return opts;
|
|
418
421
|
}
|
|
419
422
|
/**
|