@specific.dev/spectest 0.112.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/dist/daemon.js +16 -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/protocol.d.ts +1 -1
- package/dist/harness/protocol.js +1 -0
- package/package.json +2 -1
- package/src/daemon.ts +14 -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/protocol.ts +2 -0
package/dist/daemon.js
CHANGED
|
@@ -43,6 +43,7 @@ import { isDesktopApp } from "./desktop.js";
|
|
|
43
43
|
// each easy to restate subtly differently.
|
|
44
44
|
import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
|
|
45
45
|
import { startServices } from "./harness/service-graph.js";
|
|
46
|
+
import { fakeFunctions, fakeFunctionParameters, callFakeFunction } from "./harness/fake-functions.js";
|
|
46
47
|
import { dispatchFake } from "./harness/fake-websocket.js";
|
|
47
48
|
import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
|
|
48
49
|
import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
@@ -5286,6 +5287,21 @@ export function harnessMethods(state) {
|
|
|
5286
5287
|
}),
|
|
5287
5288
|
projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
|
|
5288
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
|
+
}),
|
|
5289
5305
|
eval: async (params) => {
|
|
5290
5306
|
const code = requireString(params, "code");
|
|
5291
5307
|
return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
|
|
@@ -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
|
+
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
/** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
|
|
24
24
|
export declare const PROTOCOL_VERSION = 1;
|
|
25
25
|
/** Methods either side can send. */
|
|
26
|
-
export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "interactiveSetup" | "run" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
|
|
26
|
+
export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "interactiveSetup" | "run" | "fakeFunctions" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
|
|
27
27
|
/** Methods this build knows how to dispatch. A method outside this set still
|
|
28
28
|
* *parses* — it is answered with an error, never dropped. */
|
|
29
29
|
export declare const KNOWN_METHODS: ReadonlySet<string>;
|
package/dist/harness/protocol.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specific.dev/spectest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.113.0",
|
|
4
4
|
"description": "Spectest SDK for defining test environments in TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@kubernetes/client-node": "^1.0.0",
|
|
57
57
|
"@xterm/headless": "^5.5.0",
|
|
58
|
+
"acorn": "8.18.0",
|
|
58
59
|
"dns-packet": "^5.6.1",
|
|
59
60
|
"playwright-core": "^1.61.1",
|
|
60
61
|
"ws": "^8.20.1"
|
package/src/daemon.ts
CHANGED
|
@@ -93,6 +93,7 @@ import {
|
|
|
93
93
|
unionDockerignore,
|
|
94
94
|
} from "./harness/build-context.js";
|
|
95
95
|
import { startServices } from "./harness/service-graph.js";
|
|
96
|
+
import { fakeFunctions, fakeFunctionParameters, callFakeFunction } from "./harness/fake-functions.js";
|
|
96
97
|
import { dispatchFake } from "./harness/fake-websocket.js";
|
|
97
98
|
import {
|
|
98
99
|
casesMetadata as catalogueCases,
|
|
@@ -6332,6 +6333,19 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6332
6333
|
() => runProjectSetup("setupInteractive"),
|
|
6333
6334
|
),
|
|
6334
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
|
+
|
|
6335
6349
|
eval: async (params) => {
|
|
6336
6350
|
const code = requireString(params, "code");
|
|
6337
6351
|
return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
|
|
@@ -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
|
+
}
|
package/src/harness/protocol.ts
CHANGED
|
@@ -34,6 +34,7 @@ export type Method =
|
|
|
34
34
|
| "bootstrap"
|
|
35
35
|
| "interactiveSetup"
|
|
36
36
|
| "run"
|
|
37
|
+
| "fakeFunctions"
|
|
37
38
|
| "eval"
|
|
38
39
|
| "setRecordSecrets"
|
|
39
40
|
| "teardown"
|
|
@@ -54,6 +55,7 @@ export const KNOWN_METHODS: ReadonlySet<string> = new Set<Method>([
|
|
|
54
55
|
"bootstrap",
|
|
55
56
|
"interactiveSetup",
|
|
56
57
|
"run",
|
|
58
|
+
"fakeFunctions",
|
|
57
59
|
"eval",
|
|
58
60
|
"setRecordSecrets",
|
|
59
61
|
"teardown",
|