@specific.dev/spectest 0.101.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/README.md +46 -0
- package/dist/browser.d.ts +6 -0
- package/dist/browser.js +81 -28
- package/dist/daemon.js +42 -79
- 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 +30 -0
- package/dist/harness/ingress-server.js +179 -0
- package/dist/harness/microphone-fixture.d.ts +13 -0
- package/dist/harness/microphone-fixture.js +67 -0
- package/dist/harness/tls-terminator.js +5 -9
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/package.json +4 -2
- package/src/browser.ts +69 -26
- package/src/daemon.ts +44 -95
- 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-cors.test.ts +3 -2
- package/src/harness/ingress-server.test.ts +250 -0
- package/src/harness/ingress-server.ts +188 -0
- package/src/harness/microphone-fixture.ts +71 -0
- package/src/harness/tls-terminator.test.ts +2 -2
- package/src/harness/tls-terminator.ts +5 -9
- package/src/harness/ws-bridge.test.ts +4 -5
- package/src/index.ts +11 -0
- package/src/microphone-fixture.test.ts +143 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type WsBridgeData } from "./ws-bridge.js";
|
|
2
|
+
import type { FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
3
|
+
export interface IngressServer {
|
|
4
|
+
port: number;
|
|
5
|
+
stop(force?: boolean): void;
|
|
6
|
+
requestIP(req: Request): {
|
|
7
|
+
address: string;
|
|
8
|
+
port: number;
|
|
9
|
+
} | undefined;
|
|
10
|
+
upgrade(req: Request, options: ({
|
|
11
|
+
data: WsBridgeData;
|
|
12
|
+
} | {
|
|
13
|
+
handlers: FakeWebSocketHandlers;
|
|
14
|
+
}) & {
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
}): boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Serve the ingress's Fetch handlers through node:http. Bun.serve discards
|
|
20
|
+
* Content-Length on a still-streaming Response, even when its bytes are
|
|
21
|
+
* unchanged. ServerResponse preserves the declared length while pipeline
|
|
22
|
+
* propagates backpressure and cancellation without buffering the whole body.
|
|
23
|
+
* This is also the HTTP/1 listener behind the TLS terminator.
|
|
24
|
+
*/
|
|
25
|
+
export declare function startIngressServer(options: {
|
|
26
|
+
port: number;
|
|
27
|
+
hostname?: string;
|
|
28
|
+
reusePort?: boolean;
|
|
29
|
+
fetch(req: Request, server: IngressServer): Response | undefined | Promise<Response | undefined>;
|
|
30
|
+
}): Promise<IngressServer>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { WS_BRIDGE_HANDLERS } from "./ws-bridge.js";
|
|
7
|
+
import { attachFakeWebSocket } from "./fake-websocket-connection.js";
|
|
8
|
+
// Load the installed implementation, not Bun's built-in `ws` replacement.
|
|
9
|
+
// On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
|
|
10
|
+
// asynchronous upstream handshake. The package uses the ordinary socket API.
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const { WebSocketServer } = require(join(dirname(require.resolve("ws/package.json")), "index.js"));
|
|
13
|
+
/**
|
|
14
|
+
* Serve the ingress's Fetch handlers through node:http. Bun.serve discards
|
|
15
|
+
* Content-Length on a still-streaming Response, even when its bytes are
|
|
16
|
+
* unchanged. ServerResponse preserves the declared length while pipeline
|
|
17
|
+
* propagates backpressure and cancellation without buffering the whole body.
|
|
18
|
+
* This is also the HTTP/1 listener behind the TLS terminator.
|
|
19
|
+
*/
|
|
20
|
+
export async function startIngressServer(options) {
|
|
21
|
+
const peers = new WeakMap();
|
|
22
|
+
const upgrades = new WeakMap();
|
|
23
|
+
const protocols = new WeakMap();
|
|
24
|
+
const sockets = new Set();
|
|
25
|
+
const websockets = new WebSocketServer({
|
|
26
|
+
noServer: true,
|
|
27
|
+
// The upstream handshake has already selected a protocol. Never select
|
|
28
|
+
// the client's first offer on its behalf.
|
|
29
|
+
handleProtocols: (_offered, req) => protocols.get(req) || false,
|
|
30
|
+
});
|
|
31
|
+
const fakeWebsockets = new WebSocketServer({
|
|
32
|
+
noServer: true, maxPayload: 16 * 1024 * 1024,
|
|
33
|
+
handleProtocols: (_offered, req) => protocols.get(req) || false,
|
|
34
|
+
});
|
|
35
|
+
const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
|
|
36
|
+
void dispatch(req, res);
|
|
37
|
+
});
|
|
38
|
+
server.on("connection", socket => {
|
|
39
|
+
sockets.add(socket);
|
|
40
|
+
socket.once("close", () => sockets.delete(socket));
|
|
41
|
+
});
|
|
42
|
+
server.on("upgrade", (req, socket, head) => {
|
|
43
|
+
void dispatch(req, undefined, { req, socket: socket, head, upgraded: false });
|
|
44
|
+
});
|
|
45
|
+
const facade = {
|
|
46
|
+
port: 0,
|
|
47
|
+
stop(force = false) {
|
|
48
|
+
if (force) {
|
|
49
|
+
for (const ws of websockets.clients)
|
|
50
|
+
ws.terminate();
|
|
51
|
+
for (const ws of fakeWebsockets.clients)
|
|
52
|
+
ws.terminate();
|
|
53
|
+
for (const socket of sockets)
|
|
54
|
+
socket.destroy();
|
|
55
|
+
server.closeAllConnections();
|
|
56
|
+
}
|
|
57
|
+
websockets.close();
|
|
58
|
+
fakeWebsockets.close();
|
|
59
|
+
server.close();
|
|
60
|
+
},
|
|
61
|
+
requestIP(req) {
|
|
62
|
+
const socket = peers.get(req);
|
|
63
|
+
return socket?.remoteAddress && socket.remotePort
|
|
64
|
+
? { address: socket.remoteAddress, port: socket.remotePort } : undefined;
|
|
65
|
+
},
|
|
66
|
+
upgrade(req, opts) {
|
|
67
|
+
const pending = upgrades.get(req);
|
|
68
|
+
if (!pending || pending.upgraded || pending.socket.destroyed)
|
|
69
|
+
return false;
|
|
70
|
+
protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
|
|
71
|
+
const target = "handlers" in opts ? fakeWebsockets : websockets;
|
|
72
|
+
target.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
|
|
73
|
+
pending.upgraded = true;
|
|
74
|
+
if ("handlers" in opts) {
|
|
75
|
+
attachFakeWebSocket(ws, opts.handlers);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const client = {
|
|
79
|
+
data: opts.data,
|
|
80
|
+
send: (data) => ws.send(data),
|
|
81
|
+
close: (code, reason) => ws.close(code, reason),
|
|
82
|
+
};
|
|
83
|
+
ws.on("message", (data, binary) => WS_BRIDGE_HANDLERS.message(client, binary ? Buffer.from(data) : data.toString()));
|
|
84
|
+
ws.on("close", (code, reason) => WS_BRIDGE_HANDLERS.close(client, code, reason.toString()));
|
|
85
|
+
ws.on("error", () => opts.data.upstream.close());
|
|
86
|
+
WS_BRIDGE_HANDLERS.open(client);
|
|
87
|
+
});
|
|
88
|
+
return pending.upgraded;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
async function dispatch(incoming, outgoing, upgrade) {
|
|
92
|
+
const abort = new AbortController();
|
|
93
|
+
const onClose = () => {
|
|
94
|
+
if (!outgoing?.writableFinished && !upgrade?.upgraded)
|
|
95
|
+
abort.abort();
|
|
96
|
+
};
|
|
97
|
+
const connection = outgoing ?? upgrade.socket;
|
|
98
|
+
connection.once("close", onClose);
|
|
99
|
+
const responseWriter = () => {
|
|
100
|
+
if (!outgoing) {
|
|
101
|
+
// Only attach an HTTP response after a refused upgrade. Attaching
|
|
102
|
+
// one before handleUpgrade conflicts with Bun's native handshake.
|
|
103
|
+
outgoing = new http.ServerResponse(incoming);
|
|
104
|
+
outgoing.assignSocket(upgrade.socket);
|
|
105
|
+
outgoing.once("finish", () => upgrade.socket.end());
|
|
106
|
+
}
|
|
107
|
+
return outgoing;
|
|
108
|
+
};
|
|
109
|
+
let response;
|
|
110
|
+
try {
|
|
111
|
+
const headers = new Headers();
|
|
112
|
+
for (let i = 0; i < incoming.rawHeaders.length; i += 2) {
|
|
113
|
+
headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
|
|
114
|
+
}
|
|
115
|
+
const method = incoming.method ?? "GET";
|
|
116
|
+
const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
|
|
117
|
+
method, headers, signal: abort.signal,
|
|
118
|
+
body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming),
|
|
119
|
+
duplex: "half",
|
|
120
|
+
});
|
|
121
|
+
peers.set(req, incoming.socket);
|
|
122
|
+
if (upgrade)
|
|
123
|
+
upgrades.set(req, upgrade);
|
|
124
|
+
response = await options.fetch(req, facade);
|
|
125
|
+
if (upgrade?.upgraded)
|
|
126
|
+
return;
|
|
127
|
+
if (!response)
|
|
128
|
+
throw new Error("Ingress handler returned no response");
|
|
129
|
+
if (abort.signal.aborted) {
|
|
130
|
+
await response.body?.cancel();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const writer = responseWriter();
|
|
134
|
+
writer.statusCode = response.status;
|
|
135
|
+
writer.statusMessage = response.statusText;
|
|
136
|
+
response.headers.forEach((value, key) => {
|
|
137
|
+
if (key !== "set-cookie")
|
|
138
|
+
writer.setHeader(key, value);
|
|
139
|
+
});
|
|
140
|
+
const cookies = response.headers.getSetCookie();
|
|
141
|
+
if (cookies.length)
|
|
142
|
+
writer.setHeader("set-cookie", cookies);
|
|
143
|
+
// Flush even for a body already buffered by fetch. An upstream with
|
|
144
|
+
// no length must not acquire an invented Content-Length from end().
|
|
145
|
+
writer.flushHeaders();
|
|
146
|
+
if (method === "HEAD" || response.status === 204 || response.status === 304 || !response.body) {
|
|
147
|
+
await response.body?.cancel();
|
|
148
|
+
writer.end();
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
await pipeline(Readable.fromWeb(response.body), writer);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (upgrade?.upgraded)
|
|
156
|
+
return;
|
|
157
|
+
outgoing = responseWriter();
|
|
158
|
+
if (!outgoing.headersSent && !outgoing.destroyed) {
|
|
159
|
+
outgoing.writeHead(500, { "content-type": "text/plain" });
|
|
160
|
+
outgoing.end("spectest-daemon: ingress response failed\n");
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
connection.off("close", onClose);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await new Promise((resolve, reject) => {
|
|
171
|
+
server.once("error", reject);
|
|
172
|
+
server.listen({ port: options.port, host: options.hostname ?? "0.0.0.0", reusePort: options.reusePort }, () => {
|
|
173
|
+
server.off("error", reject);
|
|
174
|
+
resolve();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
facade.port = server.address().port;
|
|
178
|
+
return facade;
|
|
179
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -33,15 +33,11 @@
|
|
|
33
33
|
* ## The shape, and why it is a byte pipe
|
|
34
34
|
*
|
|
35
35
|
* The terminator does TLS and *nothing else*: it accepts on :443, picks a
|
|
36
|
-
* leaf by SNI, and pipes the decrypted stream to
|
|
37
|
-
* bound on loopback. Opted-in HTTP/2 routes select a separate stream
|
|
38
|
-
* by ALPN; HTTP/1.1 and WebSockets
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
|
|
42
|
-
* would have meant reimplementing the WebSocket bridge against a
|
|
43
|
-
* different API, which is exactly the kind of trade this change exists to
|
|
44
|
-
* avoid: one flake removed, another introduced somewhere subtler.
|
|
36
|
+
* leaf by SNI, and pipes the decrypted stream to the plaintext ingress
|
|
37
|
+
* server bound on loopback. Opted-in HTTP/2 routes select a separate stream
|
|
38
|
+
* router by ALPN; HTTP/1.1 and WebSockets use the shared ingress listener.
|
|
39
|
+
* Routing, fakes, interceptors and reverse-proxying stay in that listener;
|
|
40
|
+
* this layer never parses or rewrites HTTP response framing.
|
|
45
41
|
*
|
|
46
42
|
* Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
|
|
47
43
|
* 50 MB bodies): ~11 % fewer small requests per second, download
|
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
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specific.dev/spectest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.103.0",
|
|
4
4
|
"description": "Spectest SDK for defining test environments in TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -56,11 +56,13 @@
|
|
|
56
56
|
"@kubernetes/client-node": "^1.0.0",
|
|
57
57
|
"@xterm/headless": "^5.5.0",
|
|
58
58
|
"dns-packet": "^5.6.1",
|
|
59
|
-
"playwright-core": "^1.61.1"
|
|
59
|
+
"playwright-core": "^1.61.1",
|
|
60
|
+
"ws": "^8.20.1"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@types/dns-packet": "^5.6.5",
|
|
63
64
|
"@types/bun": "^1.3.0",
|
|
65
|
+
"@types/ws": "^8",
|
|
64
66
|
"typescript": "^5.6.3"
|
|
65
67
|
},
|
|
66
68
|
"spectestCapabilities": [
|
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.
|
|
410
|
-
// in;
|
|
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
|
-
//
|
|
446
|
-
// for the daemon's lifetime.
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
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
|
|
2209
|
+
if (buildOpts.ownsContext !== false) await closeViewContext(holder.context);
|
|
2167
2210
|
} catch {
|
|
2168
2211
|
/* context already gone (browser died) */
|
|
2169
2212
|
}
|