Files
oh-my-pi/packages/coding-agent/test/core/js-executor.test.ts
T
can1357 b4b6536838 feat(pi-natives): added link-following control to filesystem scan options
- Added a follow_links flag to ScanOptions and build_walker so callers could control symlink traversal.
- Updated scan callsites so ast, glob, and grep continued skipping symlinks while fd's fuzzy-find enabled link following.
- Updated fd scoring to favor fuzzy basename matching for queries without '/', reducing matches based only on ancestor directory names.
2026-05-12 10:25:24 +02:00

286 lines
8.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { TempDir } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import { disposeAllVmContexts } from "../../src/eval/js/context-manager";
import { executeJs, type JsResult } from "../../src/eval/js/executor";
function createTool(
name: string,
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<AgentToolResult>,
): AgentTool {
return {
name,
label: name,
description: `${name} tool`,
parameters: Type.Object({}),
concurrency: "parallel",
execute,
} as unknown as AgentTool;
}
function getJsonData(result: JsResult): unknown {
const jsonOutputs = result.displayOutputs.filter(
(output): output is Extract<JsResult["displayOutputs"][number], { type: "json" }> => output.type === "json",
);
expect(jsonOutputs).toHaveLength(1);
return jsonOutputs[0].data;
}
function getStatusEvents(result: JsResult) {
return result.displayOutputs.filter(
(output): output is Extract<JsResult["displayOutputs"][number], { type: "status" }> => output.type === "status",
);
}
describe("executeJs", () => {
let tempDir: TempDir;
let session: ToolSession;
let sessionFile: string;
let sessionId: string;
beforeEach(async () => {
tempDir = TempDir.createSync("@js-executor-");
sessionFile = path.join(tempDir.path(), "session.jsonl");
sessionId = `session:${sessionFile}:cwd:${tempDir.path()}`;
session = {
cwd: tempDir.path(),
hasUI: false,
getSessionFile: () => sessionFile,
getSessionSpawns: () => null,
settings: Settings.isolated(),
};
await Bun.write(
path.join(tempDir.path(), "config.json"),
JSON.stringify({ name: "demo", enabled: true }, null, 2),
);
await Bun.write(path.join(tempDir.path(), "config.yaml"), "name: demo\nenabled: true\n");
});
afterEach(async () => {
await disposeAllVmContexts();
tempDir.removeSync();
vi.restoreAllMocks();
});
it("persists bindings across calls and reset clears them", async () => {
await executeJs("const answer = 2;", { sessionId, session, sessionFile });
const persisted = await executeJs("return answer + 2;", { sessionId, session, sessionFile });
expect(persisted.exitCode).toBe(0);
expect(persisted.output.trim()).toBe("4");
const resetResult = await executeJs("return typeof answer;", {
sessionId,
session,
sessionFile,
reset: true,
});
expect(resetResult.exitCode).toBe(0);
expect(resetResult.output.trim()).toBe("undefined");
});
it("exposes the worker's real process object", async () => {
const result = await executeJs(
[
"return {",
" version: process.version,",
" versionsNode: typeof process.versions.node,",
" platform: process.platform,",
" arch: process.arch,",
" hasEnv: 'env' in process,",
" hasExit: 'exit' in process,",
"};",
].join("\n"),
{ sessionId, session, sessionFile },
);
expect(result.exitCode).toBe(0);
expect(getJsonData(result)).toEqual({
version: process.version,
versionsNode: "string",
platform: process.platform,
arch: process.arch,
hasEnv: true,
hasExit: true,
});
});
it("exposes common web globals and the raw Node fs module", async () => {
const result = await executeJs(
[
"const uuid = crypto.randomUUID();",
"const digest = await webcrypto.subtle.digest('SHA-256', new TextEncoder().encode('ok'));",
"const base = __omp_session__.cwd;",
"fs.mkdirSync(base + '/nested', { recursive: true });",
"fs.writeFileSync(base + '/nested/value.txt', 'hello');",
"await fs.promises.copyFile(base + '/nested/value.txt', base + '/nested/copy.txt');",
"const text = fs.readFileSync(base + '/nested/copy.txt', 'utf8');",
"const bytes = await fs.promises.readFile(base + '/nested/copy.txt');",
"const stat = fs.statSync(base + '/nested/copy.txt');",
"const entries = fs.readdirSync(base + '/nested');",
"const start = performance.now();",
"return {",
" uuid: typeof uuid,",
" digestBytes: digest.byteLength,",
" text,",
" byteLength: bytes.byteLength,",
" isFile: stat.isFile(),",
" entries: entries.sort(),",
" hasConstants: typeof fs.constants.R_OK,",
" buffer: Buffer.from('ok').toString('hex'),",
" performance: typeof start,",
"};",
].join("\n"),
{ sessionId, session, sessionFile },
);
expect(result.exitCode).toBe(0);
expect(getJsonData(result)).toEqual({
uuid: "string",
digestBytes: 32,
text: "hello",
byteLength: 5,
isFile: true,
entries: ["copy.txt", "value.txt"],
hasConstants: "number",
buffer: "6f6b",
performance: "number",
});
});
it("reads files as text and supports offset/limit slicing", async () => {
const result = await executeJs(
[
"const full = await read('config.json');",
"const sliced = await read('config.json', { offset: 2, limit: 1 });",
"return { isString: typeof full === 'string', full, sliced };",
].join("\n"),
{
sessionId,
session,
sessionFile,
},
);
expect(result.exitCode).toBe(0);
expect(getStatusEvents(result)).toHaveLength(2);
expect(getJsonData(result)).toEqual({
isString: true,
full: '{\n "name": "demo",\n "enabled": true\n}',
sliced: ' "name": "demo",',
});
});
it("rejects protocol paths and directory reads from native read()", async () => {
const protocolResult = await executeJs("await read('agent://demo');", {
sessionId,
session,
sessionFile,
});
expect(protocolResult.exitCode).toBe(1);
expect(protocolResult.output).toContain("Protocol paths are not supported");
const directoryResult = await executeJs("await read('.');", {
sessionId,
session,
sessionFile,
});
expect(directoryResult.exitCode).toBe(1);
expect(directoryResult.output).toContain("Directory paths are not supported");
});
it("routes output() through tool.read and keeps tool.* results normalized", async () => {
const execute = vi.fn(async (_toolCallId: string, args: unknown): Promise<AgentToolResult> => {
const record = args as { path: string };
if (record.path.startsWith("agent://")) {
return { content: [{ type: "text", text: "from-agent" }] };
}
return {
content: [{ type: "text", text: "annotated" }],
details: { path: record.path, kind: "tool-result" },
};
});
const toolSession: ToolSession = {
...session,
getToolByName: name => (name === "read" ? createTool("read", execute) : undefined),
};
const result = await executeJs(
"return { toolResult: await tool.read({ path: 'package.json' }), agentOutput: await output('agent-42') };",
{
sessionId,
session: toolSession,
sessionFile,
},
);
expect(result.exitCode).toBe(0);
expect(getStatusEvents(result)).toHaveLength(2);
expect(getJsonData(result)).toEqual({
toolResult: {
text: "annotated",
details: { path: "package.json", kind: "tool-result" },
images: undefined,
},
agentOutput: "from-agent",
});
expect(execute).toHaveBeenCalledTimes(2);
expect(execute.mock.calls[0]?.[1]).toEqual({ path: "package.json", _i: "js prelude" });
expect(execute.mock.calls[1]?.[1]).toEqual({ path: "agent://agent-42", _i: "js prelude" });
});
it("auto-displays returned objects as structured output", async () => {
const result = await executeJs("return { answer: 42, nested: { ok: true } };", {
sessionId,
session,
sessionFile,
});
expect(result.exitCode).toBe(0);
expect(result.output).toBe("");
expect(result.displayOutputs).toEqual([{ type: "json", data: { answer: 42, nested: { ok: true } } }]);
});
it("cancels execution when the timeout expires", async () => {
const result = await executeJs("await new Promise(() => {})", {
sessionId,
session,
sessionFile,
timeoutMs: 20,
});
expect(result.cancelled).toBe(true);
expect(result.exitCode).toBeUndefined();
expect(result.output).toContain("Command timed out");
});
it('rewrites static `import { x } from "pkg"` to dynamic import', async () => {
const result = await executeJs('import { join } from "node:path";\nreturn join("a", "b");', {
sessionId,
session,
sessionFile,
});
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe(path.join("a", "b"));
});
it("strips TypeScript syntax before executing user code", async () => {
const result = await executeJs(
[
"interface Pair { a: number; b: number }",
"const make = (a: number, b: number): Pair => ({ a, b });",
"const p = make(3, 4) as Pair;",
"return p.a + p.b;",
].join("\n"),
{ sessionId, session, sessionFile },
);
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("7");
});
});