@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.
@@ -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";
@@ -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; `setupInteractive` runs only after it, in a fresh user start.
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 setup = requireLoaded().project[hook];
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.setupInteractive === "function",
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) => {