@specific.dev/spectest 0.111.0 → 0.112.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 +41 -0
- package/dist/components/replay-websocket.d.ts +29 -3
- package/dist/components/replay-websocket.js +157 -25
- package/dist/components/replayFake.d.ts +4 -1
- package/dist/daemon.js +14 -0
- package/dist/harness/phone.py +427 -0
- package/dist/harness/phone_recording.py +95 -0
- package/dist/index.d.ts +8 -0
- package/dist/phone.d.ts +59 -0
- package/dist/phone.js +343 -0
- package/dist/recorder.d.ts +10 -0
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +154 -24
- package/src/components/replayFake-handler.test.ts +5 -0
- package/src/components/replayFake-websocket.test.ts +166 -0
- package/src/components/replayFake.ts +4 -1
- package/src/daemon.ts +14 -0
- package/src/harness/phone.py +427 -0
- package/src/harness/phone_recording.py +95 -0
- package/src/index.ts +8 -0
- package/src/phone.test.ts +474 -0
- package/src/phone.ts +330 -0
- package/src/recorder.ts +1 -0
package/README.md
CHANGED
|
@@ -296,3 +296,44 @@ The classic viewer shows `mail.latest()` with Email/JSON tabs; JSON contains
|
|
|
296
296
|
helper arguments and the returned value. The SPA retains its nested presentation.
|
|
297
297
|
`annotate` always returns a box for the helper boundary; it does not attach views
|
|
298
298
|
to arbitrary recorded results. See `spectest docs /services` for a complete example.
|
|
299
|
+
|
|
300
|
+
## SIP phone calls
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
const phone = await ctx.phone({
|
|
304
|
+
endpoint: "sip:5060",
|
|
305
|
+
from: "sip:caller@spectest.test",
|
|
306
|
+
language: "sv",
|
|
307
|
+
});
|
|
308
|
+
await phone.call("receptionist");
|
|
309
|
+
await phone.say("Hej! Jag vill boka en tid.");
|
|
310
|
+
await phone.hangup();
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
The phone offers all installed codecs, preferring Opus then higher sample rates,
|
|
314
|
+
with G.711 and other legacy codecs available as fallbacks. Recordings use 48 kHz
|
|
315
|
+
mono PCM for capture and continuously encode it as 48 kbps VBR Opus/WebM.
|
|
316
|
+
Encoding runs off the SIP callback thread and writes no intermediate WAV;
|
|
317
|
+
the original audio timeline and waveform data are preserved.
|
|
318
|
+
|
|
319
|
+
Supertonic 3 runs locally using models installed in the golden image. Each
|
|
320
|
+
`call`, `say`, and `hangup` is recorded. One continuous recording captures
|
|
321
|
+
both sides from dialing through hangup, including replies and pauses between
|
|
322
|
+
steps. Each `say` clip starts when speech is transmitted, skipping generation
|
|
323
|
+
time, and ends when that step completes. Both `call(destination, { waitFor })`
|
|
324
|
+
and `say(text, { waitFor })` poll application state, then wait for incoming
|
|
325
|
+
silence. For `call`, polling starts after the call connects, allowing the greeting
|
|
326
|
+
to finish within the call step. For `say`, polling starts after outgoing playback.
|
|
327
|
+
Without `waitFor`, `call` ends when connected and `say` ends after outgoing playback. The quiet
|
|
328
|
+
period (700 ms), poll interval (100 ms), and wait timeout (30 seconds) are
|
|
329
|
+
internal defaults for now. `waitFor` may return a nonempty transcript string
|
|
330
|
+
instead of `true`; after the silence wait, it appears as **Response** below **Caller** in an indented chat row beneath the waveform. Return `false` or `""` to keep waiting. Your test supplies the text
|
|
331
|
+
from application state or a fake; an async callback can wait and then return it.
|
|
332
|
+
Playback starts muted and remembers your mute choice across steps, with
|
|
333
|
+
overlapping blue caller and violet recipient waveforms and replay controls.
|
|
334
|
+
Both sides play together from the same recording. Step playback trims silence at the edges of
|
|
335
|
+
utterances and waited greetings; greetings also show the approximate time to
|
|
336
|
+
first audio. Hangup steps have no player. The complete recording remains stored. Outgoing
|
|
337
|
+
text remains a plain transcript; incoming audio is not transcribed automatically. Calls
|
|
338
|
+
close and recordings finalize automatically at case end. This initial API requires
|
|
339
|
+
SDK 0.112.0 and a golden image with phone support.
|
|
@@ -9,6 +9,28 @@ export type WebSocketKey<S = unknown> = ((message: ReplayWebSocketMessage) => Ma
|
|
|
9
9
|
state: S;
|
|
10
10
|
key: MatchKey;
|
|
11
11
|
};
|
|
12
|
+
} | {
|
|
13
|
+
initial: S;
|
|
14
|
+
/** Candidate keys, not mandatory inputs. Emit multiple keys to normalize stream chunking. */
|
|
15
|
+
input(state: S, message: ReplayWebSocketMessage): {
|
|
16
|
+
state: S;
|
|
17
|
+
} & ({
|
|
18
|
+
key: MatchKey;
|
|
19
|
+
keys?: never;
|
|
20
|
+
} | {
|
|
21
|
+
keys: MatchKey[];
|
|
22
|
+
key?: never;
|
|
23
|
+
});
|
|
24
|
+
/** Runs on real output during recording and saved output during replay.
|
|
25
|
+
* Select the latest candidate as a timing anchor during recording only. */
|
|
26
|
+
output(state: S, message: ReplayWebSocketMessage): {
|
|
27
|
+
state: S;
|
|
28
|
+
anchorPreviousInput: boolean;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export type WebSocketMatcher = ((message: ReplayWebSocketMessage) => string | null) & {
|
|
32
|
+
candidates?: (message: ReplayWebSocketMessage) => string[];
|
|
33
|
+
output?: (message: ReplayWebSocketMessage) => boolean;
|
|
12
34
|
};
|
|
13
35
|
export interface WebSocketTiming {
|
|
14
36
|
/** Number of emitted input keys required. Zero means connection opening. */
|
|
@@ -25,6 +47,10 @@ export interface CassetteWebSocket {
|
|
|
25
47
|
request: CassetteRequest;
|
|
26
48
|
protocol?: string;
|
|
27
49
|
inputs: string[];
|
|
50
|
+
/** Only output-selected candidates are required in this mode. */
|
|
51
|
+
inputMode?: "anchors";
|
|
52
|
+
/** One-based occurrence of each selected hash in the full candidate stream. */
|
|
53
|
+
inputOccurrences?: number[];
|
|
28
54
|
messages: CassetteWebSocketMessage[];
|
|
29
55
|
/** Absent while recording a connection that is still open. */
|
|
30
56
|
close?: WebSocketTiming & {
|
|
@@ -35,14 +61,14 @@ export interface CassetteWebSocket {
|
|
|
35
61
|
}
|
|
36
62
|
export declare function validateWebSocketKey<S>(key: WebSocketKey<S> | undefined): void;
|
|
37
63
|
/** Clone once per connection; never retain incoming payloads. */
|
|
38
|
-
export declare function websocketMatcher<S>(key?: WebSocketKey<S>):
|
|
64
|
+
export declare function websocketMatcher<S>(key?: WebSocketKey<S>): WebSocketMatcher;
|
|
39
65
|
export declare function validateWebSocketRecording(record: CassetteWebSocket): void;
|
|
40
66
|
export interface WebSocketSession {
|
|
41
67
|
cancel(): void;
|
|
42
68
|
}
|
|
43
69
|
export declare function replayWebSocket(args: {
|
|
44
70
|
recording: CassetteWebSocket;
|
|
45
|
-
match:
|
|
71
|
+
match: WebSocketMatcher;
|
|
46
72
|
label: string;
|
|
47
73
|
timeoutMs: number;
|
|
48
74
|
lifetime?: FakeWebSocketLifetime;
|
|
@@ -59,7 +85,7 @@ export declare function recordWebSocket(args: {
|
|
|
59
85
|
headers: Record<string, string>;
|
|
60
86
|
protocols: string[];
|
|
61
87
|
request: CassetteRequest;
|
|
62
|
-
match:
|
|
88
|
+
match: WebSocketMatcher;
|
|
63
89
|
redact(value: string): string;
|
|
64
90
|
secrets: {
|
|
65
91
|
ref: string;
|
|
@@ -8,17 +8,61 @@ const require = createRequire(import.meta.url);
|
|
|
8
8
|
const { WebSocket } = require(join(dirname(require.resolve("ws/package.json")), "index.js"));
|
|
9
9
|
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
10
10
|
const MAX_PENDING = 1024;
|
|
11
|
+
const MAX_CANDIDATE_KEYS = 65536;
|
|
12
|
+
function occurrence(counts, key) {
|
|
13
|
+
if (!counts.has(key) && counts.size >= MAX_CANDIDATE_KEYS)
|
|
14
|
+
throw new Error("WebSocket candidate key limit exceeded");
|
|
15
|
+
const count = (counts.get(key) ?? 0) + 1;
|
|
16
|
+
counts.set(key, count);
|
|
17
|
+
return count;
|
|
18
|
+
}
|
|
19
|
+
function synchronous(value, label) {
|
|
20
|
+
if (value && typeof value === "object" && "then" in value) {
|
|
21
|
+
if (value instanceof Promise)
|
|
22
|
+
void value.catch(() => { });
|
|
23
|
+
throw new Error(`websocketKey ${label} must be synchronous`);
|
|
24
|
+
}
|
|
25
|
+
if (!value || typeof value !== "object" || !("state" in value))
|
|
26
|
+
throw new Error(`websocketKey ${label} must return state`);
|
|
27
|
+
}
|
|
11
28
|
export function validateWebSocketKey(key) {
|
|
12
29
|
if (key === undefined || typeof key === "function")
|
|
13
30
|
return;
|
|
14
|
-
if (!key || typeof key !== "object" || !("initial" in key)
|
|
15
|
-
|
|
31
|
+
if (!key || typeof key !== "object" || !("initial" in key)
|
|
32
|
+
|| !("reduce" in key && typeof key.reduce === "function" && !("input" in key) && !("output" in key)
|
|
33
|
+
|| "input" in key && typeof key.input === "function" && "output" in key && typeof key.output === "function" && !("reduce" in key))) {
|
|
34
|
+
throw new Error("websocketKey must be a synchronous function, { initial, reduce }, or { initial, input, output }");
|
|
16
35
|
}
|
|
17
36
|
structuredClone(key.initial);
|
|
18
37
|
}
|
|
19
38
|
/** Clone once per connection; never retain incoming payloads. */
|
|
20
39
|
export function websocketMatcher(key) {
|
|
21
40
|
let state = key && typeof key !== "function" ? structuredClone(key.initial) : undefined;
|
|
41
|
+
if (key && typeof key === "object" && "input" in key) {
|
|
42
|
+
const input = (message) => {
|
|
43
|
+
const next = key.input(state, message);
|
|
44
|
+
synchronous(next, "input");
|
|
45
|
+
if (("key" in next) === ("keys" in next) || "keys" in next && !Array.isArray(next.keys))
|
|
46
|
+
throw new Error("websocketKey input must return { state, key } or { state, keys }");
|
|
47
|
+
const values = "keys" in next ? next.keys : [next.key];
|
|
48
|
+
if (values.length > MAX_PENDING)
|
|
49
|
+
throw new Error("WebSocket candidate batch limit exceeded");
|
|
50
|
+
const hashes = values.filter(value => value !== null).map(hashKey);
|
|
51
|
+
state = next.state;
|
|
52
|
+
return hashes;
|
|
53
|
+
};
|
|
54
|
+
return Object.assign((message) => input(message).at(-1) ?? null, {
|
|
55
|
+
candidates: input,
|
|
56
|
+
output(message) {
|
|
57
|
+
const next = key.output(state, message);
|
|
58
|
+
synchronous(next, "output");
|
|
59
|
+
if (typeof next.anchorPreviousInput !== "boolean")
|
|
60
|
+
throw new Error("websocketKey output must return { state, anchorPreviousInput }");
|
|
61
|
+
state = next.state;
|
|
62
|
+
return next.anchorPreviousInput;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
22
66
|
return message => {
|
|
23
67
|
if (!key)
|
|
24
68
|
return createHash("sha256").update(typeof message === "string" ? "text:" : "binary:").update(message).digest("hex");
|
|
@@ -49,6 +93,21 @@ export function validateWebSocketRecording(record) {
|
|
|
49
93
|
|| !Array.isArray(record.messages)) {
|
|
50
94
|
throw new Error("invalid WebSocket recording: expected request, input hashes and messages");
|
|
51
95
|
}
|
|
96
|
+
if (record.inputMode !== undefined && record.inputMode !== "anchors")
|
|
97
|
+
throw new Error("invalid WebSocket input mode");
|
|
98
|
+
if (record.inputMode === "anchors") {
|
|
99
|
+
if (!Array.isArray(record.inputOccurrences) || record.inputOccurrences.length !== record.inputs.length)
|
|
100
|
+
throw new Error("invalid WebSocket anchor occurrences");
|
|
101
|
+
const seen = new Map();
|
|
102
|
+
record.inputs.forEach((key, index) => {
|
|
103
|
+
const count = record.inputOccurrences[index];
|
|
104
|
+
if (!Number.isSafeInteger(count) || count < 1 || count <= (seen.get(key) ?? 0))
|
|
105
|
+
throw new Error("invalid WebSocket anchor occurrence order");
|
|
106
|
+
seen.set(key, count);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
else if (record.inputOccurrences !== undefined)
|
|
110
|
+
throw new Error("WebSocket occurrences require anchor mode");
|
|
52
111
|
for (const message of record.messages) {
|
|
53
112
|
if (!message || typeof message.data !== "string" || !["text", "binary"].includes(message.type)) {
|
|
54
113
|
throw new Error("invalid WebSocket output message");
|
|
@@ -96,6 +155,9 @@ function closeSocket(socket, code, reason) {
|
|
|
96
155
|
}
|
|
97
156
|
export function replayWebSocket(args) {
|
|
98
157
|
const { recording: record, label } = args;
|
|
158
|
+
if ((record.inputMode === "anchors") !== !!args.match.candidates)
|
|
159
|
+
throw new Error(`${label}: websocketKey mode changed; re-record this cassette`);
|
|
160
|
+
const occurrences = new Map();
|
|
99
161
|
const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
|
|
100
162
|
let detach = () => { };
|
|
101
163
|
let socket;
|
|
@@ -127,32 +189,61 @@ export function replayWebSocket(args) {
|
|
|
127
189
|
if (position < record.inputs.length)
|
|
128
190
|
deadline = setTimeout(() => fail(missing()), args.timeoutMs);
|
|
129
191
|
};
|
|
192
|
+
const nextEvent = () => record.messages[output]
|
|
193
|
+
?? (record.close?.initiator === "server" ? record.close : undefined);
|
|
194
|
+
const starts = record.inputMode === "anchors"
|
|
195
|
+
? [...record.messages, ...(record.close?.initiator === "server" ? [record.close] : [])]
|
|
196
|
+
.filter((event, index, events) => index === 0 || event.afterInput !== events[index - 1].afterInput)
|
|
197
|
+
: [];
|
|
198
|
+
const dueAt = (event) => {
|
|
199
|
+
let due = barriers[event.afterInput] + event.delayMs;
|
|
200
|
+
// Input progress can be faster than the recording (e.g. cached TTS).
|
|
201
|
+
// Once a later anchor is reached, earlier output must not hold its response
|
|
202
|
+
// behind the old clock. Catch up in order, without dropping any messages.
|
|
203
|
+
for (const next of starts) {
|
|
204
|
+
if (next.afterInput > event.afterInput && barriers[next.afterInput] !== undefined) {
|
|
205
|
+
due = Math.min(due, barriers[next.afterInput] + next.delayMs);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return due;
|
|
209
|
+
};
|
|
130
210
|
const schedule = () => {
|
|
131
211
|
if (stopped || !socket || sentClose)
|
|
132
212
|
return;
|
|
133
213
|
clearTimeout(timer);
|
|
134
|
-
const event =
|
|
214
|
+
const event = nextEvent();
|
|
135
215
|
if (!event || barriers[event.afterInput] === undefined)
|
|
136
216
|
return;
|
|
137
|
-
const delay = Math.max(0, barriers[event.afterInput] + event.delayMs - performance.now());
|
|
138
217
|
timer = setTimeout(() => {
|
|
139
218
|
if (stopped)
|
|
140
219
|
return;
|
|
141
220
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
221
|
+
// Flush overdue output together; one timer per message would itself
|
|
222
|
+
// add latency when catching up a continuous stream of quiet audio.
|
|
223
|
+
for (let batch = 0; batch < MAX_PENDING && !stopped; batch++) {
|
|
224
|
+
const event = nextEvent();
|
|
225
|
+
if (!event || barriers[event.afterInput] === undefined)
|
|
226
|
+
return;
|
|
227
|
+
if (dueAt(event) > performance.now())
|
|
228
|
+
break;
|
|
229
|
+
if ("data" in event) {
|
|
230
|
+
const message = event.type === "text" ? event.data : new Uint8Array(Buffer.from(event.data, "base64"));
|
|
231
|
+
args.match.output?.(typeof message === "string" ? message : message.slice());
|
|
232
|
+
socket.send(message);
|
|
233
|
+
output++;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
sentClose = true;
|
|
237
|
+
closeSocket(socket, event.code, event.reason);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
150
240
|
}
|
|
241
|
+
schedule();
|
|
151
242
|
}
|
|
152
243
|
catch (error) {
|
|
153
244
|
fail(diagnostic(`playback failed: ${error.message}`));
|
|
154
245
|
}
|
|
155
|
-
}, Math.min(
|
|
246
|
+
}, Math.min(Math.max(0, dueAt(event) - performance.now()), 2_147_483_647));
|
|
156
247
|
};
|
|
157
248
|
const session = {
|
|
158
249
|
cancel() { finish(); socket?.close(1001, "replay cleanup"); },
|
|
@@ -174,13 +265,28 @@ export function replayWebSocket(args) {
|
|
|
174
265
|
if (stopped)
|
|
175
266
|
return;
|
|
176
267
|
try {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
268
|
+
if (args.match.candidates) {
|
|
269
|
+
let matched = false;
|
|
270
|
+
for (const actual of args.match.candidates(message)) {
|
|
271
|
+
const count = occurrence(occurrences, actual);
|
|
272
|
+
if (actual !== record.inputs[position] || count !== record.inputOccurrences[position])
|
|
273
|
+
continue;
|
|
274
|
+
barriers[++position] = performance.now();
|
|
275
|
+
matched = true;
|
|
276
|
+
}
|
|
277
|
+
// Unselected candidates never extend the deadline.
|
|
278
|
+
if (!matched)
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const actual = args.match(message);
|
|
283
|
+
if (actual === null)
|
|
284
|
+
return;
|
|
285
|
+
const expected = record.inputs[position];
|
|
286
|
+
if (expected !== actual)
|
|
287
|
+
throw new Error(`expected ${expected ?? "<exhausted>"}, actual ${actual}`);
|
|
288
|
+
barriers[++position] = performance.now();
|
|
289
|
+
}
|
|
184
290
|
armDeadline();
|
|
185
291
|
schedule();
|
|
186
292
|
}
|
|
@@ -229,9 +335,22 @@ export async function recordWebSocket(args) {
|
|
|
229
335
|
callback(null, args.ip, 4);
|
|
230
336
|
}),
|
|
231
337
|
});
|
|
232
|
-
const record = { request: args.request, inputs: [], messages: []
|
|
338
|
+
const record = { request: args.request, inputs: [], messages: [],
|
|
339
|
+
...(args.match.candidates ? { inputMode: "anchors", inputOccurrences: [] } : {}),
|
|
340
|
+
};
|
|
341
|
+
const occurrences = new Map();
|
|
342
|
+
let candidate;
|
|
343
|
+
let candidateIndex = 0, selectedIndex = 0;
|
|
233
344
|
let socket;
|
|
234
345
|
let anchor = performance.now();
|
|
346
|
+
const selectCandidate = () => {
|
|
347
|
+
if (!candidate || candidate.index === selectedIndex)
|
|
348
|
+
return;
|
|
349
|
+
record.inputs.push(candidate.key);
|
|
350
|
+
record.inputOccurrences.push(candidate.count);
|
|
351
|
+
anchor = candidate.at;
|
|
352
|
+
selectedIndex = candidate.index;
|
|
353
|
+
};
|
|
235
354
|
let stopped = false;
|
|
236
355
|
let clientClosed = false;
|
|
237
356
|
let upstreamClosure;
|
|
@@ -279,6 +398,9 @@ export async function recordWebSocket(args) {
|
|
|
279
398
|
const message = binary ? new Uint8Array(bytes) : bytes.toString();
|
|
280
399
|
// Redact bytes too: base64 encoding must not hide a brokered secret.
|
|
281
400
|
const redacted = binary ? redactBinary(bytes, args.redact, args.secrets) : scrub(bytes.toString());
|
|
401
|
+
// Apply the same redacted output reducer that replay will see.
|
|
402
|
+
if (args.match.output?.(binary ? new Uint8Array(Buffer.from(redacted, "base64")) : redacted))
|
|
403
|
+
selectCandidate();
|
|
282
404
|
record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
|
|
283
405
|
if (socket)
|
|
284
406
|
socket.send(message);
|
|
@@ -302,6 +424,7 @@ export async function recordWebSocket(args) {
|
|
|
302
424
|
}
|
|
303
425
|
try {
|
|
304
426
|
if (!clientClosed) {
|
|
427
|
+
selectCandidate();
|
|
305
428
|
upstreamClosure = { code, reason: reason.toString() };
|
|
306
429
|
record.close = { ...timing(), initiator: "server", code, reason: scrub(reason.toString()) };
|
|
307
430
|
}
|
|
@@ -358,10 +481,18 @@ export async function recordWebSocket(args) {
|
|
|
358
481
|
if (stopped)
|
|
359
482
|
return;
|
|
360
483
|
try {
|
|
361
|
-
const
|
|
362
|
-
if (
|
|
363
|
-
|
|
364
|
-
|
|
484
|
+
const input = typeof message === "string" ? message : Uint8Array.from(message);
|
|
485
|
+
if (args.match.candidates) {
|
|
486
|
+
const at = performance.now();
|
|
487
|
+
for (const key of args.match.candidates(input))
|
|
488
|
+
candidate = { key, count: occurrence(occurrences, key), at, index: ++candidateIndex };
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
const signature = args.match(input);
|
|
492
|
+
if (signature !== null) {
|
|
493
|
+
record.inputs.push(signature);
|
|
494
|
+
anchor = performance.now();
|
|
495
|
+
}
|
|
365
496
|
}
|
|
366
497
|
const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
367
498
|
if (size + upstream.bufferedAmount > MAX_BUFFER)
|
|
@@ -380,6 +511,7 @@ export async function recordWebSocket(args) {
|
|
|
380
511
|
return;
|
|
381
512
|
try {
|
|
382
513
|
clientClosed = true;
|
|
514
|
+
selectCandidate();
|
|
383
515
|
record.close = { ...timing(), initiator: "client", code, reason: scrub(reason) };
|
|
384
516
|
closeSocket(upstream, code, reason);
|
|
385
517
|
openingTimer = setTimeout(() => finish(), 1_000);
|
|
@@ -82,7 +82,10 @@ export interface ReplayFakeOptions<S = undefined, H extends Record<string, unkno
|
|
|
82
82
|
* matching dimensions or fallback apply when a key is supplied. */
|
|
83
83
|
key?: ReplayKey;
|
|
84
84
|
/** Complete messages (string or Uint8Array), synchronously reduced per connection.
|
|
85
|
-
*
|
|
85
|
+
* Functions and { initial, reduce } emit required keys (null skips an input).
|
|
86
|
+
* { initial, input, output } emits candidates; output can select the previous
|
|
87
|
+
* candidate as a recorded timing anchor. Replay waits for those selected keys.
|
|
88
|
+
* Raw inputs are never stored; changing this
|
|
86
89
|
* matcher can require re-recording. Defaults to SHA-256(type + bytes). */
|
|
87
90
|
websocketKey?: WebSocketKey<W>;
|
|
88
91
|
/** Maximum wait for each required input key; null-producing messages do not
|
package/dist/daemon.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { phoneScope, phoneRecordingChunk, releasePhoneRecording } from "./phone.js";
|
|
1
2
|
import { runWithBackgroundFailures } from "./harness/background-failures.js";
|
|
2
3
|
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
3
4
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
@@ -4184,6 +4185,7 @@ async function runOne(testCase) {
|
|
|
4184
4185
|
const start = Date.now();
|
|
4185
4186
|
const nativeAndroid = androidScope(testCase.id, start);
|
|
4186
4187
|
let androidRecordings = [];
|
|
4188
|
+
let phoneRecordings = [];
|
|
4187
4189
|
const chunks = [];
|
|
4188
4190
|
const origStdout = process.stdout.write.bind(process.stdout);
|
|
4189
4191
|
const origStderr = process.stderr.write.bind(process.stderr);
|
|
@@ -4439,6 +4441,7 @@ async function runOne(testCase) {
|
|
|
4439
4441
|
// Built before installing the timeout so a slow client factory surfaces as
|
|
4440
4442
|
// a real error rather than getting attributed to the test.
|
|
4441
4443
|
const base = await spectestContext();
|
|
4444
|
+
const phones = phoneScope(start);
|
|
4442
4445
|
const ctx = {
|
|
4443
4446
|
...base,
|
|
4444
4447
|
// exec/terminal/poll wrap their results at runtime (the recorder is
|
|
@@ -4452,6 +4455,7 @@ async function runOne(testCase) {
|
|
|
4452
4455
|
android: nativeAndroid.open,
|
|
4453
4456
|
desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
|
|
4454
4457
|
mcp: openMcp,
|
|
4458
|
+
phone: phones.open,
|
|
4455
4459
|
testName: testCase.name,
|
|
4456
4460
|
parent,
|
|
4457
4461
|
};
|
|
@@ -4492,6 +4496,10 @@ async function runOne(testCase) {
|
|
|
4492
4496
|
finally {
|
|
4493
4497
|
if (timer)
|
|
4494
4498
|
clearTimeout(timer);
|
|
4499
|
+
phoneRecordings = await phones.finish();
|
|
4500
|
+
const phoneErrors = phoneRecordings.flatMap(r => r.error ? [r.error] : []);
|
|
4501
|
+
if (phoneErrors.length && outcome.status === "passed")
|
|
4502
|
+
outcome = { status: "failed", error: { message: phoneErrors.join("\n") } };
|
|
4495
4503
|
nativeAndroid.deactivate();
|
|
4496
4504
|
RECORDING_EXEC = undefined;
|
|
4497
4505
|
INTERCEPTORS.endScope();
|
|
@@ -4580,6 +4588,7 @@ async function runOne(testCase) {
|
|
|
4580
4588
|
return {
|
|
4581
4589
|
status: outcome.status,
|
|
4582
4590
|
androidRecordings,
|
|
4591
|
+
phoneRecordings,
|
|
4583
4592
|
durationMs,
|
|
4584
4593
|
log: chunks.join(""),
|
|
4585
4594
|
events,
|
|
@@ -4928,6 +4937,7 @@ async function evalCode(code) {
|
|
|
4928
4937
|
// loaded `spectestContext()` yields empty maps, so quick `await fetch(...)`
|
|
4929
4938
|
// snippets don't require a /load round-trip first.
|
|
4930
4939
|
const base = await spectestContext();
|
|
4940
|
+
const phones = phoneScope(start, { record: false });
|
|
4931
4941
|
const ctx = {
|
|
4932
4942
|
...base,
|
|
4933
4943
|
// Bun does not propagate the import() caller's AsyncLocalStorage into
|
|
@@ -4945,6 +4955,7 @@ async function evalCode(code) {
|
|
|
4945
4955
|
android: nativeAndroid.open,
|
|
4946
4956
|
desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
|
|
4947
4957
|
mcp: openMcp,
|
|
4958
|
+
phone: phones.open,
|
|
4948
4959
|
testName: "eval",
|
|
4949
4960
|
parent: undefined,
|
|
4950
4961
|
};
|
|
@@ -4985,6 +4996,7 @@ async function evalCode(code) {
|
|
|
4985
4996
|
};
|
|
4986
4997
|
}
|
|
4987
4998
|
finally {
|
|
4999
|
+
await phones.finish();
|
|
4988
5000
|
fetchScope.active = false;
|
|
4989
5001
|
restoreFetch();
|
|
4990
5002
|
restoreConsole();
|
|
@@ -5362,6 +5374,8 @@ export function harnessMethods(state) {
|
|
|
5362
5374
|
},
|
|
5363
5375
|
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
5364
5376
|
// strings, so they travel in params rather than a URL path.
|
|
5377
|
+
phoneRecordingChunk: async (params) => phoneRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
|
|
5378
|
+
releasePhoneRecording: async (params) => releasePhoneRecording(String(params.id)),
|
|
5365
5379
|
androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
|
|
5366
5380
|
releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
|
|
5367
5381
|
replayChunk: async (params) => {
|