@specific.dev/spectest 0.111.0 → 0.113.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";
@@ -42,6 +43,7 @@ import { isDesktopApp } from "./desktop.js";
42
43
  // each easy to restate subtly differently.
43
44
  import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
44
45
  import { startServices } from "./harness/service-graph.js";
46
+ import { fakeFunctions, fakeFunctionParameters, callFakeFunction } from "./harness/fake-functions.js";
45
47
  import { dispatchFake } from "./harness/fake-websocket.js";
46
48
  import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
47
49
  import { summarizeBuildKit } from "./harness/buildkit-progress.js";
@@ -4184,6 +4186,7 @@ async function runOne(testCase) {
4184
4186
  const start = Date.now();
4185
4187
  const nativeAndroid = androidScope(testCase.id, start);
4186
4188
  let androidRecordings = [];
4189
+ let phoneRecordings = [];
4187
4190
  const chunks = [];
4188
4191
  const origStdout = process.stdout.write.bind(process.stdout);
4189
4192
  const origStderr = process.stderr.write.bind(process.stderr);
@@ -4439,6 +4442,7 @@ async function runOne(testCase) {
4439
4442
  // Built before installing the timeout so a slow client factory surfaces as
4440
4443
  // a real error rather than getting attributed to the test.
4441
4444
  const base = await spectestContext();
4445
+ const phones = phoneScope(start);
4442
4446
  const ctx = {
4443
4447
  ...base,
4444
4448
  // exec/terminal/poll wrap their results at runtime (the recorder is
@@ -4452,6 +4456,7 @@ async function runOne(testCase) {
4452
4456
  android: nativeAndroid.open,
4453
4457
  desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
4454
4458
  mcp: openMcp,
4459
+ phone: phones.open,
4455
4460
  testName: testCase.name,
4456
4461
  parent,
4457
4462
  };
@@ -4492,6 +4497,10 @@ async function runOne(testCase) {
4492
4497
  finally {
4493
4498
  if (timer)
4494
4499
  clearTimeout(timer);
4500
+ phoneRecordings = await phones.finish();
4501
+ const phoneErrors = phoneRecordings.flatMap(r => r.error ? [r.error] : []);
4502
+ if (phoneErrors.length && outcome.status === "passed")
4503
+ outcome = { status: "failed", error: { message: phoneErrors.join("\n") } };
4495
4504
  nativeAndroid.deactivate();
4496
4505
  RECORDING_EXEC = undefined;
4497
4506
  INTERCEPTORS.endScope();
@@ -4580,6 +4589,7 @@ async function runOne(testCase) {
4580
4589
  return {
4581
4590
  status: outcome.status,
4582
4591
  androidRecordings,
4592
+ phoneRecordings,
4583
4593
  durationMs,
4584
4594
  log: chunks.join(""),
4585
4595
  events,
@@ -4928,6 +4938,7 @@ async function evalCode(code) {
4928
4938
  // loaded `spectestContext()` yields empty maps, so quick `await fetch(...)`
4929
4939
  // snippets don't require a /load round-trip first.
4930
4940
  const base = await spectestContext();
4941
+ const phones = phoneScope(start, { record: false });
4931
4942
  const ctx = {
4932
4943
  ...base,
4933
4944
  // Bun does not propagate the import() caller's AsyncLocalStorage into
@@ -4945,6 +4956,7 @@ async function evalCode(code) {
4945
4956
  android: nativeAndroid.open,
4946
4957
  desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
4947
4958
  mcp: openMcp,
4959
+ phone: phones.open,
4948
4960
  testName: "eval",
4949
4961
  parent: undefined,
4950
4962
  };
@@ -4985,6 +4997,7 @@ async function evalCode(code) {
4985
4997
  };
4986
4998
  }
4987
4999
  finally {
5000
+ await phones.finish();
4988
5001
  fetchScope.active = false;
4989
5002
  restoreFetch();
4990
5003
  restoreConsole();
@@ -5274,6 +5287,21 @@ export function harnessMethods(state) {
5274
5287
  }),
5275
5288
  projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
5276
5289
  interactiveSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup("setupInteractive")),
5290
+ fakeFunctions: async (params) => exclusive(state, "inFlightTest", "a test or eval is already running", async () => {
5291
+ const name = requireString(params, "fake");
5292
+ const fake = FAKES.get(name);
5293
+ if (!fake)
5294
+ throw notFound(`Unknown fake: ${name}`);
5295
+ await ensureFakeHelpers(name);
5296
+ if (params.member === undefined)
5297
+ return { functions: fakeFunctions(fake.helpers), parameters: fakeFunctionParameters(fake.helpers) };
5298
+ const member = requireString(params, "member");
5299
+ if (params.expressions !== undefined)
5300
+ return callFakeFunction(fake.helpers, member, [], [], params.expressions);
5301
+ if (!Array.isArray(params.args))
5302
+ throw badRequest("args must be a JSON array");
5303
+ return callFakeFunction(fake.helpers, member, params.args, params.omitted_args);
5304
+ }),
5277
5305
  eval: async (params) => {
5278
5306
  const code = requireString(params, "code");
5279
5307
  return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
@@ -5362,6 +5390,8 @@ export function harnessMethods(state) {
5362
5390
  },
5363
5391
  // One chunk of a parked replay bundle. Case ids are arbitrary user
5364
5392
  // strings, so they travel in params rather than a URL path.
5393
+ phoneRecordingChunk: async (params) => phoneRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
5394
+ releasePhoneRecording: async (params) => releasePhoneRecording(String(params.id)),
5365
5395
  androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
5366
5396
  releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
5367
5397
  replayChunk: async (params) => {
@@ -0,0 +1,18 @@
1
+ /** Only own data properties are callable. Discovery never invokes getters. */
2
+ export declare function fakeFunctions(helpers: Record<string, unknown>): string[];
3
+ export declare function fakeFunctionParameters(helpers: Record<string, unknown>): {
4
+ [k: string]: import("./function-parameters.js").FunctionParameter[] | null;
5
+ };
6
+ /** Use the raw helpers: the test wrappers intentionally discard annotations
7
+ * when there is no recorder. Preserve both the value and its presentation. */
8
+ export declare function callFakeFunction(helpers: Record<string, unknown>, member: string, args: unknown[], omittedArgs?: number[], expressions?: string[]): Promise<{
9
+ result: {} | null;
10
+ annotation: import("../annotate.js").RenderAnnotation | null;
11
+ error: null;
12
+ duration_ms: number;
13
+ } | {
14
+ result: null;
15
+ annotation: null;
16
+ error: string;
17
+ duration_ms: number;
18
+ }>;
@@ -0,0 +1,82 @@
1
+ import { parse } from "acorn";
2
+ import { functionParameters } from "./function-parameters.js";
3
+ import { readAnnotation } from "../annotate.js";
4
+ import { deepUnwrap } from "../inspect.js";
5
+ import { safeSerialize } from "../recorder.js";
6
+ import { runUninstrumented } from "./instrumentation-scope.js";
7
+ /** Only own data properties are callable. Discovery never invokes getters. */
8
+ export function fakeFunctions(helpers) {
9
+ return Object.getOwnPropertyNames(helpers).filter((name) => typeof Object.getOwnPropertyDescriptor(helpers, name)?.value === "function").sort();
10
+ }
11
+ export function fakeFunctionParameters(helpers) {
12
+ return Object.fromEntries(fakeFunctions(helpers).map(name => [name,
13
+ functionParameters(Object.getOwnPropertyDescriptor(helpers, name).value),
14
+ ]));
15
+ }
16
+ /** Use the raw helpers: the test wrappers intentionally discard annotations
17
+ * when there is no recorder. Preserve both the value and its presentation. */
18
+ export async function callFakeFunction(helpers, member, args, omittedArgs = [], expressions) {
19
+ const fn = Object.getOwnPropertyDescriptor(helpers, member)?.value;
20
+ if (typeof fn !== "function")
21
+ throw new Error(`Unknown fake function: ${member}`);
22
+ if (!Array.isArray(omittedArgs) || omittedArgs.some(i => !Number.isInteger(i) || i < 0 || i >= args.length || args[i] !== null))
23
+ throw new Error("omitted_args must contain indices of null argument placeholders");
24
+ const omitted = new Set(omittedArgs);
25
+ const actualArgs = args.map((value, i) => omitted.has(i) ? undefined : value);
26
+ const start = Date.now();
27
+ try {
28
+ const value = await runUninstrumented(() => Reflect.apply(fn, helpers, expressions === undefined ? actualArgs : evaluateArguments(fn, expressions)));
29
+ const annotated = readAnnotation(value);
30
+ return {
31
+ result: safeSerialize(deepUnwrap(annotated ? annotated.value : value)) ?? null,
32
+ annotation: annotated?.annotation ?? null,
33
+ error: null,
34
+ duration_ms: Date.now() - start,
35
+ };
36
+ }
37
+ catch (error) {
38
+ return { result: null, annotation: null,
39
+ error: error instanceof Error ? error.message : String(error), duration_ms: Date.now() - start };
40
+ }
41
+ }
42
+ /** Expressions execute only in the environment, never in either dashboard.
43
+ * Compile every field before evaluating any, so a syntax error cannot partially
44
+ * execute the call. Keep actual JS values (undefined, Date, functions, etc.). */
45
+ function evaluateArguments(fn, expressions) {
46
+ if (!Array.isArray(expressions) || expressions.some(source => typeof source !== "string"))
47
+ throw new Error("expressions must be an array of JavaScript expressions");
48
+ const parameters = functionParameters(fn);
49
+ const label = (index) => parameters?.[index]?.name ?? `Argument ${index + 1}`;
50
+ const withLabel = (index, action) => {
51
+ try {
52
+ return action();
53
+ }
54
+ catch (error) {
55
+ throw new Error(`${label(index)}: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ };
58
+ const compiled = expressions.map((source, index) => withLabel(index, () => {
59
+ if (!source.trim())
60
+ return null;
61
+ const expression = `(${source}\n)`;
62
+ const program = parse(expression, { ecmaVersion: "latest" });
63
+ if (program.body.length !== 1 || program.body[0].type !== "ExpressionStatement")
64
+ throw new Error("Enter a single JavaScript expression.");
65
+ return new Function(`"use strict"; return ${expression};`);
66
+ }));
67
+ // Preserve interior omissions but avoid inventing trailing arguments.
68
+ while (compiled.length && compiled.at(-1) === null)
69
+ compiled.pop();
70
+ const args = [];
71
+ compiled.forEach((evaluate, index) => withLabel(index, () => {
72
+ const value = evaluate ? evaluate() : undefined;
73
+ if (parameters?.[index]?.rest) {
74
+ if (!Array.isArray(value))
75
+ throw new Error("Enter an array expression for the remaining arguments.");
76
+ args.push(...value);
77
+ }
78
+ else
79
+ args.push(value);
80
+ }));
81
+ return args;
82
+ }
@@ -0,0 +1,9 @@
1
+ export interface FunctionParameter {
2
+ name: string;
3
+ has_default: boolean;
4
+ rest: boolean;
5
+ destructured: boolean;
6
+ }
7
+ /** Parse runtime JavaScript, never execute it (including default expressions).
8
+ * Null means opaque/unparseable, while [] means a known zero-argument helper. */
9
+ export declare function functionParameters(fn: Function): FunctionParameter[] | null;
@@ -0,0 +1,42 @@
1
+ import { parse } from "acorn";
2
+ /** Parse runtime JavaScript, never execute it (including default expressions).
3
+ * Null means opaque/unparseable, while [] means a known zero-argument helper. */
4
+ export function functionParameters(fn) {
5
+ const source = Function.prototype.toString.call(fn);
6
+ if (source.length > 256_000)
7
+ return null;
8
+ // Function/arrow expressions and object methods have different grammar.
9
+ for (const method of [false, true]) {
10
+ const text = method ? `({${source}})` : `(${source})`;
11
+ try {
12
+ const program = parse(text, { ecmaVersion: "latest", sourceType: "module" });
13
+ const statement = program.body[0];
14
+ if (statement?.type !== "ExpressionStatement")
15
+ continue;
16
+ let expression = statement.expression;
17
+ if (method) {
18
+ if (expression.type !== "ObjectExpression")
19
+ continue;
20
+ const properties = expression.properties;
21
+ if (properties.length !== 1 || properties[0]?.type !== "Property")
22
+ continue;
23
+ expression = properties[0].value;
24
+ }
25
+ if (expression.type !== "FunctionExpression" && expression.type !== "ArrowFunctionExpression")
26
+ continue;
27
+ return expression.params.map((parameter) => {
28
+ const rest = parameter.type === "RestElement";
29
+ let binding = rest ? parameter.argument : parameter;
30
+ const has_default = binding.type === "AssignmentPattern";
31
+ if (binding.type === "AssignmentPattern")
32
+ binding = binding.left;
33
+ return {
34
+ name: binding.type === "Identifier" ? binding.name : text.slice(binding.start, binding.end),
35
+ has_default, rest, destructured: binding.type !== "Identifier",
36
+ };
37
+ });
38
+ }
39
+ catch { /* Native/bound functions and unsupported syntax stay opaque. */ }
40
+ }
41
+ return null;
42
+ }