@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.
@@ -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
- * Return null to emit no input. Raw inputs are never stored; changing this
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) => {