@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
|
@@ -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";
|
|
@@ -3531,6 +3532,7 @@ async function runProjectSetupInner(hook: "setup" | "setupInteractive"): Promise
|
|
|
3531
3532
|
// ────────────────────────────────────────────────────────────────────────
|
|
3532
3533
|
|
|
3533
3534
|
interface RunResult {
|
|
3535
|
+
phoneRecordings: PhoneRecording[];
|
|
3534
3536
|
androidRecordings: AndroidRecording[];
|
|
3535
3537
|
status: "passed" | "failed";
|
|
3536
3538
|
durationMs: number;
|
|
@@ -5061,6 +5063,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5061
5063
|
const start = Date.now();
|
|
5062
5064
|
const nativeAndroid = androidScope(testCase.id, start);
|
|
5063
5065
|
let androidRecordings: AndroidRecording[] = [];
|
|
5066
|
+
let phoneRecordings: PhoneRecording[] = [];
|
|
5064
5067
|
const chunks: string[] = [];
|
|
5065
5068
|
const origStdout = process.stdout.write.bind(process.stdout);
|
|
5066
5069
|
const origStderr = process.stderr.write.bind(process.stderr);
|
|
@@ -5359,6 +5362,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5359
5362
|
// a real error rather than getting attributed to the test.
|
|
5360
5363
|
const base = await spectestContext();
|
|
5361
5364
|
|
|
5365
|
+
const phones = phoneScope(start);
|
|
5362
5366
|
const ctx: TestContext<unknown> = {
|
|
5363
5367
|
...base,
|
|
5364
5368
|
// exec/terminal/poll wrap their results at runtime (the recorder is
|
|
@@ -5372,6 +5376,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5372
5376
|
android: nativeAndroid.open,
|
|
5373
5377
|
desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
|
|
5374
5378
|
mcp: openMcp,
|
|
5379
|
+
phone: phones.open,
|
|
5375
5380
|
testName: testCase.name,
|
|
5376
5381
|
parent,
|
|
5377
5382
|
};
|
|
@@ -5415,6 +5420,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5415
5420
|
};
|
|
5416
5421
|
} finally {
|
|
5417
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") } };
|
|
5418
5426
|
nativeAndroid.deactivate();
|
|
5419
5427
|
RECORDING_EXEC = undefined;
|
|
5420
5428
|
INTERCEPTORS.endScope();
|
|
@@ -5497,6 +5505,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5497
5505
|
return {
|
|
5498
5506
|
status: outcome.status,
|
|
5499
5507
|
androidRecordings,
|
|
5508
|
+
phoneRecordings,
|
|
5500
5509
|
durationMs,
|
|
5501
5510
|
log: chunks.join(""),
|
|
5502
5511
|
events,
|
|
@@ -5915,6 +5924,7 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
5915
5924
|
// snippets don't require a /load round-trip first.
|
|
5916
5925
|
const base = await spectestContext();
|
|
5917
5926
|
|
|
5927
|
+
const phones = phoneScope(start, { record: false });
|
|
5918
5928
|
const ctx: TestContext<undefined> = {
|
|
5919
5929
|
...base,
|
|
5920
5930
|
// Bun does not propagate the import() caller's AsyncLocalStorage into
|
|
@@ -5932,6 +5942,7 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
5932
5942
|
android: nativeAndroid.open,
|
|
5933
5943
|
desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
|
|
5934
5944
|
mcp: openMcp,
|
|
5945
|
+
phone: phones.open,
|
|
5935
5946
|
testName: "eval",
|
|
5936
5947
|
parent: undefined,
|
|
5937
5948
|
};
|
|
@@ -5978,6 +5989,7 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
5978
5989
|
error: { message: explainEvalExportError(code, message), stack: e.stack },
|
|
5979
5990
|
};
|
|
5980
5991
|
} finally {
|
|
5992
|
+
await phones.finish();
|
|
5981
5993
|
fetchScope.active = false;
|
|
5982
5994
|
restoreFetch();
|
|
5983
5995
|
restoreConsole();
|
|
@@ -6408,6 +6420,8 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6408
6420
|
|
|
6409
6421
|
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
6410
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)),
|
|
6411
6425
|
androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
|
|
6412
6426
|
releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
|
|
6413
6427
|
replayChunk: async (params) => {
|