@specific.dev/spectest 0.110.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 +18 -3
- package/dist/harness/phone.py +427 -0
- package/dist/harness/phone_recording.py +95 -0
- package/dist/index.d.ts +20 -7
- package/dist/index.js +2 -2
- 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 +18 -3
- package/src/harness/phone.py +427 -0
- package/src/harness/phone_recording.py +95 -0
- package/src/index.ts +17 -8
- package/src/interactive-setup.test.ts +2 -2
- package/src/phone.test.ts +474 -0
- package/src/phone.ts +330 -0
- package/src/recorder.ts +1 -0
|
@@ -163,6 +163,11 @@ describe("scripted behavior on replay fakes", () => {
|
|
|
163
163
|
extends: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
|
|
164
164
|
compilerOptions: {
|
|
165
165
|
noEmit: true,
|
|
166
|
+
// The WebSocket regression suite also typechecks the example key.
|
|
167
|
+
// Resolve its public import without relying on a local file: install.
|
|
168
|
+
paths: {
|
|
169
|
+
"@specific.dev/spectest/components": [fileURLToPath(new URL("./index.ts", import.meta.url))],
|
|
170
|
+
},
|
|
166
171
|
rootDir: fileURLToPath(new URL("../../../", import.meta.url)),
|
|
167
172
|
typeRoots: [fileURLToPath(new URL("../../node_modules/@types", import.meta.url))],
|
|
168
173
|
},
|
|
@@ -262,3 +262,169 @@ test("an abandoned handshake cancels the connection before open", async () => {
|
|
|
262
262
|
expect(h.state.websocketSessions.size).toBe(0);
|
|
263
263
|
expect(h.state.websocketErrors).toEqual([]);
|
|
264
264
|
});
|
|
265
|
+
|
|
266
|
+
test("output hooks select previous candidates; record and replay preserve timing without quiet", async () => {
|
|
267
|
+
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
|
268
|
+
await once(server, "listening");
|
|
269
|
+
cleanup.push(() => { for (const c of server.clients) c.terminate(); server.close(); });
|
|
270
|
+
server.on("connection", ws => {
|
|
271
|
+
let count = 0;
|
|
272
|
+
ws.send("opening");
|
|
273
|
+
ws.on("message", () => { if (++count === 3) setTimeout(() => { ws.send("answer"); ws.close(4001, "done"); }, 25); });
|
|
274
|
+
});
|
|
275
|
+
const key: WebSocketKey<{ outputs: number }> = {
|
|
276
|
+
initial: { outputs: 0 },
|
|
277
|
+
input(state, message) { return { state, key: message === "noise" ? null : message }; },
|
|
278
|
+
output(state, message) { return { state: { outputs: state.outputs + 1 }, anchorPreviousInput: message === "answer" }; },
|
|
279
|
+
};
|
|
280
|
+
let record!: CassetteWebSocket;
|
|
281
|
+
let complete!: () => void;
|
|
282
|
+
const done = new Promise<void>(resolve => complete = resolve);
|
|
283
|
+
const errors: Error[] = [];
|
|
284
|
+
const r = await recordWebSocket({
|
|
285
|
+
url: `ws://voice.test:${(server.address() as { port: number }).port}/live`, ip: "127.0.0.1", host: "voice.test", headers: {}, protocols: [], request,
|
|
286
|
+
match: websocketMatcher(key), redact: s => s, secrets: [], label: "anchored",
|
|
287
|
+
captured: value => record = value,
|
|
288
|
+
finished(_session, error) { if (error) errors.push(error); complete(); },
|
|
289
|
+
});
|
|
290
|
+
cleanup.push(() => r.session.cancel());
|
|
291
|
+
const socket: FakeWebSocket = { readyState: 1, send() {}, close() {} };
|
|
292
|
+
await r.handlers.open!(socket);
|
|
293
|
+
await r.handlers.message!(socket, "previous");
|
|
294
|
+
await r.handlers.message!(socket, "candidate");
|
|
295
|
+
await r.handlers.message!(socket, "candidate");
|
|
296
|
+
await done;
|
|
297
|
+
expect(errors).toEqual([]);
|
|
298
|
+
expect(record.inputMode).toBe("anchors");
|
|
299
|
+
expect(record.inputs).toEqual([websocketMatcher(() => "candidate")("x")!]);
|
|
300
|
+
expect(record.inputOccurrences).toEqual([2]);
|
|
301
|
+
expect(record.messages[0].afterInput).toBe(0);
|
|
302
|
+
expect(record.messages[1]).toMatchObject({ afterInput: 1, data: "answer" });
|
|
303
|
+
expect(record.messages[1].delayMs).toBeGreaterThanOrEqual(20);
|
|
304
|
+
expect(record.messages[1].delayMs).toBeLessThan(500);
|
|
305
|
+
const h = await harness([record], key), c = await h.connect();
|
|
306
|
+
await tick(); expect(c.sent).toEqual(["opening"]);
|
|
307
|
+
await c.message("noise"); await c.message("previous"); await c.message("candidate");
|
|
308
|
+
await tick(40); expect(c.sent).toEqual(["opening"]);
|
|
309
|
+
await c.message("candidate");
|
|
310
|
+
await tick(5); expect(c.sent).toEqual(["opening"]);
|
|
311
|
+
await tick(45); expect(c.sent).toEqual(["opening", "answer"]);
|
|
312
|
+
await c.close(4001, "done"); expect(h.state.websocketErrors).toEqual([]);
|
|
313
|
+
expect(key.initial.outputs).toBe(0);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("candidate batches normalize chunking and output state feeds subsequent inputs", async () => {
|
|
317
|
+
const key: WebSocketKey<{ turn: number; position: number }> = {
|
|
318
|
+
initial: { turn: 0, position: 0 },
|
|
319
|
+
input(state, message) {
|
|
320
|
+
const keys = Array.from(String(message), () => ({ turn: state.turn, position: ++state.position }));
|
|
321
|
+
return { state, keys };
|
|
322
|
+
},
|
|
323
|
+
output(state, message) { return { state: message === "next" ? { turn: state.turn + 1, position: 0 } : state, anchorPreviousInput: true }; },
|
|
324
|
+
};
|
|
325
|
+
const hash = (turn: number, position: number) => websocketMatcher(() => ({ turn, position }))("x")!;
|
|
326
|
+
const h = await harness([{ request, inputMode: "anchors", inputs: [hash(0, 3), hash(1, 2)], inputOccurrences: [1, 1], messages: [
|
|
327
|
+
{ afterInput: 1, delayMs: 0, type: "text", data: "next" },
|
|
328
|
+
{ afterInput: 2, delayMs: 0, type: "text", data: "done" },
|
|
329
|
+
] }], key);
|
|
330
|
+
const c = await h.connect();
|
|
331
|
+
await c.message("a"); await c.message("bc"); await tick(); expect(c.sent).toEqual(["next"]);
|
|
332
|
+
await c.message("de"); await tick(); expect(c.sent).toEqual(["next", "done"]);
|
|
333
|
+
await c.close(); expect(h.state.websocketErrors).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("unmatched candidates cannot extend the required anchor deadline", async () => {
|
|
337
|
+
const key: WebSocketKey<null> = { initial: null,
|
|
338
|
+
input(state, message) { return { state, key: message }; },
|
|
339
|
+
output(state) { return { state, anchorPreviousInput: true }; },
|
|
340
|
+
};
|
|
341
|
+
const h = await harness([{ request, inputMode: "anchors", inputs: [websocketMatcher(() => "wanted")("x")!], inputOccurrences: [1], messages: [] }], key, 30);
|
|
342
|
+
const c = await h.connect();
|
|
343
|
+
for (let i = 0; i < 6; i++) { await c.message("unrelated"); await tick(10); }
|
|
344
|
+
expect(h.state.websocketErrors.join()).toContain("required input never arrived");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("anchor validation and hook failures are explicit", async () => {
|
|
348
|
+
const key: WebSocketKey<null> = { initial: null,
|
|
349
|
+
input(state, message) { return { state, key: message }; },
|
|
350
|
+
output(state) { return { state, anchorPreviousInput: true }; },
|
|
351
|
+
};
|
|
352
|
+
const h = await harness([], key);
|
|
353
|
+
const input = websocketMatcher(() => "x")("x")!;
|
|
354
|
+
for (const counts of [[], [0], [1.2], [2, 1]]) {
|
|
355
|
+
expect(() => h.helpers.replay(cassette([{ request, inputMode: "anchors", inputs: counts.length === 2 ? [input, input] : [input], inputOccurrences: counts, messages: [] }]))).toThrow("occurrence");
|
|
356
|
+
}
|
|
357
|
+
h.helpers.replay(cassette([{ request, inputs: [], messages: [] }]));
|
|
358
|
+
await expect(h.connect()).rejects.toThrow("re-record");
|
|
359
|
+
const asyncInput = websocketMatcher({ ...key, input: async () => ({ state: null, key: null }) } as any);
|
|
360
|
+
expect(() => asyncInput.candidates!("x")).toThrow("synchronous");
|
|
361
|
+
const asyncOutput = websocketMatcher({ ...key, output: async () => ({ state: null, anchorPreviousInput: true }) } as any);
|
|
362
|
+
expect(() => asyncOutput.output!("x")).toThrow("synchronous");
|
|
363
|
+
expect(() => websocketMatcher({ ...key, output: () => ({ state: null }) } as any).output!("x")).toThrow("anchorPreviousInput");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("output hook cannot mutate binary playback and can select an opening with no input", async () => {
|
|
367
|
+
const key: WebSocketKey<null> = { initial: null,
|
|
368
|
+
input(state) { return { state, key: null }; },
|
|
369
|
+
output(state, message) { if (message instanceof Uint8Array) message.fill(0); return { state, anchorPreviousInput: true }; },
|
|
370
|
+
};
|
|
371
|
+
const h = await harness([{ request, inputMode: "anchors", inputs: [], inputOccurrences: [], messages: [
|
|
372
|
+
{ afterInput: 0, delayMs: 0, type: "binary", data: "Af8=" },
|
|
373
|
+
] }], key);
|
|
374
|
+
const c = await h.connect(); await tick();
|
|
375
|
+
expect(c.sent).toEqual([new Uint8Array([1, 255])]);
|
|
376
|
+
await c.close(); expect(h.state.websocketErrors).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
export function inferredHooks() {
|
|
380
|
+
return replayFake({ name: "inferred", host: "voice.test", websocketKey: {
|
|
381
|
+
initial: { position: 0 },
|
|
382
|
+
input(state, message) {
|
|
383
|
+
const position: number = state.position;
|
|
384
|
+
// @ts-expect-error state is inferred from initial
|
|
385
|
+
const wrong: string = state.position;
|
|
386
|
+
return { state: { position: position + 1 }, key: message };
|
|
387
|
+
},
|
|
388
|
+
output(state) { return { state, anchorPreviousInput: state.position > 0 }; },
|
|
389
|
+
} });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
test("GPT-Live candidates normalize chunking and select fast speech without caller silence", async () => {
|
|
393
|
+
const { liveKey } = await import("../../../examples/phone-receptionist/spectest/live-key.js");
|
|
394
|
+
const audio = (pcm: Buffer) => JSON.stringify({ type: "session.input_audio.append", audio: pcm.toString("base64") });
|
|
395
|
+
const response = JSON.stringify({ type: "session.output_audio.delta", delta: Buffer.alloc(960, 16).toString("base64") });
|
|
396
|
+
const a = websocketMatcher(liveKey), b = websocketMatcher(liveKey);
|
|
397
|
+
expect(a.candidates!(audio(Buffer.alloc(1920)))).toEqual([]);
|
|
398
|
+
const speech = Buffer.alloc(960 * 3, 16);
|
|
399
|
+
const whole = a.candidates!(audio(speech));
|
|
400
|
+
const split = [speech.subarray(0, 300), speech.subarray(300, 1140), speech.subarray(1140)]
|
|
401
|
+
.flatMap(pcm => b.candidates!(audio(pcm)));
|
|
402
|
+
expect(whole).toEqual(split); expect(whole).toHaveLength(3);
|
|
403
|
+
expect(a.output!(response)).toBe(true); // No quiet samples at all.
|
|
404
|
+
expect(a.candidates!(audio(Buffer.alloc(960, 16)))).toEqual([]); // Same ongoing utterance.
|
|
405
|
+
expect(a.candidates!(audio(Buffer.alloc(960)))).toEqual([]);
|
|
406
|
+
expect(a.candidates!(audio(Buffer.alloc(960, 16)))).toHaveLength(1); // Next turn.
|
|
407
|
+
expect(a.output!(response)).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
test("a reached response anchor catches up older output without adding its stale clock delay", async () => {
|
|
412
|
+
const key: WebSocketKey<null> = { initial: null,
|
|
413
|
+
input(state, message) { return { state, key: message }; },
|
|
414
|
+
output(state) { return { state, anchorPreviousInput: true }; },
|
|
415
|
+
};
|
|
416
|
+
const messages = Array.from({length: 200}, (_, index) => ({
|
|
417
|
+
afterInput: 0, delayMs: 3000 + index, type: "text" as const, data: `quiet-${index}`,
|
|
418
|
+
}));
|
|
419
|
+
const h = await harness([{ request, inputMode: "anchors",
|
|
420
|
+
inputs: [websocketMatcher(() => "go")("x")!], inputOccurrences: [1], messages: [
|
|
421
|
+
...messages, {afterInput: 1, delayMs: 20, type: "text", data: "response"},
|
|
422
|
+
],
|
|
423
|
+
}], key);
|
|
424
|
+
const c = await h.connect();
|
|
425
|
+
await tick(); expect(c.sent).toEqual([]);
|
|
426
|
+
await c.message("go");
|
|
427
|
+
await tick(60);
|
|
428
|
+
expect(c.sent).toEqual([...messages.map(m => m.data), "response"]);
|
|
429
|
+
await c.close(); expect(h.state.websocketErrors).toEqual([]);
|
|
430
|
+
});
|
|
@@ -177,7 +177,10 @@ export interface ReplayFakeOptions<
|
|
|
177
177
|
* matching dimensions or fallback apply when a key is supplied. */
|
|
178
178
|
key?: ReplayKey;
|
|
179
179
|
/** Complete messages (string or Uint8Array), synchronously reduced per connection.
|
|
180
|
-
*
|
|
180
|
+
* Functions and { initial, reduce } emit required keys (null skips an input).
|
|
181
|
+
* { initial, input, output } emits candidates; output can select the previous
|
|
182
|
+
* candidate as a recorded timing anchor. Replay waits for those selected keys.
|
|
183
|
+
* Raw inputs are never stored; changing this
|
|
181
184
|
* matcher can require re-recording. Defaults to SHA-256(type + bytes). */
|
|
182
185
|
websocketKey?: WebSocketKey<W>;
|
|
183
186
|
/** Maximum wait for each required input key; null-producing messages do not
|
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { phoneScope, phoneRecordingChunk, releasePhoneRecording, type PhoneRecording } 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";
|
|
@@ -3481,7 +3482,7 @@ let interactiveSetupCompleted = false;
|
|
|
3481
3482
|
|
|
3482
3483
|
/**
|
|
3483
3484
|
* Run a project setup hook. Normal `setup` runs before the shared warm
|
|
3484
|
-
* snapshot; `
|
|
3485
|
+
* snapshot; `interactive.seed` runs only after it, in a fresh user start.
|
|
3485
3486
|
* Both use the same context and boot-log capture.
|
|
3486
3487
|
*
|
|
3487
3488
|
* Unlike test runs, this is NOT instrumented — no recorder, no event
|
|
@@ -3500,7 +3501,8 @@ async function runProjectSetup(hook: "setup" | "setupInteractive" = "setup"): Pr
|
|
|
3500
3501
|
}
|
|
3501
3502
|
|
|
3502
3503
|
async function runProjectSetupInner(hook: "setup" | "setupInteractive"): Promise<ProjectSetupResult> {
|
|
3503
|
-
const
|
|
3504
|
+
const project = requireLoaded().project;
|
|
3505
|
+
const setup = hook === "setup" ? project.setup : project.interactive?.seed;
|
|
3504
3506
|
if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
|
|
3505
3507
|
return { ran: false, durationMs: 0 };
|
|
3506
3508
|
}
|
|
@@ -3530,6 +3532,7 @@ async function runProjectSetupInner(hook: "setup" | "setupInteractive"): Promise
|
|
|
3530
3532
|
// ────────────────────────────────────────────────────────────────────────
|
|
3531
3533
|
|
|
3532
3534
|
interface RunResult {
|
|
3535
|
+
phoneRecordings: PhoneRecording[];
|
|
3533
3536
|
androidRecordings: AndroidRecording[];
|
|
3534
3537
|
status: "passed" | "failed";
|
|
3535
3538
|
durationMs: number;
|
|
@@ -5060,6 +5063,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5060
5063
|
const start = Date.now();
|
|
5061
5064
|
const nativeAndroid = androidScope(testCase.id, start);
|
|
5062
5065
|
let androidRecordings: AndroidRecording[] = [];
|
|
5066
|
+
let phoneRecordings: PhoneRecording[] = [];
|
|
5063
5067
|
const chunks: string[] = [];
|
|
5064
5068
|
const origStdout = process.stdout.write.bind(process.stdout);
|
|
5065
5069
|
const origStderr = process.stderr.write.bind(process.stderr);
|
|
@@ -5358,6 +5362,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5358
5362
|
// a real error rather than getting attributed to the test.
|
|
5359
5363
|
const base = await spectestContext();
|
|
5360
5364
|
|
|
5365
|
+
const phones = phoneScope(start);
|
|
5361
5366
|
const ctx: TestContext<unknown> = {
|
|
5362
5367
|
...base,
|
|
5363
5368
|
// exec/terminal/poll wrap their results at runtime (the recorder is
|
|
@@ -5371,6 +5376,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5371
5376
|
android: nativeAndroid.open,
|
|
5372
5377
|
desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
|
|
5373
5378
|
mcp: openMcp,
|
|
5379
|
+
phone: phones.open,
|
|
5374
5380
|
testName: testCase.name,
|
|
5375
5381
|
parent,
|
|
5376
5382
|
};
|
|
@@ -5414,6 +5420,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5414
5420
|
};
|
|
5415
5421
|
} finally {
|
|
5416
5422
|
if (timer) clearTimeout(timer);
|
|
5423
|
+
phoneRecordings = await phones.finish();
|
|
5424
|
+
const phoneErrors = phoneRecordings.flatMap(r => r.error ? [r.error] : []);
|
|
5425
|
+
if (phoneErrors.length && outcome.status === "passed") outcome = { status: "failed", error: { message: phoneErrors.join("\n") } };
|
|
5417
5426
|
nativeAndroid.deactivate();
|
|
5418
5427
|
RECORDING_EXEC = undefined;
|
|
5419
5428
|
INTERCEPTORS.endScope();
|
|
@@ -5496,6 +5505,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5496
5505
|
return {
|
|
5497
5506
|
status: outcome.status,
|
|
5498
5507
|
androidRecordings,
|
|
5508
|
+
phoneRecordings,
|
|
5499
5509
|
durationMs,
|
|
5500
5510
|
log: chunks.join(""),
|
|
5501
5511
|
events,
|
|
@@ -5914,6 +5924,7 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
5914
5924
|
// snippets don't require a /load round-trip first.
|
|
5915
5925
|
const base = await spectestContext();
|
|
5916
5926
|
|
|
5927
|
+
const phones = phoneScope(start, { record: false });
|
|
5917
5928
|
const ctx: TestContext<undefined> = {
|
|
5918
5929
|
...base,
|
|
5919
5930
|
// Bun does not propagate the import() caller's AsyncLocalStorage into
|
|
@@ -5931,6 +5942,7 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
5931
5942
|
android: nativeAndroid.open,
|
|
5932
5943
|
desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
|
|
5933
5944
|
mcp: openMcp,
|
|
5945
|
+
phone: phones.open,
|
|
5934
5946
|
testName: "eval",
|
|
5935
5947
|
parent: undefined,
|
|
5936
5948
|
};
|
|
@@ -5977,6 +5989,7 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
5977
5989
|
error: { message: explainEvalExportError(code, message), stack: e.stack },
|
|
5978
5990
|
};
|
|
5979
5991
|
} finally {
|
|
5992
|
+
await phones.finish();
|
|
5980
5993
|
fetchScope.active = false;
|
|
5981
5994
|
restoreFetch();
|
|
5982
5995
|
restoreConsole();
|
|
@@ -6161,7 +6174,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6161
6174
|
capabilities: ["prepared-images-v1"],
|
|
6162
6175
|
// Optional discovery: old SDKs omit this field and are never sent the
|
|
6163
6176
|
// new method. Check the loaded project, including after warm restore.
|
|
6164
|
-
setupInteractive: typeof loaded?.project.
|
|
6177
|
+
setupInteractive: typeof loaded?.project.interactive?.seed === "function",
|
|
6165
6178
|
replayEnvironmentMode: true,
|
|
6166
6179
|
}),
|
|
6167
6180
|
|
|
@@ -6407,6 +6420,8 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6407
6420
|
|
|
6408
6421
|
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
6409
6422
|
// strings, so they travel in params rather than a URL path.
|
|
6423
|
+
phoneRecordingChunk: async (params) => phoneRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
|
|
6424
|
+
releasePhoneRecording: async (params) => releasePhoneRecording(String(params.id)),
|
|
6410
6425
|
androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
|
|
6411
6426
|
releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
|
|
6412
6427
|
replayChunk: async (params) => {
|