@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.
@@ -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";
@@ -92,6 +93,7 @@ import {
92
93
  unionDockerignore,
93
94
  } from "./harness/build-context.js";
94
95
  import { startServices } from "./harness/service-graph.js";
96
+ import { fakeFunctions, fakeFunctionParameters, callFakeFunction } from "./harness/fake-functions.js";
95
97
  import { dispatchFake } from "./harness/fake-websocket.js";
96
98
  import {
97
99
  casesMetadata as catalogueCases,
@@ -3531,6 +3533,7 @@ async function runProjectSetupInner(hook: "setup" | "setupInteractive"): Promise
3531
3533
  // ────────────────────────────────────────────────────────────────────────
3532
3534
 
3533
3535
  interface RunResult {
3536
+ phoneRecordings: PhoneRecording[];
3534
3537
  androidRecordings: AndroidRecording[];
3535
3538
  status: "passed" | "failed";
3536
3539
  durationMs: number;
@@ -5061,6 +5064,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5061
5064
  const start = Date.now();
5062
5065
  const nativeAndroid = androidScope(testCase.id, start);
5063
5066
  let androidRecordings: AndroidRecording[] = [];
5067
+ let phoneRecordings: PhoneRecording[] = [];
5064
5068
  const chunks: string[] = [];
5065
5069
  const origStdout = process.stdout.write.bind(process.stdout);
5066
5070
  const origStderr = process.stderr.write.bind(process.stderr);
@@ -5359,6 +5363,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5359
5363
  // a real error rather than getting attributed to the test.
5360
5364
  const base = await spectestContext();
5361
5365
 
5366
+ const phones = phoneScope(start);
5362
5367
  const ctx: TestContext<unknown> = {
5363
5368
  ...base,
5364
5369
  // exec/terminal/poll wrap their results at runtime (the recorder is
@@ -5372,6 +5377,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5372
5377
  android: nativeAndroid.open,
5373
5378
  desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
5374
5379
  mcp: openMcp,
5380
+ phone: phones.open,
5375
5381
  testName: testCase.name,
5376
5382
  parent,
5377
5383
  };
@@ -5415,6 +5421,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5415
5421
  };
5416
5422
  } finally {
5417
5423
  if (timer) clearTimeout(timer);
5424
+ phoneRecordings = await phones.finish();
5425
+ const phoneErrors = phoneRecordings.flatMap(r => r.error ? [r.error] : []);
5426
+ if (phoneErrors.length && outcome.status === "passed") outcome = { status: "failed", error: { message: phoneErrors.join("\n") } };
5418
5427
  nativeAndroid.deactivate();
5419
5428
  RECORDING_EXEC = undefined;
5420
5429
  INTERCEPTORS.endScope();
@@ -5497,6 +5506,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5497
5506
  return {
5498
5507
  status: outcome.status,
5499
5508
  androidRecordings,
5509
+ phoneRecordings,
5500
5510
  durationMs,
5501
5511
  log: chunks.join(""),
5502
5512
  events,
@@ -5915,6 +5925,7 @@ async function evalCode(code: string): Promise<EvalResult> {
5915
5925
  // snippets don't require a /load round-trip first.
5916
5926
  const base = await spectestContext();
5917
5927
 
5928
+ const phones = phoneScope(start, { record: false });
5918
5929
  const ctx: TestContext<undefined> = {
5919
5930
  ...base,
5920
5931
  // Bun does not propagate the import() caller's AsyncLocalStorage into
@@ -5932,6 +5943,7 @@ async function evalCode(code: string): Promise<EvalResult> {
5932
5943
  android: nativeAndroid.open,
5933
5944
  desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
5934
5945
  mcp: openMcp,
5946
+ phone: phones.open,
5935
5947
  testName: "eval",
5936
5948
  parent: undefined,
5937
5949
  };
@@ -5978,6 +5990,7 @@ async function evalCode(code: string): Promise<EvalResult> {
5978
5990
  error: { message: explainEvalExportError(code, message), stack: e.stack },
5979
5991
  };
5980
5992
  } finally {
5993
+ await phones.finish();
5981
5994
  fetchScope.active = false;
5982
5995
  restoreFetch();
5983
5996
  restoreConsole();
@@ -6320,6 +6333,19 @@ export function harnessMethods(state: RouteState): MethodTable {
6320
6333
  () => runProjectSetup("setupInteractive"),
6321
6334
  ),
6322
6335
 
6336
+ fakeFunctions: async (params) => exclusive(state, "inFlightTest", "a test or eval is already running", async () => {
6337
+ const name = requireString(params, "fake");
6338
+ const fake = FAKES.get(name);
6339
+ if (!fake) throw notFound(`Unknown fake: ${name}`);
6340
+ await ensureFakeHelpers(name);
6341
+ if (params.member === undefined) return { functions: fakeFunctions(fake.helpers!), parameters: fakeFunctionParameters(fake.helpers!) };
6342
+ const member = requireString(params, "member");
6343
+ if (params.expressions !== undefined)
6344
+ return callFakeFunction(fake.helpers!, member, [], [], params.expressions as string[]);
6345
+ if (!Array.isArray(params.args)) throw badRequest("args must be a JSON array");
6346
+ return callFakeFunction(fake.helpers!, member, params.args, params.omitted_args as number[] | undefined);
6347
+ }),
6348
+
6323
6349
  eval: async (params) => {
6324
6350
  const code = requireString(params, "code");
6325
6351
  return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
@@ -6408,6 +6434,8 @@ export function harnessMethods(state: RouteState): MethodTable {
6408
6434
 
6409
6435
  // One chunk of a parked replay bundle. Case ids are arbitrary user
6410
6436
  // strings, so they travel in params rather than a URL path.
6437
+ phoneRecordingChunk: async (params) => phoneRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
6438
+ releasePhoneRecording: async (params) => releasePhoneRecording(String(params.id)),
6411
6439
  androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
6412
6440
  releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
6413
6441
  replayChunk: async (params) => {
@@ -0,0 +1,62 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ test("daemon dispatch discovers cached helpers, preserves annotations and excludes concurrent evals", async () => {
7
+ const root = await mkdtemp(join(tmpdir(), "spectest-fake-functions-"));
8
+ try {
9
+ await mkdir(join(root, "spectest"));
10
+ await writeFile(join(root, "spectest/index.ts"), `
11
+ import { defineEnvironment, defineFake, annotate } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
12
+ const env = defineEnvironment({ name: "helpers", services: {}, fakes: {
13
+ mail: defineFake({ name: "mail", hostnames: ["mail.test"], handler: () => new Response("ok"), helpers: () => {
14
+ let count = 0;
15
+ return {
16
+ receipt(amount) { count += amount; return annotate({ subject: "Receipt " + count }, "email"); },
17
+ async wait() { await globalThis.gate; return count; },
18
+ };
19
+ } }),
20
+ }});
21
+ export default env.project({});
22
+ `);
23
+ const script = `
24
+ import { strict as assert } from "node:assert";
25
+ import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
26
+ await METHODS.load({});
27
+ {
28
+ const discovery = await METHODS.fakeFunctions({fake:"mail"});
29
+ assert.deepEqual(discovery.functions, ["receipt", "wait"]);
30
+ assert.deepEqual(discovery.parameters.receipt, [{ name: "amount", has_default: false, rest: false, destructured: false }]);
31
+ assert.deepEqual(discovery.parameters.wait, []);
32
+ }
33
+ await assert.rejects(METHODS.fakeFunctions({fake:"missing"}), /Unknown fake/);
34
+ await assert.rejects(METHODS.fakeFunctions({fake:"mail", member:"receipt", args:{}}), /JSON array/);
35
+ for (const count of [1, 2]) {
36
+ const result = await METHODS.fakeFunctions({fake:"mail", member:"receipt", expressions:["2 - 1"]});
37
+ assert.equal(result.result.subject, "Receipt " + count);
38
+ assert.equal(result.annotation.kind, "email");
39
+ assert.equal(result.annotation.message.subject, result.result.subject);
40
+ }
41
+ let release;
42
+ globalThis.gate = new Promise(r => release = r);
43
+ const pending = METHODS.fakeFunctions({fake:"mail", member:"wait", expressions:[]});
44
+ await assert.rejects(METHODS.eval({code:"export default 1"}), /already running/);
45
+ await assert.rejects(METHODS.fakeFunctions({fake:"mail"}), /already running/);
46
+ release();
47
+ assert.equal((await pending).result, 2);
48
+ {
49
+ const discovery = await METHODS.fakeFunctions({fake:"mail"});
50
+ assert.deepEqual(discovery.functions, ["receipt", "wait"]);
51
+ assert.deepEqual(discovery.parameters.receipt, [{ name: "amount", has_default: false, rest: false, destructured: false }]);
52
+ assert.deepEqual(discovery.parameters.wait, []);
53
+ }
54
+ process.exit(0);
55
+ `;
56
+ const child = Bun.spawn([process.execPath, "--eval", script], {
57
+ env: { ...process.env, SPECTEST_APP_DIR: root }, stdout: "pipe", stderr: "pipe",
58
+ });
59
+ const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
60
+ expect(code, stdout + stderr).toBe(0);
61
+ } finally { await rm(root, { recursive: true, force: true }); }
62
+ }, 20000);
@@ -0,0 +1,77 @@
1
+ import { expect, test } from "bun:test";
2
+ import { annotate } from "../annotate.js";
3
+ import { fakeFunctions, callFakeFunction } from "./fake-functions.js";
4
+
5
+ test("discovery includes only own data functions and never runs getters", async () => {
6
+ const helpers = Object.create({ inherited() { throw new Error("no"); } });
7
+ Object.defineProperties(helpers, {
8
+ getter: { get() { throw new Error("no"); } },
9
+ value: { value: 42 },
10
+ own: { value() {} },
11
+ });
12
+ expect(fakeFunctions(helpers)).toEqual(["own"]);
13
+ for (const name of ["getter", "inherited", "constructor", "toString", "missing", "value"])
14
+ await expect(callFakeFunction(helpers, name, [])).rejects.toThrow("Unknown fake function");
15
+ });
16
+
17
+ test("calls preserve receiver, arguments, live state, and async return values", async () => {
18
+ const helpers = { count: 0, async add(value: { amount: number }) { this.count += value.amount; return this.count; } };
19
+ expect((await callFakeFunction(helpers, "add", [{ amount: 3 }])).result).toBe(3);
20
+ expect((await callFakeFunction(helpers, "add", [{ amount: 2 }])).result).toBe(5);
21
+ });
22
+
23
+ test("email and chat annotations survive separately from the raw value", async () => {
24
+ const value = { subject: "Receipt", html: "<p>Paid</p>" };
25
+ const helpers = {
26
+ email: () => annotate(value, "email"),
27
+ chat: async () => annotate({ original: 1 }, "chat", [{ side: "other", text: "Hello" }]),
28
+ empty() {},
29
+ fail() { throw new Error("declined"); },
30
+ async reject() { throw new Error("offline"); },
31
+ };
32
+ const email = await callFakeFunction(helpers, "email", []);
33
+ expect(email.result).toEqual(value);
34
+ expect(email.annotation).toEqual({ kind: "email", message: value });
35
+ const chat = await callFakeFunction(helpers, "chat", []);
36
+ expect(chat.result).toEqual({ original: 1 });
37
+ expect(chat.annotation?.kind).toBe("chat");
38
+ expect((await callFakeFunction(helpers, "empty", [])).result).toBeNull();
39
+ expect((await callFakeFunction(helpers, "fail", [])).error).toBe("declined");
40
+ expect((await callFakeFunction(helpers, "reject", [])).error).toBe("offline");
41
+ });
42
+
43
+ test("evaluates JavaScript expressions with defaults, rest and non-JSON values", async () => {
44
+ const helpers = {
45
+ values(label = "default", ...items: unknown[]) { return [label, ...items]; },
46
+ inspect(date: Date, callback: (n: number) => number) { return [date.getUTCFullYear(), callback(4)]; },
47
+ count(...items: unknown[]) { return items.length; },
48
+ };
49
+ const call = (member: string, expressions: string[]) => callFakeFunction(helpers, member, [], [], expressions);
50
+ expect((await call("values", ["'123'", "[1 + 2, { ok: true }, null]"])).result).toEqual(["123", 3, { ok: true }, null]);
51
+ expect((await call("values", ["", "[false]"])).result).toEqual(["default", false]);
52
+ expect((await call("values", ["undefined", ""])).result).toEqual(["default"]);
53
+ expect((await call("inspect", ["new Date('2026-01-01')", "n => n * 2"])).result).toEqual([2026, 8]);
54
+ expect((await call("count", [""])).result).toBe(0);
55
+ expect((await call("values", ["'x'", "42"])).error).toContain("items: Enter an array expression");
56
+ });
57
+
58
+ test("rejects invalid expressions before evaluating fields or invoking a helper", async () => {
59
+ let calls = 0;
60
+ const helpers = { send(to: unknown, body: unknown) { calls++; return [to, body]; } };
61
+ const globals = globalThis as typeof globalThis & { expressionCalls?: number };
62
+ globals.expressionCalls = 0;
63
+ try {
64
+ const call = (expressions: string[]) => callFakeFunction(helpers, "send", [], [], expressions);
65
+ expect((await call(["++globalThis.expressionCalls", "{broken"])).error).toContain("body:");
66
+ expect(globals.expressionCalls).toBe(0);
67
+ expect((await call(["1); globalThis.expressionCalls++; (2", "''"])).error).toContain("to:");
68
+ expect((await call(["unquotedMissingName", "''"])).error).toContain("to:");
69
+ expect(calls).toBe(0);
70
+ await expect(callFakeFunction(helpers, "missing", [], [], ["++globalThis.expressionCalls"])).rejects.toThrow("Unknown fake function");
71
+ expect(globals.expressionCalls).toBe(0);
72
+ expect((await call(["++globalThis.expressionCalls", "'hello' // trailing comment"])).result).toEqual([1, "hello"]);
73
+ expect(globals.expressionCalls).toBe(1);
74
+ expect(calls).toBe(1);
75
+ expect((await callFakeFunction({ max: Math.max }, "max", [], [], ["1 + 2", "10"])).result).toBe(10);
76
+ } finally { delete globals.expressionCalls; }
77
+ });
@@ -0,0 +1,78 @@
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
+
8
+ /** Only own data properties are callable. Discovery never invokes getters. */
9
+ export function fakeFunctions(helpers: Record<string, unknown>): string[] {
10
+ return Object.getOwnPropertyNames(helpers).filter((name) =>
11
+ typeof Object.getOwnPropertyDescriptor(helpers, name)?.value === "function",
12
+ ).sort();
13
+ }
14
+
15
+ export function fakeFunctionParameters(helpers: Record<string, unknown>) {
16
+ return Object.fromEntries(fakeFunctions(helpers).map(name => [name,
17
+ functionParameters(Object.getOwnPropertyDescriptor(helpers, name)!.value),
18
+ ]));
19
+ }
20
+
21
+ /** Use the raw helpers: the test wrappers intentionally discard annotations
22
+ * when there is no recorder. Preserve both the value and its presentation. */
23
+ export async function callFakeFunction(helpers: Record<string, unknown>, member: string, args: unknown[], omittedArgs: number[] = [], expressions?: string[]) {
24
+ const fn = Object.getOwnPropertyDescriptor(helpers, member)?.value;
25
+ if (typeof fn !== "function") throw new Error(`Unknown fake function: ${member}`);
26
+ if (!Array.isArray(omittedArgs) || omittedArgs.some(i => !Number.isInteger(i) || i < 0 || i >= args.length || args[i] !== null))
27
+ throw new Error("omitted_args must contain indices of null argument placeholders");
28
+ const omitted = new Set(omittedArgs);
29
+ const actualArgs = args.map((value, i) => omitted.has(i) ? undefined : value);
30
+ const start = Date.now();
31
+ try {
32
+ const value = await runUninstrumented(() => Reflect.apply(fn, helpers,
33
+ expressions === undefined ? actualArgs : evaluateArguments(fn, expressions)));
34
+ const annotated = readAnnotation(value);
35
+ return {
36
+ result: safeSerialize(deepUnwrap(annotated ? annotated.value : value)) ?? null,
37
+ annotation: annotated?.annotation ?? null,
38
+ error: null,
39
+ duration_ms: Date.now() - start,
40
+ };
41
+ } catch (error) {
42
+ return { result: null, annotation: null,
43
+ error: error instanceof Error ? error.message : String(error), duration_ms: Date.now() - start };
44
+ }
45
+ }
46
+
47
+ /** Expressions execute only in the environment, never in either dashboard.
48
+ * Compile every field before evaluating any, so a syntax error cannot partially
49
+ * execute the call. Keep actual JS values (undefined, Date, functions, etc.). */
50
+ function evaluateArguments(fn: Function, expressions: string[]): unknown[] {
51
+ if (!Array.isArray(expressions) || expressions.some(source => typeof source !== "string"))
52
+ throw new Error("expressions must be an array of JavaScript expressions");
53
+ const parameters = functionParameters(fn);
54
+ const label = (index: number) => parameters?.[index]?.name ?? `Argument ${index + 1}`;
55
+ const withLabel = <T>(index: number, action: () => T): T => {
56
+ try { return action(); }
57
+ catch (error) { throw new Error(`${label(index)}: ${error instanceof Error ? error.message : String(error)}`); }
58
+ };
59
+ const compiled = expressions.map((source, index) => withLabel(index, () => {
60
+ if (!source.trim()) 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) compiled.pop();
69
+ const args: unknown[] = [];
70
+ compiled.forEach((evaluate, index) => withLabel(index, () => {
71
+ const value = evaluate ? evaluate() : undefined;
72
+ if (parameters?.[index]?.rest) {
73
+ if (!Array.isArray(value)) throw new Error("Enter an array expression for the remaining arguments.");
74
+ args.push(...value);
75
+ } else args.push(value);
76
+ }));
77
+ return args;
78
+ }
@@ -0,0 +1,45 @@
1
+ import { expect, test } from "bun:test";
2
+ import { functionParameters } from "./function-parameters.js";
3
+ import { callFakeFunction, fakeFunctionParameters } from "./fake-functions.js";
4
+
5
+ const p = (name: string, has_default = false, rest = false, destructured = false) => ({ name, has_default, rest, destructured });
6
+
7
+ test("reads runtime names from methods, arrows, async and generator functions", () => {
8
+ const methods = { send(to: string, subject: string, text: string) {}, async load(id: number) {}, *items(limit = 3) {} };
9
+ expect(fakeFunctionParameters(methods)).toEqual({ send: [p("to"), p("subject"), p("text")], load: [p("id")], items: [p("limit", true)] });
10
+ expect(functionParameters(value => value)).toEqual([p("value")]);
11
+ expect(functionParameters(async value => value)).toEqual([p("value")]);
12
+ expect(functionParameters(function named(ångström: string) {})).toEqual([p("ångström")]);
13
+ expect(functionParameters(() => 42)).toEqual([]);
14
+ });
15
+
16
+ test("handles nested defaults, commas, comments and patterns without evaluating anything", () => {
17
+ let calls = 0;
18
+ const fn = (first = (++calls, /[,)]/), /* not,a,param */ second = (x = [1, 2]) => x,
19
+ third = `a,${[1, 2].join(",")}`, { to, subject: title } = { to: "a", subject: "b" }, [a, b] = [1, 2], ...rest: unknown[]) => {};
20
+ const result = functionParameters(fn)!;
21
+ expect(result.map(v => [v.has_default, v.rest, v.destructured])).toEqual([
22
+ [true, false, false], [true, false, false], [true, false, false], [true, false, true], [true, false, true], [false, true, false],
23
+ ]);
24
+ expect(result.slice(0, 3).map(v => v.name)).toEqual(["first", "second", "third"]);
25
+ expect(result[3].name).toContain("to");
26
+ expect(result[3].name).toContain("subject");
27
+ expect(result[4].name).toMatch(/^\[/);
28
+ expect(result[5].name).toBe("rest");
29
+ expect(calls).toBe(0);
30
+ });
31
+
32
+ test("opaque functions stay unknown and user-supplied toString is never called", () => {
33
+ const fn = (name: string) => name;
34
+ Object.defineProperty(fn, "toString", { get() { throw new Error("do not inspect"); } });
35
+ expect(functionParameters(fn)).toEqual([p("name")]);
36
+ expect(functionParameters(fn.bind(null))).toBeNull();
37
+ expect(functionParameters(Math.max)).toBeNull();
38
+ });
39
+
40
+ test("omitted slots trigger defaults while explicit null and later arguments are preserved", async () => {
41
+ const helpers = { send(to = "default", text: unknown, ...extra: unknown[]) { return [to, text, ...extra]; } };
42
+ expect((await callFakeFunction(helpers, "send", [null, "123", false, { ok: true }], [0])).result).toEqual(["default", "123", false, { ok: true }]);
43
+ expect((await callFakeFunction(helpers, "send", [null, "text"])).result).toEqual([null, "text"]);
44
+ for (const omitted of [[-1], [2], [0.5], [1]]) await expect(callFakeFunction(helpers, "send", [null, "text"], omitted)).rejects.toThrow("omitted_args");
45
+ });
@@ -0,0 +1,43 @@
1
+ import { parse, type Node, type Expression, type Pattern, type FunctionExpression, type ArrowFunctionExpression } from "acorn";
2
+
3
+ export interface FunctionParameter {
4
+ name: string;
5
+ has_default: boolean;
6
+ rest: boolean;
7
+ destructured: boolean;
8
+ }
9
+
10
+ /** Parse runtime JavaScript, never execute it (including default expressions).
11
+ * Null means opaque/unparseable, while [] means a known zero-argument helper. */
12
+ export function functionParameters(fn: Function): FunctionParameter[] | null {
13
+ const source = Function.prototype.toString.call(fn);
14
+ if (source.length > 256_000) return null;
15
+ // Function/arrow expressions and object methods have different grammar.
16
+ for (const method of [false, true]) {
17
+ const text = method ? `({${source}})` : `(${source})`;
18
+ try {
19
+ const program = parse(text, { ecmaVersion: "latest", sourceType: "module" });
20
+ const statement = program.body[0];
21
+ if (statement?.type !== "ExpressionStatement") continue;
22
+ let expression: Node = statement.expression;
23
+ if (method) {
24
+ if (expression.type !== "ObjectExpression") continue;
25
+ const properties = (expression as Extract<Expression, { type: "ObjectExpression" }>).properties;
26
+ if (properties.length !== 1 || properties[0]?.type !== "Property") continue;
27
+ expression = properties[0].value;
28
+ }
29
+ if (expression.type !== "FunctionExpression" && expression.type !== "ArrowFunctionExpression") continue;
30
+ return (expression as FunctionExpression | ArrowFunctionExpression).params.map((parameter) => {
31
+ const rest = parameter.type === "RestElement";
32
+ let binding: Pattern = rest ? parameter.argument : parameter;
33
+ const has_default = binding.type === "AssignmentPattern";
34
+ if (binding.type === "AssignmentPattern") binding = binding.left;
35
+ return {
36
+ name: binding.type === "Identifier" ? binding.name : text.slice(binding.start, binding.end),
37
+ has_default, rest, destructured: binding.type !== "Identifier",
38
+ };
39
+ });
40
+ } catch { /* Native/bound functions and unsupported syntax stay opaque. */ }
41
+ }
42
+ return null;
43
+ }