@specific.dev/spectest 0.111.0 → 0.113.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 +30 -0
- package/dist/harness/fake-functions.d.ts +18 -0
- package/dist/harness/fake-functions.js +82 -0
- package/dist/harness/function-parameters.d.ts +9 -0
- package/dist/harness/function-parameters.js +42 -0
- package/dist/harness/phone.py +427 -0
- package/dist/harness/phone_recording.py +95 -0
- package/dist/harness/protocol.d.ts +1 -1
- package/dist/harness/protocol.js +1 -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 +2 -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 +28 -0
- package/src/fake-functions.test.ts +62 -0
- package/src/harness/fake-functions.test.ts +77 -0
- package/src/harness/fake-functions.ts +78 -0
- package/src/harness/function-parameters.test.ts +45 -0
- package/src/harness/function-parameters.ts +43 -0
- package/src/harness/phone.py +427 -0
- package/src/harness/phone_recording.py +95 -0
- package/src/harness/protocol.ts +2 -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";
|
|
@@ -42,6 +43,7 @@ import { isDesktopApp } from "./desktop.js";
|
|
|
42
43
|
// each easy to restate subtly differently.
|
|
43
44
|
import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
|
|
44
45
|
import { startServices } from "./harness/service-graph.js";
|
|
46
|
+
import { fakeFunctions, fakeFunctionParameters, callFakeFunction } from "./harness/fake-functions.js";
|
|
45
47
|
import { dispatchFake } from "./harness/fake-websocket.js";
|
|
46
48
|
import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
|
|
47
49
|
import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
@@ -4184,6 +4186,7 @@ async function runOne(testCase) {
|
|
|
4184
4186
|
const start = Date.now();
|
|
4185
4187
|
const nativeAndroid = androidScope(testCase.id, start);
|
|
4186
4188
|
let androidRecordings = [];
|
|
4189
|
+
let phoneRecordings = [];
|
|
4187
4190
|
const chunks = [];
|
|
4188
4191
|
const origStdout = process.stdout.write.bind(process.stdout);
|
|
4189
4192
|
const origStderr = process.stderr.write.bind(process.stderr);
|
|
@@ -4439,6 +4442,7 @@ async function runOne(testCase) {
|
|
|
4439
4442
|
// Built before installing the timeout so a slow client factory surfaces as
|
|
4440
4443
|
// a real error rather than getting attributed to the test.
|
|
4441
4444
|
const base = await spectestContext();
|
|
4445
|
+
const phones = phoneScope(start);
|
|
4442
4446
|
const ctx = {
|
|
4443
4447
|
...base,
|
|
4444
4448
|
// exec/terminal/poll wrap their results at runtime (the recorder is
|
|
@@ -4452,6 +4456,7 @@ async function runOne(testCase) {
|
|
|
4452
4456
|
android: nativeAndroid.open,
|
|
4453
4457
|
desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
|
|
4454
4458
|
mcp: openMcp,
|
|
4459
|
+
phone: phones.open,
|
|
4455
4460
|
testName: testCase.name,
|
|
4456
4461
|
parent,
|
|
4457
4462
|
};
|
|
@@ -4492,6 +4497,10 @@ async function runOne(testCase) {
|
|
|
4492
4497
|
finally {
|
|
4493
4498
|
if (timer)
|
|
4494
4499
|
clearTimeout(timer);
|
|
4500
|
+
phoneRecordings = await phones.finish();
|
|
4501
|
+
const phoneErrors = phoneRecordings.flatMap(r => r.error ? [r.error] : []);
|
|
4502
|
+
if (phoneErrors.length && outcome.status === "passed")
|
|
4503
|
+
outcome = { status: "failed", error: { message: phoneErrors.join("\n") } };
|
|
4495
4504
|
nativeAndroid.deactivate();
|
|
4496
4505
|
RECORDING_EXEC = undefined;
|
|
4497
4506
|
INTERCEPTORS.endScope();
|
|
@@ -4580,6 +4589,7 @@ async function runOne(testCase) {
|
|
|
4580
4589
|
return {
|
|
4581
4590
|
status: outcome.status,
|
|
4582
4591
|
androidRecordings,
|
|
4592
|
+
phoneRecordings,
|
|
4583
4593
|
durationMs,
|
|
4584
4594
|
log: chunks.join(""),
|
|
4585
4595
|
events,
|
|
@@ -4928,6 +4938,7 @@ async function evalCode(code) {
|
|
|
4928
4938
|
// loaded `spectestContext()` yields empty maps, so quick `await fetch(...)`
|
|
4929
4939
|
// snippets don't require a /load round-trip first.
|
|
4930
4940
|
const base = await spectestContext();
|
|
4941
|
+
const phones = phoneScope(start, { record: false });
|
|
4931
4942
|
const ctx = {
|
|
4932
4943
|
...base,
|
|
4933
4944
|
// Bun does not propagate the import() caller's AsyncLocalStorage into
|
|
@@ -4945,6 +4956,7 @@ async function evalCode(code) {
|
|
|
4945
4956
|
android: nativeAndroid.open,
|
|
4946
4957
|
desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
|
|
4947
4958
|
mcp: openMcp,
|
|
4959
|
+
phone: phones.open,
|
|
4948
4960
|
testName: "eval",
|
|
4949
4961
|
parent: undefined,
|
|
4950
4962
|
};
|
|
@@ -4985,6 +4997,7 @@ async function evalCode(code) {
|
|
|
4985
4997
|
};
|
|
4986
4998
|
}
|
|
4987
4999
|
finally {
|
|
5000
|
+
await phones.finish();
|
|
4988
5001
|
fetchScope.active = false;
|
|
4989
5002
|
restoreFetch();
|
|
4990
5003
|
restoreConsole();
|
|
@@ -5274,6 +5287,21 @@ export function harnessMethods(state) {
|
|
|
5274
5287
|
}),
|
|
5275
5288
|
projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
|
|
5276
5289
|
interactiveSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup("setupInteractive")),
|
|
5290
|
+
fakeFunctions: async (params) => exclusive(state, "inFlightTest", "a test or eval is already running", async () => {
|
|
5291
|
+
const name = requireString(params, "fake");
|
|
5292
|
+
const fake = FAKES.get(name);
|
|
5293
|
+
if (!fake)
|
|
5294
|
+
throw notFound(`Unknown fake: ${name}`);
|
|
5295
|
+
await ensureFakeHelpers(name);
|
|
5296
|
+
if (params.member === undefined)
|
|
5297
|
+
return { functions: fakeFunctions(fake.helpers), parameters: fakeFunctionParameters(fake.helpers) };
|
|
5298
|
+
const member = requireString(params, "member");
|
|
5299
|
+
if (params.expressions !== undefined)
|
|
5300
|
+
return callFakeFunction(fake.helpers, member, [], [], params.expressions);
|
|
5301
|
+
if (!Array.isArray(params.args))
|
|
5302
|
+
throw badRequest("args must be a JSON array");
|
|
5303
|
+
return callFakeFunction(fake.helpers, member, params.args, params.omitted_args);
|
|
5304
|
+
}),
|
|
5277
5305
|
eval: async (params) => {
|
|
5278
5306
|
const code = requireString(params, "code");
|
|
5279
5307
|
return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
|
|
@@ -5362,6 +5390,8 @@ export function harnessMethods(state) {
|
|
|
5362
5390
|
},
|
|
5363
5391
|
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
5364
5392
|
// strings, so they travel in params rather than a URL path.
|
|
5393
|
+
phoneRecordingChunk: async (params) => phoneRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
|
|
5394
|
+
releasePhoneRecording: async (params) => releasePhoneRecording(String(params.id)),
|
|
5365
5395
|
androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
|
|
5366
5396
|
releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
|
|
5367
5397
|
replayChunk: async (params) => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Only own data properties are callable. Discovery never invokes getters. */
|
|
2
|
+
export declare function fakeFunctions(helpers: Record<string, unknown>): string[];
|
|
3
|
+
export declare function fakeFunctionParameters(helpers: Record<string, unknown>): {
|
|
4
|
+
[k: string]: import("./function-parameters.js").FunctionParameter[] | null;
|
|
5
|
+
};
|
|
6
|
+
/** Use the raw helpers: the test wrappers intentionally discard annotations
|
|
7
|
+
* when there is no recorder. Preserve both the value and its presentation. */
|
|
8
|
+
export declare function callFakeFunction(helpers: Record<string, unknown>, member: string, args: unknown[], omittedArgs?: number[], expressions?: string[]): Promise<{
|
|
9
|
+
result: {} | null;
|
|
10
|
+
annotation: import("../annotate.js").RenderAnnotation | null;
|
|
11
|
+
error: null;
|
|
12
|
+
duration_ms: number;
|
|
13
|
+
} | {
|
|
14
|
+
result: null;
|
|
15
|
+
annotation: null;
|
|
16
|
+
error: string;
|
|
17
|
+
duration_ms: number;
|
|
18
|
+
}>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
import { functionParameters } from "./function-parameters.js";
|
|
3
|
+
import { readAnnotation } from "../annotate.js";
|
|
4
|
+
import { deepUnwrap } from "../inspect.js";
|
|
5
|
+
import { safeSerialize } from "../recorder.js";
|
|
6
|
+
import { runUninstrumented } from "./instrumentation-scope.js";
|
|
7
|
+
/** Only own data properties are callable. Discovery never invokes getters. */
|
|
8
|
+
export function fakeFunctions(helpers) {
|
|
9
|
+
return Object.getOwnPropertyNames(helpers).filter((name) => typeof Object.getOwnPropertyDescriptor(helpers, name)?.value === "function").sort();
|
|
10
|
+
}
|
|
11
|
+
export function fakeFunctionParameters(helpers) {
|
|
12
|
+
return Object.fromEntries(fakeFunctions(helpers).map(name => [name,
|
|
13
|
+
functionParameters(Object.getOwnPropertyDescriptor(helpers, name).value),
|
|
14
|
+
]));
|
|
15
|
+
}
|
|
16
|
+
/** Use the raw helpers: the test wrappers intentionally discard annotations
|
|
17
|
+
* when there is no recorder. Preserve both the value and its presentation. */
|
|
18
|
+
export async function callFakeFunction(helpers, member, args, omittedArgs = [], expressions) {
|
|
19
|
+
const fn = Object.getOwnPropertyDescriptor(helpers, member)?.value;
|
|
20
|
+
if (typeof fn !== "function")
|
|
21
|
+
throw new Error(`Unknown fake function: ${member}`);
|
|
22
|
+
if (!Array.isArray(omittedArgs) || omittedArgs.some(i => !Number.isInteger(i) || i < 0 || i >= args.length || args[i] !== null))
|
|
23
|
+
throw new Error("omitted_args must contain indices of null argument placeholders");
|
|
24
|
+
const omitted = new Set(omittedArgs);
|
|
25
|
+
const actualArgs = args.map((value, i) => omitted.has(i) ? undefined : value);
|
|
26
|
+
const start = Date.now();
|
|
27
|
+
try {
|
|
28
|
+
const value = await runUninstrumented(() => Reflect.apply(fn, helpers, expressions === undefined ? actualArgs : evaluateArguments(fn, expressions)));
|
|
29
|
+
const annotated = readAnnotation(value);
|
|
30
|
+
return {
|
|
31
|
+
result: safeSerialize(deepUnwrap(annotated ? annotated.value : value)) ?? null,
|
|
32
|
+
annotation: annotated?.annotation ?? null,
|
|
33
|
+
error: null,
|
|
34
|
+
duration_ms: Date.now() - start,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return { result: null, annotation: null,
|
|
39
|
+
error: error instanceof Error ? error.message : String(error), duration_ms: Date.now() - start };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Expressions execute only in the environment, never in either dashboard.
|
|
43
|
+
* Compile every field before evaluating any, so a syntax error cannot partially
|
|
44
|
+
* execute the call. Keep actual JS values (undefined, Date, functions, etc.). */
|
|
45
|
+
function evaluateArguments(fn, expressions) {
|
|
46
|
+
if (!Array.isArray(expressions) || expressions.some(source => typeof source !== "string"))
|
|
47
|
+
throw new Error("expressions must be an array of JavaScript expressions");
|
|
48
|
+
const parameters = functionParameters(fn);
|
|
49
|
+
const label = (index) => parameters?.[index]?.name ?? `Argument ${index + 1}`;
|
|
50
|
+
const withLabel = (index, action) => {
|
|
51
|
+
try {
|
|
52
|
+
return action();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw new Error(`${label(index)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const compiled = expressions.map((source, index) => withLabel(index, () => {
|
|
59
|
+
if (!source.trim())
|
|
60
|
+
return null;
|
|
61
|
+
const expression = `(${source}\n)`;
|
|
62
|
+
const program = parse(expression, { ecmaVersion: "latest" });
|
|
63
|
+
if (program.body.length !== 1 || program.body[0].type !== "ExpressionStatement")
|
|
64
|
+
throw new Error("Enter a single JavaScript expression.");
|
|
65
|
+
return new Function(`"use strict"; return ${expression};`);
|
|
66
|
+
}));
|
|
67
|
+
// Preserve interior omissions but avoid inventing trailing arguments.
|
|
68
|
+
while (compiled.length && compiled.at(-1) === null)
|
|
69
|
+
compiled.pop();
|
|
70
|
+
const args = [];
|
|
71
|
+
compiled.forEach((evaluate, index) => withLabel(index, () => {
|
|
72
|
+
const value = evaluate ? evaluate() : undefined;
|
|
73
|
+
if (parameters?.[index]?.rest) {
|
|
74
|
+
if (!Array.isArray(value))
|
|
75
|
+
throw new Error("Enter an array expression for the remaining arguments.");
|
|
76
|
+
args.push(...value);
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
args.push(value);
|
|
80
|
+
}));
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface FunctionParameter {
|
|
2
|
+
name: string;
|
|
3
|
+
has_default: boolean;
|
|
4
|
+
rest: boolean;
|
|
5
|
+
destructured: boolean;
|
|
6
|
+
}
|
|
7
|
+
/** Parse runtime JavaScript, never execute it (including default expressions).
|
|
8
|
+
* Null means opaque/unparseable, while [] means a known zero-argument helper. */
|
|
9
|
+
export declare function functionParameters(fn: Function): FunctionParameter[] | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
/** Parse runtime JavaScript, never execute it (including default expressions).
|
|
3
|
+
* Null means opaque/unparseable, while [] means a known zero-argument helper. */
|
|
4
|
+
export function functionParameters(fn) {
|
|
5
|
+
const source = Function.prototype.toString.call(fn);
|
|
6
|
+
if (source.length > 256_000)
|
|
7
|
+
return null;
|
|
8
|
+
// Function/arrow expressions and object methods have different grammar.
|
|
9
|
+
for (const method of [false, true]) {
|
|
10
|
+
const text = method ? `({${source}})` : `(${source})`;
|
|
11
|
+
try {
|
|
12
|
+
const program = parse(text, { ecmaVersion: "latest", sourceType: "module" });
|
|
13
|
+
const statement = program.body[0];
|
|
14
|
+
if (statement?.type !== "ExpressionStatement")
|
|
15
|
+
continue;
|
|
16
|
+
let expression = statement.expression;
|
|
17
|
+
if (method) {
|
|
18
|
+
if (expression.type !== "ObjectExpression")
|
|
19
|
+
continue;
|
|
20
|
+
const properties = expression.properties;
|
|
21
|
+
if (properties.length !== 1 || properties[0]?.type !== "Property")
|
|
22
|
+
continue;
|
|
23
|
+
expression = properties[0].value;
|
|
24
|
+
}
|
|
25
|
+
if (expression.type !== "FunctionExpression" && expression.type !== "ArrowFunctionExpression")
|
|
26
|
+
continue;
|
|
27
|
+
return expression.params.map((parameter) => {
|
|
28
|
+
const rest = parameter.type === "RestElement";
|
|
29
|
+
let binding = rest ? parameter.argument : parameter;
|
|
30
|
+
const has_default = binding.type === "AssignmentPattern";
|
|
31
|
+
if (binding.type === "AssignmentPattern")
|
|
32
|
+
binding = binding.left;
|
|
33
|
+
return {
|
|
34
|
+
name: binding.type === "Identifier" ? binding.name : text.slice(binding.start, binding.end),
|
|
35
|
+
has_default, rest, destructured: binding.type !== "Identifier",
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch { /* Native/bound functions and unsupported syntax stay opaque. */ }
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|