u
This commit is contained in:
220
test/javascript.test.js
Normal file
220
test/javascript.test.js
Normal file
@@ -0,0 +1,220 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* javascript_execute — sandbox execution, isolation guarantees, and the
|
||||
* timeout/serialization behaviour of the worker bridge.
|
||||
*
|
||||
* These tests spawn real worker threads and run real vm2 sandboxes: the
|
||||
* security properties (a runaway loop, blocked host modules) are exactly
|
||||
* what a mocked-out vm2 would fail to verify.
|
||||
*
|
||||
* Note: no test passes allow_network=true, because that path requires an
|
||||
* interactive TTY approval prompt. Network plumbing is exercised separately
|
||||
* in a scratch worker, not here.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Worker } from 'worker_threads';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { javascriptExecuteTool } from '../src/tools/javascript.js';
|
||||
|
||||
const WORKER_PATH = fileURLToPath(new URL('../src/tools/javascript-worker.js', import.meta.url));
|
||||
|
||||
/** Run a snippet through the real tool, normalizing errors to a message. */
|
||||
const exec = (args) =>
|
||||
javascriptExecuteTool.invoke(args).then(
|
||||
(result) => ({ ok: true, result }),
|
||||
(err) => ({ ok: false, error: err.message }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Drive the worker directly, bypassing the approval layer, so worker-level
|
||||
* behaviour (timeouts, serialization) can be asserted without a TTY.
|
||||
*/
|
||||
function runInWorker(code, { network = false, timeout = 5000, cap = 20_000 } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(WORKER_PATH, {
|
||||
workerData: {
|
||||
code,
|
||||
root: process.cwd(),
|
||||
vmTimeout: timeout,
|
||||
bufferLimit: 32 * 1024 * 1024,
|
||||
network,
|
||||
},
|
||||
});
|
||||
const bail = setTimeout(() => {
|
||||
worker.terminate();
|
||||
resolve({ timedOut: true });
|
||||
}, cap);
|
||||
worker.on('message', (msg) => {
|
||||
clearTimeout(bail);
|
||||
worker.terminate();
|
||||
resolve(msg);
|
||||
});
|
||||
worker.on('error', (err) => {
|
||||
clearTimeout(bail);
|
||||
worker.terminate();
|
||||
resolve({ error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Result serialization ───────────────────────────────────────────────────
|
||||
|
||||
test('a snippet returns its value via module.exports', async () => {
|
||||
const { ok, result } = await exec({ code: 'module.exports = 6 * 7;' });
|
||||
assert.ok(ok, 'expected the call to succeed');
|
||||
assert.equal(result.result, 42);
|
||||
});
|
||||
|
||||
test('nested objects and arrays survive the worker boundary', async () => {
|
||||
// vm2 returns Proxy objects; structured-clone rejects them outright, so
|
||||
// the worker rebuilds plain host containers before posting.
|
||||
const { ok, result } = await exec({
|
||||
code: 'module.exports = { a: 1, b: [1, 2, { c: true }], d: "x" };',
|
||||
});
|
||||
assert.ok(ok);
|
||||
assert.deepEqual(result.result, { a: 1, b: [1, 2, { c: true }], d: 'x' });
|
||||
});
|
||||
|
||||
test('a returned array is not reported as unserializable', async () => {
|
||||
const { ok, result } = await exec({ code: 'module.exports = [[1, 2], [3, { d: 4 }]];' });
|
||||
assert.ok(ok);
|
||||
assert.deepEqual(result.result, [[1, 2], [3, { d: 4 }]]);
|
||||
});
|
||||
|
||||
test('a returned function degrades to a type marker instead of throwing', async () => {
|
||||
const { ok, result } = await exec({ code: 'module.exports = function f() {};' });
|
||||
assert.ok(ok);
|
||||
assert.deepEqual(result.result, { type: 'function' });
|
||||
});
|
||||
|
||||
test('values nested deeper than the depth limit are summarized, not dropped silently', async () => {
|
||||
const { ok, result } = await exec({ code: 'module.exports = { a: { b: { c: { d: { e: 1 } } } } };' });
|
||||
assert.ok(ok);
|
||||
assert.equal(result.result.a.b.c.d.type, 'object');
|
||||
assert.match(result.result.a.b.c.d.note, /deeply nested/);
|
||||
});
|
||||
|
||||
// ─── Console capture ────────────────────────────────────────────────────────
|
||||
|
||||
test('console output is captured and returned rather than printed to the terminal', async () => {
|
||||
const { ok, result } = await exec({
|
||||
code: 'console.log("plain"); console.error("boom"); module.exports = 1;',
|
||||
});
|
||||
assert.ok(ok);
|
||||
assert.equal(result.stdout, 'plain\nerror: boom');
|
||||
assert.equal(result.consoleLines, 2);
|
||||
});
|
||||
|
||||
test('console output beyond max_output is capped and flagged', async () => {
|
||||
const { ok, result } = await exec({
|
||||
code: 'for (let i = 0; i < 100; i++) console.log("x".repeat(20));',
|
||||
max_output: 60,
|
||||
});
|
||||
assert.ok(ok);
|
||||
assert.equal(result.truncated, true);
|
||||
assert.ok(result.stdout.length <= 60, 'stdout must respect the cap');
|
||||
assert.match(result.truncationNote, /60 chars/);
|
||||
});
|
||||
|
||||
// ─── Errors ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a thrown error surfaces as a ToolError carrying name, message and stack', async () => {
|
||||
const { ok, error } = await exec({ code: 'null.x;' });
|
||||
assert.equal(ok, false);
|
||||
assert.match(error, /Cannot read properties of null/);
|
||||
assert.match(error, /TypeError/);
|
||||
});
|
||||
|
||||
test('a syntax error is reported rather than crashing the worker', async () => {
|
||||
const { ok, error } = await exec({ code: 'const = ;' });
|
||||
assert.equal(ok, false);
|
||||
assert.ok(error.length > 0);
|
||||
});
|
||||
|
||||
test('blank code is rejected before any worker is spawned', async () => {
|
||||
const { ok, error } = await exec({ code: ' ' });
|
||||
assert.equal(ok, false);
|
||||
assert.match(error, /non-empty string/);
|
||||
});
|
||||
|
||||
test('a non-positive timeout is rejected', async () => {
|
||||
const { ok, error } = await exec({ code: '1;', timeout: 0 });
|
||||
assert.equal(ok, false);
|
||||
assert.match(error, /positive integer/);
|
||||
});
|
||||
|
||||
// ─── Timeout: the reason this runs in a worker at all ───────────────────────
|
||||
|
||||
test('an infinite loop is killed at the timeout instead of hanging', async () => {
|
||||
// The core guarantee. NodeVM does not honor vm2's own `timeout`, so
|
||||
// without the worker being terminated this call never returns.
|
||||
const started = Date.now();
|
||||
const { ok, result } = await exec({ code: 'while (true) {}', timeout: 1000 });
|
||||
const elapsed = Date.now() - started;
|
||||
assert.ok(ok, 'a timeout is a normal outcome, not a thrown error');
|
||||
assert.equal(result.timedOut, true);
|
||||
assert.ok(elapsed < 8000, `expected a prompt kill, took ${elapsed} ms`);
|
||||
assert.match(result.note, /timeout/);
|
||||
});
|
||||
|
||||
test('the worker-level hard kill also stops a blocking loop', async () => {
|
||||
const report = await runInWorker('while (true) {}', { cap: 3000 });
|
||||
assert.equal(report.timedOut, true);
|
||||
});
|
||||
|
||||
// ─── Isolation ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('built-in host modules are not reachable', async () => {
|
||||
for (const mod of ['fs', 'child_process', 'os', 'node:fs']) {
|
||||
const { ok, error } = await exec({ code: `require(${JSON.stringify(mod)});` });
|
||||
assert.equal(ok, false, `${mod} must not be requireable`);
|
||||
assert.match(error, /Cannot find module/);
|
||||
}
|
||||
});
|
||||
|
||||
test('node_modules packages are not reachable', async () => {
|
||||
const { ok, error } = await exec({ code: 'require("chalk");' });
|
||||
assert.equal(ok, false);
|
||||
assert.match(error, /Cannot find module/);
|
||||
});
|
||||
|
||||
test('vm2 itself is not reachable (no nesting escape)', async () => {
|
||||
const { ok, error } = await exec({ code: 'require("vm2");' });
|
||||
assert.equal(ok, false);
|
||||
assert.match(error, /Cannot find module/);
|
||||
});
|
||||
|
||||
test('fetch is absent unless network access was granted', async () => {
|
||||
const offline = await runInWorker('module.exports = typeof fetch;');
|
||||
assert.equal(offline.result, 'undefined');
|
||||
});
|
||||
|
||||
// ─── Async ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a promise assigned to module.exports is awaited', async () => {
|
||||
const { ok, result } = await exec({
|
||||
code: 'async function f() { return 41 + 1; }\nmodule.exports = f();',
|
||||
});
|
||||
assert.ok(ok);
|
||||
assert.equal(result.result, 42);
|
||||
});
|
||||
|
||||
test('a rejected promise is reported as an error', async () => {
|
||||
const { ok, error } = await exec({
|
||||
code: 'module.exports = (async () => { throw new Error("async boom"); })();',
|
||||
});
|
||||
assert.equal(ok, false);
|
||||
assert.match(error, /async boom/);
|
||||
});
|
||||
|
||||
test('a promise that never settles is bounded by the timeout', async () => {
|
||||
const report = await runInWorker('module.exports = new Promise(() => {});', {
|
||||
timeout: 800,
|
||||
cap: 6000,
|
||||
});
|
||||
assert.match(String(report.error ?? ''), /did not settle/);
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict';
|
||||
import { createElement as h } from 'react';
|
||||
import { renderToString } from 'ink';
|
||||
|
||||
import { createTurnHandlers, dispatchTurnEvent } from '../src/repl.js';
|
||||
import { createTurnHandlers, dispatchTurnEvent, stampUserInput } from '../src/repl.js';
|
||||
import { TURN_EVENTS, TURN_STATES } from '../src/turn-state.js';
|
||||
import { createStore } from '../src/ui/store.js';
|
||||
import { App } from '../src/ui/app.js';
|
||||
@@ -55,6 +55,21 @@ function playTurn(script) {
|
||||
return { store, observed };
|
||||
}
|
||||
|
||||
test('a user message is prefixed with a zero-padded 24-hour local time', () => {
|
||||
const at = (h, m) => new Date(2026, 8, 17, h, m);
|
||||
assert.equal(stampUserInput('add a test', at(14, 32)), '[14:32] add a test');
|
||||
assert.equal(stampUserInput('list dirs', at(9, 5)), '[09:05] list dirs');
|
||||
assert.equal(stampUserInput('midnight', at(0, 0)), '[00:00] midnight');
|
||||
assert.equal(stampUserInput('last minute', at(23, 59)), '[23:59] last minute');
|
||||
});
|
||||
|
||||
test('the timestamp prefix never touches the text it wraps', () => {
|
||||
const at = new Date(2026, 8, 17, 8, 7);
|
||||
const text = ' run websearch with start_date \n and a second line ';
|
||||
assert.equal(stampUserInput(text, at), `[08:07] ${text}`);
|
||||
assert.equal(stampUserInput('', at), '[08:07] ');
|
||||
});
|
||||
|
||||
test('a plain answer reads as connecting -> streaming', () => {
|
||||
const { observed } = playTurn([
|
||||
{ cb: 'onContent' },
|
||||
|
||||
Reference in New Issue
Block a user