@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 +41 -0
- package/dist/components/replay-websocket.d.ts +29 -3
- package/dist/components/replay-websocket.js +157 -25
- package/dist/components/replayFake.d.ts +4 -1
- package/dist/daemon.js +30 -0
- package/dist/harness/fake-functions.d.ts +18 -0
- package/dist/harness/fake-functions.js +82 -0
- package/dist/harness/function-parameters.d.ts +9 -0
- package/dist/harness/function-parameters.js +42 -0
- package/dist/harness/phone.py +427 -0
- package/dist/harness/phone_recording.py +95 -0
- package/dist/harness/protocol.d.ts +1 -1
- package/dist/harness/protocol.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/phone.d.ts +59 -0
- package/dist/phone.js +343 -0
- package/dist/recorder.d.ts +10 -0
- package/package.json +2 -1
- package/src/components/replay-websocket.ts +154 -24
- package/src/components/replayFake-handler.test.ts +5 -0
- package/src/components/replayFake-websocket.test.ts +166 -0
- package/src/components/replayFake.ts +4 -1
- package/src/daemon.ts +28 -0
- package/src/fake-functions.test.ts +62 -0
- package/src/harness/fake-functions.test.ts +77 -0
- package/src/harness/fake-functions.ts +78 -0
- package/src/harness/function-parameters.test.ts +45 -0
- package/src/harness/function-parameters.ts +43 -0
- package/src/harness/phone.py +427 -0
- package/src/harness/phone_recording.py +95 -0
- package/src/harness/protocol.ts +2 -0
- package/src/index.ts +8 -0
- package/src/phone.test.ts +474 -0
- package/src/phone.ts +330 -0
- package/src/recorder.ts +1 -0
|
@@ -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
|
-
*
|
|
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
|
+
}
|