Initial Commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export type { RunnerInput, RunnerOutput } from "./runner.js";
|
||||
export { runRunner } from "./runner.js";
|
||||
export type {
|
||||
ProcessSpawner,
|
||||
SpawnEngineOptions,
|
||||
} from "./spawn-engine.js";
|
||||
export { SpawnEngine, sanitiseEnv } from "./spawn-engine.js";
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Child-process executable for the spawn-engine. Reads stdin, drives
|
||||
* `runRunner`, writes stdout. The actual evaluation is in `runner.ts` —
|
||||
* this file is the stdio plumbing that the parent process spawns. It is
|
||||
* excluded from coverage thresholds because it cannot be exercised
|
||||
* realistically without actually spawning a child process; the
|
||||
* spawn-engine integration tests cover the whole pipeline end-to-end
|
||||
* against a temp-file fixture that mirrors this implementation.
|
||||
*/
|
||||
|
||||
import { type RunnerInput, type RunnerOutput, runRunner } from "./runner.js";
|
||||
|
||||
async function readStdinAll(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
const raw = await readStdinAll();
|
||||
const input = JSON.parse(raw) as RunnerInput;
|
||||
const output = runRunner(input);
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const output: RunnerOutput = {
|
||||
success: false,
|
||||
result: undefined,
|
||||
error: `Runner error: ${message}`,
|
||||
logs: [],
|
||||
memoryUsed: 0,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
66
packages/devtools/tooling/sandbox/src/spawn-engine/runner.md
Normal file
66
packages/devtools/tooling/sandbox/src/spawn-engine/runner.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Runner (spawn-engine child entry)
|
||||
|
||||
## Purpose
|
||||
|
||||
`runner.ts` is the child-process entry point invoked by `SpawnEngine`. The parent process spawns it via `node:child_process.spawn`, writes a JSON `RunnerInput` envelope to its stdin (closing the pipe to signal end-of-input), and reads a JSON `RunnerOutput` envelope from its stdout. The runner runs inside a fresh OS process with a sanitised environment — that process boundary is the security guarantee, not anything the runner itself does.
|
||||
|
||||
The runner exports `runRunner(input)` as a pure function so the parent can also test the evaluation pipeline without spawning a child. The `main()` entry point reads stdin, parses JSON, calls `runRunner`, and writes stdout — exposed for tests that want to drive the full I/O surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure `runRunner(input): RunnerOutput` for in-process testing.
|
||||
- `readStdinAll()` async stdin slurper.
|
||||
- `main()` orchestrator.
|
||||
- Auto-invokes `main()` when the file is launched directly (matched by
|
||||
filename ending in `runner.{js,ts,cjs,mjs}`).
|
||||
- `stringify(value)` JSON-safe fallback used by the captured console.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
The runner is normally invoked by `SpawnEngine`; consumers do not call it
|
||||
directly. To test in isolation:
|
||||
|
||||
```ts
|
||||
import { runRunner } from "@suaveplan/sandbox/runner";
|
||||
|
||||
const out = runRunner({
|
||||
code: "1 + 1",
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
console.log(out.result); // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the full stdin/stdout pipeline against the runner file by spawning a
|
||||
real child:
|
||||
|
||||
```ts
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const child = spawn(process.execPath, ["./dist/spawn-engine/runner.js"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
child.stdin.write(JSON.stringify({ code: "1 + 1", context: {}, modules: [], maxOutputSize: 1024 }));
|
||||
child.stdin.end();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RunnerInput` — `{ code, context, modules, maxOutputSize }`.
|
||||
- `RunnerOutput` — `{ success, result, error, logs, memoryUsed }`.
|
||||
- `runRunner(input: RunnerInput): RunnerOutput`.
|
||||
- `readStdinAll(): Promise<string>`.
|
||||
- `main(): Promise<void>`.
|
||||
- `stringify(value: unknown): string`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The runner builds a function scope via
|
||||
`Reflect.construct(Function, [...keys, source])` — this is the controlled
|
||||
boundary the runner owns; the parent is the one that decides whether to
|
||||
spawn it. Strict mode is enforced via the prologue. The
|
||||
auto-invoke guard checks `process.argv[1]` against a filename regex so
|
||||
re-importing the runner from a test never triggers the main loop.
|
||||
95
packages/devtools/tooling/sandbox/src/spawn-engine/runner.ts
Normal file
95
packages/devtools/tooling/sandbox/src/spawn-engine/runner.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Child-process pure evaluation core for the spawn-engine.
|
||||
*
|
||||
* `runRunner` takes a JSON `RunnerInput` envelope and returns a
|
||||
* `RunnerOutput` envelope, evaluating the wrapped expression in a strict-mode
|
||||
* function scope (with `console` and modules injected). The actual stdin /
|
||||
* stdout plumbing lives in `runner-bin.ts`, which spawns the child.
|
||||
*
|
||||
* SECURITY: This function evaluates arbitrary code received over IPC. That
|
||||
* is the runner's job — the spawn-engine's parent process is responsible
|
||||
* for deciding whether to spawn the runner at all. The boundary is process
|
||||
* isolation, not anything the runner itself does.
|
||||
*/
|
||||
|
||||
export interface RunnerInput {
|
||||
readonly code: string;
|
||||
readonly context: Record<string, unknown>;
|
||||
readonly modules: ReadonlyArray<{
|
||||
name: string;
|
||||
exports: Record<string, unknown>;
|
||||
}>;
|
||||
readonly maxOutputSize: number;
|
||||
}
|
||||
|
||||
export interface RunnerOutput {
|
||||
readonly success: boolean;
|
||||
readonly result: unknown;
|
||||
readonly error: string | undefined;
|
||||
readonly logs: readonly string[];
|
||||
readonly memoryUsed: number;
|
||||
}
|
||||
|
||||
/** JSON-safe string conversion used by the captured console. */
|
||||
export function stringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure evaluator. */
|
||||
export function runRunner(input: RunnerInput): RunnerOutput {
|
||||
const logs: string[] = [];
|
||||
let totalLogSize = 0;
|
||||
const append = (line: string): void => {
|
||||
totalLogSize += line.length;
|
||||
if (totalLogSize <= input.maxOutputSize) logs.push(line);
|
||||
};
|
||||
const sandboxConsole = {
|
||||
log: (...a: unknown[]) => append(a.map(stringify).join(" ")),
|
||||
warn: (...a: unknown[]) =>
|
||||
append(`[warn] ${a.map(stringify).join(" ")}`),
|
||||
error: (...a: unknown[]) =>
|
||||
append(`[error] ${a.map(stringify).join(" ")}`),
|
||||
};
|
||||
const scope: Record<string, unknown> = {
|
||||
console: sandboxConsole,
|
||||
...input.context,
|
||||
};
|
||||
for (const mod of input.modules) {
|
||||
scope[mod.name] = mod.exports;
|
||||
}
|
||||
const memBefore = process.memoryUsage().heapUsed;
|
||||
try {
|
||||
const keys = Object.keys(scope);
|
||||
const vals = keys.map((k) => scope[k]);
|
||||
const source = `"use strict";\nreturn (${input.code})`;
|
||||
const factory = Reflect.construct(Function, [...keys, source]) as (
|
||||
...args: unknown[]
|
||||
) => unknown;
|
||||
const result = factory(...vals);
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
return {
|
||||
success: true,
|
||||
result,
|
||||
error: undefined,
|
||||
logs,
|
||||
memoryUsed: Math.max(0, memAfter - memBefore),
|
||||
};
|
||||
} catch (err) {
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
success: false,
|
||||
result: undefined,
|
||||
error: message,
|
||||
logs,
|
||||
memoryUsed: Math.max(0, memAfter - memBefore),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# SpawnEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpawnEngine` uses `node:child_process.spawn` for the strongest isolation the package offers: a fresh OS process spawned per execution, pinned to `process.execPath`, with a sanitised environment that strips identity-revealing keys (`HOME`/`USER`/`SHELL`/`LOGNAME`) and case-insensitive substring matches against `SECRET`/`KEY`/`TOKEN`/`PASSWORD`/`CREDENTIAL`/`AUTH`. Code is sent to the child over stdin as a JSON envelope; the result is read from stdout the same way. Timeouts are enforced with `child.kill("SIGKILL")` so the running script cannot intercept termination.
|
||||
|
||||
The child process runs `runner.ts` (shipped via the `./runner` subpath export). The engine resolves the runner path relative to its own module URL by default, but callers can pin a custom path via the `runnerPath` option — useful for tests that want to drive the engine against a controlled runner fixture.
|
||||
|
||||
## Features
|
||||
|
||||
- Real `node:child_process.spawn` — true OS-process isolation.
|
||||
- Sanitised env via `sanitiseEnv` (exported for direct testing).
|
||||
- Injectable `ProcessSpawner` seam (defaults to `node:child_process.spawn`).
|
||||
- Configurable `runnerPath` and `execPath`.
|
||||
- Timeout enforcement via `SIGKILL` after `Promise.race`.
|
||||
- Failure modes: timeout, no-output, parse-error, all surfaced via the
|
||||
structured `SandboxResult` `error` field.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SpawnEngine } from "@suaveplan/sandbox/spawn-engine";
|
||||
|
||||
const engine = new SpawnEngine({
|
||||
timeout: 5000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 50 * 1024 * 1024,
|
||||
});
|
||||
const r = await engine.execute("1 + 1");
|
||||
engine.destroy();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a custom spawner for tests, plus a stripped-down env for tightest
|
||||
control:
|
||||
|
||||
```ts
|
||||
import { SpawnEngine } from "@suaveplan/sandbox/spawn-engine";
|
||||
|
||||
const engine = new SpawnEngine({
|
||||
timeout: 5000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 5e7,
|
||||
env: { PATH: process.env.PATH },
|
||||
spawner: (cmd, args, opts) => myFakeSpawn(cmd, args, opts),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SpawnEngine(options: SpawnEngineOptions)`.
|
||||
- `execute(code, context?, options?): Promise<SandboxResult>`.
|
||||
- `registerModule(mod): void`.
|
||||
- `unregisterModule(name): boolean`.
|
||||
- `getRunnerPath(): string`.
|
||||
- `getSanitisedEnv(): Record<string, string>`.
|
||||
- `reset(): void`.
|
||||
- `destroy(): void`.
|
||||
- `sanitiseEnv(env)` — pure helper exported for direct testing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `raceChild` helper consolidates the four ways a child can finish:
|
||||
clean exit with parseable JSON (`ok`), clean exit with non-JSON or empty
|
||||
output (`no-output` / `parse-error`), error event before close
|
||||
(`parse-error`), and timeout (`timeout`). Settling is idempotent — only the
|
||||
first outcome counts — so a timeout that races with a clean exit doesn't
|
||||
double-resolve. The kill on timeout is wrapped in try/catch because
|
||||
`child.kill` raises on already-exited processes on some platforms.
|
||||
@@ -0,0 +1,472 @@
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runRunner } from "./runner.js";
|
||||
import { SpawnEngine, sanitiseEnv } from "./spawn-engine.js";
|
||||
|
||||
const RUNNER_SCRIPT = `
|
||||
const stringify = (v) => {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v === null) return 'null';
|
||||
if (v === undefined) return 'undefined';
|
||||
try { return JSON.stringify(v); } catch { return String(v); }
|
||||
};
|
||||
const chunks = [];
|
||||
process.stdin.on('data', (c) => chunks.push(c));
|
||||
process.stdin.on('end', () => {
|
||||
let input;
|
||||
try { input = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
||||
catch (err) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
success: false, result: undefined,
|
||||
error: 'Runner error: ' + (err && err.message),
|
||||
logs: [], memoryUsed: 0,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const logs = [];
|
||||
let total = 0;
|
||||
const append = (l) => { total += l.length; if (total <= input.maxOutputSize) logs.push(l); };
|
||||
const sandboxConsole = {
|
||||
log: (...a) => append(a.map(stringify).join(' ')),
|
||||
warn: (...a) => append('[warn] ' + a.map(stringify).join(' ')),
|
||||
error: (...a) => append('[error] ' + a.map(stringify).join(' ')),
|
||||
};
|
||||
const scope = { console: sandboxConsole, ...input.context };
|
||||
for (const m of input.modules) scope[m.name] = m.exports;
|
||||
const memBefore = process.memoryUsage().heapUsed;
|
||||
try {
|
||||
const keys = Object.keys(scope);
|
||||
const vals = keys.map(k => scope[k]);
|
||||
const f = Reflect.construct(Function, [...keys, '"use strict";\\nreturn (' + input.code + ')']);
|
||||
const result = f(...vals);
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
process.stdout.write(JSON.stringify({
|
||||
success: true, result, error: undefined,
|
||||
logs, memoryUsed: Math.max(0, memAfter - memBefore),
|
||||
}));
|
||||
} catch (err) {
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
process.stdout.write(JSON.stringify({
|
||||
success: false, result: undefined,
|
||||
error: err && err.message ? err.message : String(err),
|
||||
logs, memoryUsed: Math.max(0, memAfter - memBefore),
|
||||
}));
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
function writeRunner(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "sandbox-runner-"));
|
||||
const path = join(dir, "runner.js");
|
||||
writeFileSync(path, RUNNER_SCRIPT);
|
||||
return path;
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
timeout: 10000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 50 * 1024 * 1024,
|
||||
};
|
||||
|
||||
describe("sanitiseEnv", () => {
|
||||
it("strips sensitive env keys", { timeout: 5000 }, () => {
|
||||
const out = sanitiseEnv({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/root",
|
||||
USER: "x",
|
||||
SHELL: "/bin/bash",
|
||||
LOGNAME: "x",
|
||||
MY_SECRET: "x",
|
||||
API_KEY: "x",
|
||||
BEARER_TOKEN: "x",
|
||||
DB_PASSWORD: "x",
|
||||
AWS_CREDENTIAL: "x",
|
||||
AUTH_SERVER: "x",
|
||||
SAFE_VAR: "ok",
|
||||
});
|
||||
expect(out.PATH).toBe("/usr/bin");
|
||||
expect(out.SAFE_VAR).toBe("ok");
|
||||
expect(out.HOME).toBeUndefined();
|
||||
expect(out.MY_SECRET).toBeUndefined();
|
||||
expect(out.API_KEY).toBeUndefined();
|
||||
expect(out.BEARER_TOKEN).toBeUndefined();
|
||||
expect(out.DB_PASSWORD).toBeUndefined();
|
||||
expect(out.AWS_CREDENTIAL).toBeUndefined();
|
||||
expect(out.AUTH_SERVER).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips empty values", { timeout: 5000 }, () => {
|
||||
const out = sanitiseEnv({ EMPTY: "", PRESENT: "yes" });
|
||||
expect(out.EMPTY).toBeUndefined();
|
||||
expect(out.PRESENT).toBe("yes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRunner (in-process)", () => {
|
||||
it("evaluates 1+1", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: "1 + 1",
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.success).toBe(true);
|
||||
expect(out.result).toBe(2);
|
||||
});
|
||||
|
||||
it("captures console output", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: 'function(){ console.log("hi", 1); console.warn("w"); console.error("e"); return 0; }()',
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.logs).toContain("hi 1");
|
||||
expect(out.logs).toContain("[warn] w");
|
||||
expect(out.logs).toContain("[error] e");
|
||||
});
|
||||
|
||||
it("propagates thrown errors", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: 'function(){ throw new Error("boom"); }()',
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.success).toBe(false);
|
||||
expect(out.error).toContain("boom");
|
||||
});
|
||||
|
||||
it("propagates non-Error throws", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: 'function(){ throw "string"; }()',
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.success).toBe(false);
|
||||
expect(out.error).toBe("string");
|
||||
});
|
||||
|
||||
it("respects maxOutputSize", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: 'function(){ console.log("aaaaa"); return 0; }()',
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 2,
|
||||
});
|
||||
expect(out.logs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("logs null and undefined via stringify", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = runRunner({
|
||||
code: "function(){ console.log(null, undefined); return 0; }()",
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.logs).toContain("null undefined");
|
||||
});
|
||||
|
||||
it("logs unstringifiable objects via String() fallback", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = runRunner({
|
||||
code: "function(){ var o = {}; o.self = o; console.log(o); return 0; }()",
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.success).toBe(true);
|
||||
expect(out.logs.length).toBe(1);
|
||||
});
|
||||
|
||||
it("injects context + modules", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: "x + cfg.y",
|
||||
context: { x: 1 },
|
||||
modules: [{ name: "cfg", exports: { y: 2 } }],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.result).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnEngine (real child_process)", () => {
|
||||
const runnerPath = writeRunner();
|
||||
|
||||
it("end-to-end runner pipeline returns 1+1", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute("1 + 1");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe(2);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("strips sensitive env keys", { timeout: 15000 }, () => {
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
env: { PATH: "/usr/bin", SECRET_X: "x", HOME: "/r" },
|
||||
});
|
||||
const env = e.getSanitisedEnv();
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.SECRET_X).toBeUndefined();
|
||||
expect(env.HOME).toBeUndefined();
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("kills child on timeout", { timeout: 15000 }, async () => {
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
timeout: 300,
|
||||
});
|
||||
const r = await e.execute("function(){ while(true){} }()");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/timed out/i);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("captures child output and logs", { timeout: 15000 }, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute(
|
||||
'function(){ console.log("hi"); return "done"; }()'
|
||||
);
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe("done");
|
||||
expect(r.logs).toContain("hi");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("registerModule + execute injects exports", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.registerModule({ name: "cfg", exports: { v: 21 } });
|
||||
const r = await e.execute("cfg.v * 2");
|
||||
expect(r.result).toBe(42);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("registerModule overwrites by name", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.registerModule({ name: "cfg", exports: { v: 1 } });
|
||||
e.registerModule({ name: "cfg", exports: { v: 2 } });
|
||||
const r = await e.execute("cfg.v");
|
||||
expect(r.result).toBe(2);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("unregisterModule returns boolean", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.registerModule({ name: "cfg", exports: {} });
|
||||
expect(e.unregisterModule("cfg")).toBe(true);
|
||||
expect(e.unregisterModule("cfg")).toBe(false);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("reset restores initial modules", { timeout: 15000 }, async () => {
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
modules: [{ name: "cfg", exports: { v: 9 } }],
|
||||
});
|
||||
e.unregisterModule("cfg");
|
||||
e.reset();
|
||||
const r = await e.execute("cfg.v");
|
||||
expect(r.result).toBe(9);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("returns failure result after destroy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.destroy();
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("disposed");
|
||||
});
|
||||
|
||||
it("getRunnerPath returns configured path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
expect(e.getRunnerPath()).toBe(runnerPath);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("default runner path resolves relative to module", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig });
|
||||
expect(e.getRunnerPath()).toMatch(/runner-bin\.(?:js|ts)$/);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces no-output when child writes nothing", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const silentRunner = writeRunner();
|
||||
writeFileSync(silentRunner, "process.exit(0);");
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath: silentRunner,
|
||||
});
|
||||
const r = await e.execute("1 + 1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("no output");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces parse-error when child writes garbage", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const garbageRunner = writeRunner();
|
||||
writeFileSync(
|
||||
garbageRunner,
|
||||
"process.stdout.write('not json'); process.exit(0);"
|
||||
);
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath: garbageRunner,
|
||||
});
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("Failed to parse");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces parse-error when child errors out before spawn", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
// Inject a spawner that emits an error event on the next tick.
|
||||
const fakeChild = {
|
||||
stdin: { write: () => {}, end: () => {} } as never,
|
||||
stdout: {
|
||||
on: () => {},
|
||||
} as never,
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
if (event === "error") {
|
||||
queueMicrotask(() => cb(new Error("spawn failed")));
|
||||
}
|
||||
},
|
||||
kill: () => true,
|
||||
};
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
spawner: () => fakeChild as never,
|
||||
});
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("spawn failed");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces failure when spawner returns null pipes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
spawner: () =>
|
||||
({
|
||||
stdin: null,
|
||||
stdout: null,
|
||||
on: () => {},
|
||||
kill: () => true,
|
||||
}) as never,
|
||||
});
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("stdio pipes");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("respects per-execution maxOutputSize override", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute(
|
||||
'function(){ console.log("aaaa"); return 1; }()',
|
||||
{},
|
||||
{ maxOutputSize: 2 }
|
||||
);
|
||||
expect(r.logs.length).toBe(0);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("uses real node:child_process by default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
// Default spawner just wraps node:child_process.spawn; sanity-check
|
||||
// that it's callable.
|
||||
expect(typeof nodeSpawn).toBe("function");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("name is 'spawn'", { timeout: 5000 }, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
expect(e.name).toBe("spawn");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces child failure result with success=false fields", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute('function(){ throw new Error("oops"); }()');
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("oops");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("reads child reply without memoryUsed/logs fields gracefully", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const partialRunner = writeRunner();
|
||||
writeFileSync(
|
||||
partialRunner,
|
||||
"process.stdin.on('data', () => {}); process.stdin.on('end', () => { process.stdout.write(JSON.stringify({ success: true, result: 7 })); });"
|
||||
);
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath: partialRunner,
|
||||
});
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe(7);
|
||||
expect(r.memoryUsed).toBe(0);
|
||||
expect(r.logs).toEqual([]);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("reset works when no initial modules", { timeout: 5000 }, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.reset();
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("execute uses default context+options", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute("5 + 5");
|
||||
expect(r.result).toBe(10);
|
||||
e.destroy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user