@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 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>): (message: ReplayWebSocketMessage) => string | null;
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: (message: ReplayWebSocketMessage) => string | null;
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: (message: ReplayWebSocketMessage) => string | null;
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) || typeof key.reduce !== "function") {
15
- throw new Error("websocketKey must be a synchronous function or { initial, reduce }");
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 = record.messages[output] ?? (record.close?.initiator === "server" ? record.close : undefined);
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
- if ("data" in event) {
143
- socket.send(event.type === "text" ? event.data : new Uint8Array(Buffer.from(event.data, "base64")));
144
- output++;
145
- schedule();
146
- }
147
- else {
148
- sentClose = true;
149
- closeSocket(socket, event.code, event.reason);
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(delay, 2_147_483_647));
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
- const actual = args.match(message);
178
- if (actual === null)
179
- return;
180
- const expected = record.inputs[position];
181
- if (expected !== actual)
182
- throw new Error(`expected ${expected ?? "<exhausted>"}, actual ${actual}`);
183
- barriers[++position] = performance.now();
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 signature = args.match(typeof message === "string" ? message : Uint8Array.from(message));
362
- if (signature !== null) {
363
- record.inputs.push(signature);
364
- anchor = performance.now();
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
- * Return null to emit no input. Raw inputs are never stored; changing this
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";
@@ -2932,7 +2933,7 @@ async function bootstrapInner(prepared, identity) {
2932
2933
  let interactiveSetupCompleted = false;
2933
2934
  /**
2934
2935
  * Run a project setup hook. Normal `setup` runs before the shared warm
2935
- * snapshot; `setupInteractive` runs only after it, in a fresh user start.
2936
+ * snapshot; `interactive.seed` runs only after it, in a fresh user start.
2936
2937
  * Both use the same context and boot-log capture.
2937
2938
  *
2938
2939
  * Unlike test runs, this is NOT instrumented — no recorder, no event
@@ -2951,7 +2952,8 @@ async function runProjectSetup(hook = "setup") {
2951
2952
  }
2952
2953
  }
2953
2954
  async function runProjectSetupInner(hook) {
2954
- const setup = requireLoaded().project[hook];
2955
+ const project = requireLoaded().project;
2956
+ const setup = hook === "setup" ? project.setup : project.interactive?.seed;
2955
2957
  if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
2956
2958
  return { ran: false, durationMs: 0 };
2957
2959
  }
@@ -4183,6 +4185,7 @@ async function runOne(testCase) {
4183
4185
  const start = Date.now();
4184
4186
  const nativeAndroid = androidScope(testCase.id, start);
4185
4187
  let androidRecordings = [];
4188
+ let phoneRecordings = [];
4186
4189
  const chunks = [];
4187
4190
  const origStdout = process.stdout.write.bind(process.stdout);
4188
4191
  const origStderr = process.stderr.write.bind(process.stderr);
@@ -4438,6 +4441,7 @@ async function runOne(testCase) {
4438
4441
  // Built before installing the timeout so a slow client factory surfaces as
4439
4442
  // a real error rather than getting attributed to the test.
4440
4443
  const base = await spectestContext();
4444
+ const phones = phoneScope(start);
4441
4445
  const ctx = {
4442
4446
  ...base,
4443
4447
  // exec/terminal/poll wrap their results at runtime (the recorder is
@@ -4451,6 +4455,7 @@ async function runOne(testCase) {
4451
4455
  android: nativeAndroid.open,
4452
4456
  desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
4453
4457
  mcp: openMcp,
4458
+ phone: phones.open,
4454
4459
  testName: testCase.name,
4455
4460
  parent,
4456
4461
  };
@@ -4491,6 +4496,10 @@ async function runOne(testCase) {
4491
4496
  finally {
4492
4497
  if (timer)
4493
4498
  clearTimeout(timer);
4499
+ phoneRecordings = await phones.finish();
4500
+ const phoneErrors = phoneRecordings.flatMap(r => r.error ? [r.error] : []);
4501
+ if (phoneErrors.length && outcome.status === "passed")
4502
+ outcome = { status: "failed", error: { message: phoneErrors.join("\n") } };
4494
4503
  nativeAndroid.deactivate();
4495
4504
  RECORDING_EXEC = undefined;
4496
4505
  INTERCEPTORS.endScope();
@@ -4579,6 +4588,7 @@ async function runOne(testCase) {
4579
4588
  return {
4580
4589
  status: outcome.status,
4581
4590
  androidRecordings,
4591
+ phoneRecordings,
4582
4592
  durationMs,
4583
4593
  log: chunks.join(""),
4584
4594
  events,
@@ -4927,6 +4937,7 @@ async function evalCode(code) {
4927
4937
  // loaded `spectestContext()` yields empty maps, so quick `await fetch(...)`
4928
4938
  // snippets don't require a /load round-trip first.
4929
4939
  const base = await spectestContext();
4940
+ const phones = phoneScope(start, { record: false });
4930
4941
  const ctx = {
4931
4942
  ...base,
4932
4943
  // Bun does not propagate the import() caller's AsyncLocalStorage into
@@ -4944,6 +4955,7 @@ async function evalCode(code) {
4944
4955
  android: nativeAndroid.open,
4945
4956
  desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
4946
4957
  mcp: openMcp,
4958
+ phone: phones.open,
4947
4959
  testName: "eval",
4948
4960
  parent: undefined,
4949
4961
  };
@@ -4984,6 +4996,7 @@ async function evalCode(code) {
4984
4996
  };
4985
4997
  }
4986
4998
  finally {
4999
+ await phones.finish();
4987
5000
  fetchScope.active = false;
4988
5001
  restoreFetch();
4989
5002
  restoreConsole();
@@ -5143,7 +5156,7 @@ export function harnessMethods(state) {
5143
5156
  capabilities: ["prepared-images-v1"],
5144
5157
  // Optional discovery: old SDKs omit this field and are never sent the
5145
5158
  // new method. Check the loaded project, including after warm restore.
5146
- setupInteractive: typeof loaded?.project.setupInteractive === "function",
5159
+ setupInteractive: typeof loaded?.project.interactive?.seed === "function",
5147
5160
  replayEnvironmentMode: true,
5148
5161
  }),
5149
5162
  // Live bootstrap progress, polled by the control plane during
@@ -5361,6 +5374,8 @@ export function harnessMethods(state) {
5361
5374
  },
5362
5375
  // One chunk of a parked replay bundle. Case ids are arbitrary user
5363
5376
  // strings, so they travel in params rather than a URL path.
5377
+ phoneRecordingChunk: async (params) => phoneRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
5378
+ releasePhoneRecording: async (params) => releasePhoneRecording(String(params.id)),
5364
5379
  androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
5365
5380
  releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
5366
5381
  replayChunk: async (params) => {