independent-tests 0.1.0 → 0.2.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/AGENTS.md +3 -1
- package/README.md +112 -54
- package/SKILL.md +13 -10
- package/bin/backends.mjs +55 -25
- package/bin/generate.mjs +3 -3
- package/bin/handout.mjs +3 -3
- package/bin/lib.mjs +176 -79
- package/bin/platform.mjs +145 -0
- package/bin/run.mjs +6 -4
- package/bin/runners/boundary.mjs +27 -8
- package/bin/runners/guard.mjs +8 -6
- package/bin/runners/pytest.mjs +3 -2
- package/bin/runners/shared.mjs +10 -7
- package/package.json +8 -7
- package/prompts/agent.md +1 -1
- package/prompts/exec/agent.md +1 -1
- package/prompts/http/agent.md +1 -1
- package/template/package-lock.json +1 -1
- package/template/package.json +2 -2
- package/template/sandbox/run.mjs +3 -2
- package/template/sandbox/test.mjs +30 -0
- package/template-python/README.md +2 -1
- package/template-python/sandbox/run.py +5 -3
package/bin/platform.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// What differs between macOS, Linux and Windows on the host, in one place,
|
|
2
|
+
// so the rest reads the same on all three:
|
|
3
|
+
//
|
|
4
|
+
// - relative paths (manifest keys, test ids, toolchain.json paths, the
|
|
5
|
+
// paths a judge prints) always use "/", whatever the OS writes (`slash`);
|
|
6
|
+
// - a command line string goes through the OS shell (`shellSync`): sh on
|
|
7
|
+
// macOS and Linux, cmd.exe on Windows, as npm runs a package's scripts;
|
|
8
|
+
// - a command found on PATH honours PATHEXT, and npm's `.cmd` shims run
|
|
9
|
+
// through cmd.exe (`spawnCommand`), since Node refuses to spawn them
|
|
10
|
+
// directly;
|
|
11
|
+
// - a process and everything it started is killed by its group on macOS
|
|
12
|
+
// and Linux (it was spawned `detached`) and by `taskkill /T` on Windows,
|
|
13
|
+
// where `detached` would open a console window instead (`GROUP`,
|
|
14
|
+
// `killTree`);
|
|
15
|
+
// - a venv keeps its interpreter in bin/ or, on Windows, Scripts/.
|
|
16
|
+
//
|
|
17
|
+
// Claude Code's OS sandbox (and sandbox-exec, bwrap) exist on macOS and
|
|
18
|
+
// Linux only: on Windows the sessions run with the file tools' deny rules
|
|
19
|
+
// and the transcript audit, not an OS sandbox (`OS_SANDBOX`, lib.mjs
|
|
20
|
+
// `sessionSettings`, backends.mjs `placement`).
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { existsSync, statSync } from "node:fs";
|
|
24
|
+
import { delimiter, dirname, extname, isAbsolute, join, sep } from "node:path";
|
|
25
|
+
|
|
26
|
+
export const WINDOWS = process.platform === "win32";
|
|
27
|
+
/** Whether this OS has a sandbox the sessions run under: macOS and Linux. */
|
|
28
|
+
export const OS_SANDBOX = process.platform === "darwin" || process.platform === "linux";
|
|
29
|
+
|
|
30
|
+
/** A relative path with "/" between its parts, on every OS. */
|
|
31
|
+
export const slash = (path) => (sep === "/" ? path : path.split(sep).join("/"));
|
|
32
|
+
/** `path` is `folder` or inside it; both absolute paths of this OS. */
|
|
33
|
+
export const inside = (path, folder) =>
|
|
34
|
+
path === folder || path.startsWith(folder.endsWith(sep) ? folder : `${folder}${sep}`);
|
|
35
|
+
|
|
36
|
+
/** The interpreter a venv holds, relative to the folder above `.venv`, with "/": `.venv/bin/python` or `.venv/Scripts/python.exe`. */
|
|
37
|
+
export const venvBin = (name, venv = ".venv") =>
|
|
38
|
+
WINDOWS ? `${venv}/Scripts/${name}.exe` : `${venv}/bin/${name}`;
|
|
39
|
+
/** The folder of a venv's executables, by name: `bin` or `Scripts`. */
|
|
40
|
+
export const VENV_BIN_DIR = WINDOWS ? "Scripts" : "bin";
|
|
41
|
+
/** The Python that starts a venv: `python3` on macOS and Linux; on Windows `python` (`python3` there is often the Store's stub). */
|
|
42
|
+
export const DEFAULT_PYTHON = WINDOWS ? "python" : "python3";
|
|
43
|
+
|
|
44
|
+
/** The PATH key of an environment: Windows keeps it as `Path`, and two spellings in one environment are undefined. */
|
|
45
|
+
export const pathKey = (env) => Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
|
|
46
|
+
/** `env` with `dirs` first on its PATH. */
|
|
47
|
+
export function prependPath(env, dirs) {
|
|
48
|
+
const key = pathKey(env);
|
|
49
|
+
return { ...env, [key]: [...dirs, env[key]].filter(Boolean).join(delimiter) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The first `name` on PATH, or null: `command -v` on every OS. On Windows
|
|
54
|
+
* each PATHEXT extension is tried, `.exe` and `.cmd` among them; a name
|
|
55
|
+
* with a folder in it is only checked.
|
|
56
|
+
*/
|
|
57
|
+
export function which(name, env = process.env) {
|
|
58
|
+
const exts = WINDOWS && extname(name) === "" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
|
|
59
|
+
const isFile = (path) => {
|
|
60
|
+
try {
|
|
61
|
+
return statSync(path).isFile();
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
if (isAbsolute(name) || name.includes("/") || name.includes(sep))
|
|
67
|
+
return exts.map((ext) => `${name}${ext}`).find(isFile) ?? null;
|
|
68
|
+
for (const dir of (env[pathKey(env)] ?? "").split(delimiter).filter(Boolean))
|
|
69
|
+
for (const ext of exts) if (isFile(join(dir, `${name}${ext}`))) return join(dir, `${name}${ext}`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A command line string through the OS shell: sh on macOS and Linux, cmd.exe on Windows. */
|
|
74
|
+
export const shellSync = (command, options = {}) =>
|
|
75
|
+
spawnSync(command, { windowsHide: true, ...options, shell: true });
|
|
76
|
+
|
|
77
|
+
/** An argument quoted for cmd.exe, when it needs it. */
|
|
78
|
+
const cmdQuote = (arg) => (/^[\w@.:/\\=,+-]+$/.test(arg) ? arg : `"${arg.replaceAll('"', '""')}"`);
|
|
79
|
+
/**
|
|
80
|
+
* `[bin, argv, options]` to spawn `bin argv` with: unchanged on macOS and
|
|
81
|
+
* Linux; on Windows `bin` found on the PATH of `env`, and npm's `.cmd`
|
|
82
|
+
* shims (npx, npm, pnpm, corepack, node_modules/.bin/vitest) run through
|
|
83
|
+
* cmd.exe with each argument quoted.
|
|
84
|
+
*/
|
|
85
|
+
export function commandOf(bin, argv, env = process.env) {
|
|
86
|
+
if (!WINDOWS) return [bin, argv, {}];
|
|
87
|
+
const found = which(bin, env);
|
|
88
|
+
if (found === null || !/\.(cmd|bat)$/i.test(found)) return [found ?? bin, argv, { windowsHide: true }];
|
|
89
|
+
return [[cmdQuote(found), ...argv.map(cmdQuote)].join(" "), [], { windowsHide: true, shell: true }];
|
|
90
|
+
}
|
|
91
|
+
/** `spawnSync(bin, argv, options)` through `commandOf`: npm's shims run on Windows too. */
|
|
92
|
+
export function spawnCommand(bin, argv, options = {}) {
|
|
93
|
+
const [file, args, extra] = commandOf(bin, argv, options.env);
|
|
94
|
+
return spawnSync(file, args, { ...options, ...extra });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Spawn options for a process whose whole tree `killTree` takes down: a
|
|
99
|
+
* process group leader on macOS and Linux; on Windows no console window,
|
|
100
|
+
* and taskkill finds the tree by parent.
|
|
101
|
+
*/
|
|
102
|
+
export const GROUP = WINDOWS ? { windowsHide: true } : { detached: true };
|
|
103
|
+
/** Kills `pid` and everything it started (spawned with `GROUP`); quiet when it is gone. */
|
|
104
|
+
export function killTree(pid) {
|
|
105
|
+
if (pid === undefined || pid === null) return;
|
|
106
|
+
if (WINDOWS) {
|
|
107
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
process.kill(-pid, "SIGKILL");
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Git for Windows' bash, which Claude Code's Bash tool runs on Windows:
|
|
117
|
+
* CLAUDE_CODE_GIT_BASH_PATH, else bin/bash.exe beside the git on PATH.
|
|
118
|
+
* Never System32's bash.exe, which starts WSL. Null when there is none.
|
|
119
|
+
*/
|
|
120
|
+
export function gitBash() {
|
|
121
|
+
const given = process.env.CLAUDE_CODE_GIT_BASH_PATH;
|
|
122
|
+
if (given && existsSync(given)) return given;
|
|
123
|
+
const git = which("git");
|
|
124
|
+
if (git === null) return null;
|
|
125
|
+
// <Git>/cmd/git.exe or <Git>/bin/git.exe or <Git>/mingw64/bin/git.exe.
|
|
126
|
+
for (let at = dirname(git); dirname(at) !== at; at = dirname(at)) {
|
|
127
|
+
const bash = join(at, "bin", "bash.exe");
|
|
128
|
+
if (existsSync(bash)) return bash;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Text as the transcript audit compares it: on Windows "\" (and JSON's
|
|
135
|
+
* "\\") as "/", Git Bash's `/c/` as `c:/`, and lower case, since a session
|
|
136
|
+
* may name one path in any of those forms. Unchanged elsewhere.
|
|
137
|
+
*/
|
|
138
|
+
export const comparable = WINDOWS
|
|
139
|
+
? (text) =>
|
|
140
|
+
text
|
|
141
|
+
.replaceAll("\\\\", "/")
|
|
142
|
+
.replaceAll("\\", "/")
|
|
143
|
+
.replace(/(^|[\s"'=(:;])\/([a-z])\//gi, "$1$2:/")
|
|
144
|
+
.toLowerCase()
|
|
145
|
+
: (text) => text;
|
package/bin/run.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { existsSync, rmSync, writeFileSync } from "node:fs";
|
|
|
28
28
|
import { join } from "node:path";
|
|
29
29
|
import {
|
|
30
30
|
AGENT,
|
|
31
|
+
backendOf,
|
|
31
32
|
DEFAULT_MODEL,
|
|
32
33
|
RUNS,
|
|
33
34
|
args,
|
|
@@ -61,11 +62,12 @@ const git = (dir, ...argv) =>
|
|
|
61
62
|
|
|
62
63
|
const { options, rest } = args();
|
|
63
64
|
const runs = Number(options.runs ?? 1);
|
|
64
|
-
// --backend runs the agent on another backend (lib.mjs `backendOf
|
|
65
|
-
//
|
|
66
|
-
|
|
65
|
+
// --backend runs the agent on another backend (lib.mjs `backendOf`, by
|
|
66
|
+
// default whichever agent the person has); its variant is the backend's
|
|
67
|
+
// name unless --model names a model.
|
|
68
|
+
const backend = backendOf({ backend: typeof options.backend === "string" ? options.backend : undefined, kind: "agent" });
|
|
67
69
|
const model =
|
|
68
|
-
typeof options.model === "string" ? options.model :
|
|
70
|
+
typeof options.model === "string" ? options.model : backend === "claude-cli" ? DEFAULT_MODEL : undefined;
|
|
69
71
|
const noPublic = options["no-public"] === true;
|
|
70
72
|
// --brief red-team briefs the agent with prompts/agent-red-team.md (pass the
|
|
71
73
|
// public suite with the least general code; bin/red-team-audit.mjs), and its
|
package/bin/runners/boundary.mjs
CHANGED
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
// JSON); the service's output is kept in memory, never written into the
|
|
40
40
|
// tree, and its tail goes to stderr only when it did not become ready.
|
|
41
41
|
// Exit code: the suite command's.
|
|
42
|
+
//
|
|
43
|
+
// On Windows a shell string runs through cmd.exe, the group is the
|
|
44
|
+
// tether's process tree (`taskkill /T`), and there is no resident-memory
|
|
45
|
+
// ceiling, only the heap cap.
|
|
42
46
|
|
|
43
47
|
import { spawn, spawnSync } from "node:child_process";
|
|
44
48
|
import { readFileSync } from "node:fs";
|
|
@@ -59,9 +63,16 @@ const HEAP_MB = Number(process.env.INDEPENDENT_TESTS_TEST_HEAP_MB ?? 512);
|
|
|
59
63
|
const MAX_RSS_MB = Number(process.env.INDEPENDENT_TESTS_TEST_MAX_RSS_MB ?? 1024);
|
|
60
64
|
const LIFETIME_MS = Number(process.env.INDEPENDENT_TESTS_SERVICE_MINUTES ?? 30) * 60_000;
|
|
61
65
|
const OUTPUT_CHARS = 4_000;
|
|
66
|
+
const WINDOWS = process.platform === "win32";
|
|
62
67
|
|
|
63
|
-
/** An argv array as given, a string through `sh -c
|
|
64
|
-
const argvOf = (command) =>
|
|
68
|
+
/** An argv array as given, a string through `sh -c` (cmd.exe on Windows). */
|
|
69
|
+
const argvOf = (command) =>
|
|
70
|
+
Array.isArray(command) ? command : WINDOWS ? [process.env.ComSpec || "cmd.exe", "/d", "/s", "/c", command] : ["sh", "-c", command];
|
|
71
|
+
/** Kills `pid`'s group (its tree on Windows). */
|
|
72
|
+
const killGroup = (pid) => {
|
|
73
|
+
if (WINDOWS) spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
74
|
+
else process.kill(-pid, "SIGKILL");
|
|
75
|
+
};
|
|
65
76
|
|
|
66
77
|
// The tether: leader of its own group, the service a child in it. Its
|
|
67
78
|
// argv[1] is the service's argv as JSON; its stdin is a pipe from this
|
|
@@ -71,19 +82,23 @@ const { spawn, spawnSync } = require("node:child_process");
|
|
|
71
82
|
const [bin, ...argv] = JSON.parse(process.argv[1]);
|
|
72
83
|
const maxMb = Number(process.argv[2]);
|
|
73
84
|
const lifetime = Number(process.argv[3]);
|
|
85
|
+
const windows = process.platform === "win32";
|
|
74
86
|
const down = (why) => {
|
|
75
87
|
if (why) process.stderr.write("\\n@@boundary " + why + "\\n");
|
|
76
|
-
try {
|
|
88
|
+
try {
|
|
89
|
+
if (windows) spawnSync("taskkill", ["/pid", String(process.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
90
|
+
else process.kill(-process.pid, "SIGKILL");
|
|
91
|
+
} catch {}
|
|
77
92
|
process.exit(137);
|
|
78
93
|
};
|
|
79
|
-
const child = spawn(bin, argv, { stdio: ["ignore", "inherit", "inherit"] });
|
|
94
|
+
const child = spawn(bin, argv, { stdio: ["ignore", "inherit", "inherit"], windowsHide: true });
|
|
80
95
|
child.on("error", (error) => down("service did not start: " + error.message));
|
|
81
96
|
child.on("exit", (code, signal) => down("service exited " + (signal ?? code)));
|
|
82
97
|
process.stdin.on("end", () => down(null));
|
|
83
98
|
process.stdin.on("close", () => down(null));
|
|
84
99
|
process.stdin.resume();
|
|
85
100
|
setTimeout(() => down("service killed after its time limit"), lifetime);
|
|
86
|
-
setInterval(() => {
|
|
101
|
+
if (!windows) setInterval(() => {
|
|
87
102
|
const pids = (spawnSync("pgrep", ["-g", String(process.pid)], { encoding: "utf8" }).stdout || "").split("\\n").filter(Boolean);
|
|
88
103
|
if (pids.length === 0) return;
|
|
89
104
|
const rss = (spawnSync("ps", ["-o", "rss=", "-p", pids.join(",")], { encoding: "utf8" }).stdout || "").split(/\\s+/).filter(Boolean);
|
|
@@ -98,7 +113,7 @@ let inner = null;
|
|
|
98
113
|
const takeDown = () => {
|
|
99
114
|
if (tether === null) return;
|
|
100
115
|
try {
|
|
101
|
-
|
|
116
|
+
killGroup(tether.pid);
|
|
102
117
|
} catch {}
|
|
103
118
|
tether = null;
|
|
104
119
|
};
|
|
@@ -162,7 +177,8 @@ if (config.service !== undefined) {
|
|
|
162
177
|
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --max-old-space-size=${HEAP_MB}`.trim(),
|
|
163
178
|
};
|
|
164
179
|
tether = spawn(process.execPath, ["-e", TETHER, JSON.stringify(argvOf(start)), String(MAX_RSS_MB), String(LIFETIME_MS)], {
|
|
165
|
-
detached:
|
|
180
|
+
detached: !WINDOWS,
|
|
181
|
+
windowsHide: true,
|
|
166
182
|
env: serviceEnv,
|
|
167
183
|
stdio: ["pipe", "pipe", "pipe"],
|
|
168
184
|
});
|
|
@@ -189,7 +205,10 @@ if (config.service !== undefined) {
|
|
|
189
205
|
}
|
|
190
206
|
}
|
|
191
207
|
|
|
192
|
-
|
|
208
|
+
// On Windows a bare name (npm, npx) is often a .cmd shim, which only cmd.exe runs.
|
|
209
|
+
inner = WINDOWS && !/[\\/]|\.exe$/i.test(bin)
|
|
210
|
+
? spawn([bin, ...args].map((arg) => (/^[\w@.:/\\=,+-]+$/.test(arg) ? arg : `"${arg.replaceAll('"', '""')}"`)).join(" "), { env, stdio: ["ignore", "inherit", "inherit"], shell: true, windowsHide: true })
|
|
211
|
+
: spawn(bin, args, { env, stdio: ["ignore", "inherit", "inherit"] });
|
|
193
212
|
const code = await new Promise((resolve) => {
|
|
194
213
|
inner.on("error", (error) => {
|
|
195
214
|
console.error(`boundary: ${bin}: ${error.message}`);
|
package/bin/runners/guard.mjs
CHANGED
|
@@ -16,22 +16,24 @@
|
|
|
16
16
|
// Input on stdin: { bin, argv, timeoutMs, maxRssMb }. Output: the child's
|
|
17
17
|
// stdout and stderr, then one stderr line `@@guard {"reason", "peakMb"}`;
|
|
18
18
|
// exit code the child's, or 137 when the guard killed it.
|
|
19
|
+
//
|
|
20
|
+
// On Windows the time limit kills the process tree (`taskkill /T`); there
|
|
21
|
+
// is no resident-memory ceiling there, only the heap cap the callers set.
|
|
19
22
|
|
|
20
23
|
import { spawn, spawnSync } from "node:child_process";
|
|
21
24
|
import { readFileSync } from "node:fs";
|
|
25
|
+
import { GROUP, WINDOWS, commandOf, killTree } from "../platform.mjs";
|
|
22
26
|
|
|
23
27
|
const { bin, argv, timeoutMs, maxRssMb = 1024 } = JSON.parse(readFileSync(0, "utf8"));
|
|
24
|
-
const
|
|
28
|
+
const [file, args, options] = commandOf(bin, argv);
|
|
29
|
+
const child = spawn(file, args, { stdio: ["ignore", "pipe", "pipe"], ...GROUP, ...options });
|
|
25
30
|
child.stdout.pipe(process.stdout);
|
|
26
31
|
child.stderr.pipe(process.stderr);
|
|
27
32
|
let reason = null;
|
|
28
33
|
let peakMb = 0;
|
|
29
|
-
const killGroup = () =>
|
|
30
|
-
try {
|
|
31
|
-
process.kill(-child.pid, "SIGKILL");
|
|
32
|
-
} catch {}
|
|
33
|
-
};
|
|
34
|
+
const killGroup = () => killTree(child.pid);
|
|
34
35
|
const groupRssMb = () => {
|
|
36
|
+
if (WINDOWS) return 0;
|
|
35
37
|
const pids = (spawnSync("pgrep", ["-g", String(child.pid)], { encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean);
|
|
36
38
|
if (pids.length === 0) return 0;
|
|
37
39
|
const rss = (spawnSync("ps", ["-o", "rss=", "-p", pids.join(",")], { encoding: "utf8" }).stdout ?? "").split(/\s+/).filter(Boolean);
|
package/bin/runners/pytest.mjs
CHANGED
|
@@ -27,13 +27,14 @@ import { tmpdir } from "node:os";
|
|
|
27
27
|
import { delimiter, dirname, isAbsolute, join, relative } from "node:path";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
29
|
import { OUTPUT_CHARS, build, config, guardedSync, print } from "./shared.mjs";
|
|
30
|
+
import { DEFAULT_PYTHON } from "../platform.mjs";
|
|
30
31
|
|
|
31
32
|
export const REPORTER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "template-python", "sandbox");
|
|
32
33
|
|
|
33
34
|
const {
|
|
34
35
|
suite,
|
|
35
36
|
files,
|
|
36
|
-
python: pythonOption =
|
|
37
|
+
python: pythonOption = DEFAULT_PYTHON,
|
|
37
38
|
build: command = null,
|
|
38
39
|
args: extra = [],
|
|
39
40
|
perFile = true,
|
|
@@ -43,7 +44,7 @@ const {
|
|
|
43
44
|
pythonPath = [],
|
|
44
45
|
} = config("node bin/runners/pytest.mjs '<json>'");
|
|
45
46
|
const root = realpathSync(process.cwd());
|
|
46
|
-
const python = pythonOption
|
|
47
|
+
const python = /[\\/]/.test(pythonOption) && !isAbsolute(pythonOption) ? join(root, pythonOption) : pythonOption;
|
|
47
48
|
const env = {
|
|
48
49
|
...process.env,
|
|
49
50
|
PYTHONDONTWRITEBYTECODE: "1",
|
package/bin/runners/shared.mjs
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import { existsSync, realpathSync } from "node:fs";
|
|
13
|
-
import {
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { prependPath, shellSync, slash, spawnCommand } from "../platform.mjs";
|
|
15
16
|
|
|
16
17
|
export const OUTPUT_CHARS = 4_000;
|
|
17
18
|
|
|
@@ -87,18 +88,19 @@ export function withBins(cwd) {
|
|
|
87
88
|
if (existsSync(join(at, "node_modules", ".bin"))) bins.push(join(at, "node_modules", ".bin"));
|
|
88
89
|
if (dirname(at) === at) break;
|
|
89
90
|
}
|
|
90
|
-
return
|
|
91
|
+
return prependPath(process.env, bins);
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/**
|
|
94
|
-
* Runs the build: an argv array as given, a string through
|
|
95
|
-
*
|
|
95
|
+
* Runs the build: an argv array as given, a string through the OS shell
|
|
96
|
+
* (sh, or cmd.exe on Windows, as npm runs scripts) with the repository's
|
|
97
|
+
* binaries on PATH, or nothing when it is null.
|
|
96
98
|
*/
|
|
97
99
|
export function build(command, cwd) {
|
|
98
100
|
if (command === null || command === undefined) return { compiled: true, compileOutput: "" };
|
|
99
101
|
const run = Array.isArray(command)
|
|
100
|
-
?
|
|
101
|
-
:
|
|
102
|
+
? spawnCommand(command[0], command.slice(1), { cwd, encoding: "utf8" })
|
|
103
|
+
: shellSync(command, { cwd, encoding: "utf8", env: withBins(cwd) });
|
|
102
104
|
const compiled = run.status === 0;
|
|
103
105
|
return {
|
|
104
106
|
compiled,
|
|
@@ -113,7 +115,8 @@ export function build(command, cwd) {
|
|
|
113
115
|
* ids in every staged tree, and the baseline would read them as regressions.
|
|
114
116
|
*/
|
|
115
117
|
export function print(result) {
|
|
116
|
-
const roots = [...new Set([process.cwd(), realpathSync(process.cwd())].flatMap((path) => [path, path.replace(/^\/private\//, "/")]))].sort((a, b) => b.length - a.length);
|
|
118
|
+
const roots = [...new Set([process.cwd(), realpathSync(process.cwd())].flatMap((path) => [path, path.replace(/^\/private\//, "/"), slash(path)]))].sort((a, b) => b.length - a.length);
|
|
119
|
+
|
|
117
120
|
for (const file of result.files)
|
|
118
121
|
for (const test of file.tests) for (const root of roots) test.name = test.name.replaceAll(root, "<root>");
|
|
119
122
|
const tests = result.files.flatMap((file) => file.tests);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "independent-tests",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Hidden, calibrated test suites for AI coding agents: a public suite the agent works against and a hidden suite it never sees that judges the result.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"hidden-tests",
|
|
9
9
|
"test-generation",
|
|
10
10
|
"claude-code",
|
|
11
|
+
"codex",
|
|
12
|
+
"cursor",
|
|
11
13
|
"evaluation",
|
|
12
14
|
"acceptance-tests",
|
|
13
15
|
"typescript",
|
|
@@ -46,11 +48,10 @@
|
|
|
46
48
|
"RESULTS.md",
|
|
47
49
|
"SKILL.md"
|
|
48
50
|
],
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
+
"scripts": {
|
|
52
|
+
"test": "node test/smoke.mjs"
|
|
51
53
|
},
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
]
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": "^20.19.0 || >=22.0.0"
|
|
56
|
+
}
|
|
56
57
|
}
|
package/prompts/agent.md
CHANGED
|
@@ -10,7 +10,7 @@ Implement the specification in `spec.md`.
|
|
|
10
10
|
`spec.md`, add at least one case whose inputs differ from every public
|
|
11
11
|
test and from every example the spec writes down: other values, other
|
|
12
12
|
sizes, other orders, other combinations. After `npm run build`,
|
|
13
|
-
`node
|
|
13
|
+
`node sandbox/test.mjs tests/own` runs them.
|
|
14
14
|
- Everything else (`tests/public/`, `sandbox/`, `package.json`,
|
|
15
15
|
`tsconfig.json`, the lockfile) is harness, and a submission that changes
|
|
16
16
|
any of it is rejected.
|
package/prompts/exec/agent.md
CHANGED
|
@@ -13,7 +13,7 @@ Implement the specification in `spec.md`: a command, `node src/cli.js`.
|
|
|
13
13
|
each scenario in `spec.md`, add at least one case whose inputs differ from
|
|
14
14
|
every public test and from every example the spec writes down: other
|
|
15
15
|
values, other sizes, other orders, other combinations. After
|
|
16
|
-
`npm run build`, `node
|
|
16
|
+
`npm run build`, `node sandbox/test.mjs tests/own`
|
|
17
17
|
runs them.
|
|
18
18
|
- Everything else (`tests/public/`, `sandbox/`, `sandbox.env`,
|
|
19
19
|
`package.json`, `tsconfig.json`, the lockfile) is harness, and a submission
|
package/prompts/http/agent.md
CHANGED
|
@@ -16,7 +16,7 @@ Implement the specification in `spec.md`: an HTTP service.
|
|
|
16
16
|
callback parameter you take from them). For each scenario in `spec.md`, add
|
|
17
17
|
at least one case whose inputs differ from every public test and from every
|
|
18
18
|
example the spec writes down: other values, other sizes, other orders,
|
|
19
|
-
other combinations. `node sandbox/boundary.mjs -- node
|
|
19
|
+
other combinations. `node sandbox/boundary.mjs -- node sandbox/test.mjs tests/own`
|
|
20
20
|
runs them.
|
|
21
21
|
- Everything else (`tests/public/`, `sandbox/`, `sandbox.env`,
|
|
22
22
|
`package.json`, `tsconfig.json`, the lockfile) is harness, and a submission
|
package/template/package.json
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
|
-
"node": ">=22.
|
|
7
|
+
"node": "^20.19.0 || >=22.0.0"
|
|
8
8
|
},
|
|
9
9
|
"packageManager": "npm@10.9.2",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc -p tsconfig.json",
|
|
12
|
-
"test": "npm run build && node
|
|
12
|
+
"test": "npm run build && node sandbox/test.mjs"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"typescript": "5.9.3"
|
package/template/sandbox/run.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { existsSync, readdirSync } from "node:fs";
|
|
12
|
-
import { join, relative } from "node:path";
|
|
12
|
+
import { join, relative, sep } from "node:path";
|
|
13
13
|
|
|
14
14
|
const SUITES = { public: "tests/public", private: "tests/private" };
|
|
15
15
|
const FILE_TIMEOUT_MS = 120_000;
|
|
@@ -71,7 +71,8 @@ const compiled = compile.status === 0;
|
|
|
71
71
|
const files = listTests(dir)
|
|
72
72
|
.sort()
|
|
73
73
|
.map((source) => {
|
|
74
|
-
|
|
74
|
+
// "/" on every OS: test ids are `file :: name`.
|
|
75
|
+
const file = relative(root, source).split(sep).join("/");
|
|
75
76
|
const runtime = file.replace(/\.tsx?$/, ".js");
|
|
76
77
|
if (!compiled) return { file, crashed: true, timedOut: false, tests: [] };
|
|
77
78
|
const run = spawnSync(
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// What `npm test` runs after the build: node:test over every compiled
|
|
2
|
+
// tests/public/**/*.test.js, with sandbox.env loaded, its output as
|
|
3
|
+
// node:test prints it. The files are listed here rather than globbed on
|
|
4
|
+
// the command line: Node 20 takes no glob there, and Windows' shell
|
|
5
|
+
// expands none.
|
|
6
|
+
//
|
|
7
|
+
// node sandbox/test.mjs [folder] default tests/public
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const folder = process.argv[2] ?? join("tests", "public");
|
|
14
|
+
|
|
15
|
+
function listTests(at) {
|
|
16
|
+
if (!existsSync(at)) return [];
|
|
17
|
+
return readdirSync(at, { withFileTypes: true }).flatMap((entry) => {
|
|
18
|
+
const path = join(at, entry.name);
|
|
19
|
+
if (entry.isDirectory()) return listTests(path);
|
|
20
|
+
return entry.name.endsWith(".test.js") ? [path] : [];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const files = listTests(folder).sort();
|
|
25
|
+
if (files.length === 0) {
|
|
26
|
+
console.error(`No compiled tests under ${folder}/ (run the build first).`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const run = spawnSync(process.execPath, ["--env-file=sandbox.env", "--test", ...files], { stdio: "inherit" });
|
|
30
|
+
process.exitCode = run.status ?? 1;
|
|
@@ -12,7 +12,8 @@ part of a submission.
|
|
|
12
12
|
one, present only where the grader stages it; `tests/own/` the agent's.
|
|
13
13
|
- `python3 sandbox/run.py public` runs a suite and prints JSON;
|
|
14
14
|
`python3 sandbox/compile_check.py` parses everything;
|
|
15
|
-
`.venv/bin/python -m pytest tests/own`
|
|
15
|
+
`.venv/bin/python -m pytest tests/own` (`.venv/Scripts/python` on
|
|
16
|
+
Windows) runs pytest directly.
|
|
16
17
|
- `sandbox/independent_tests_reporter.py` is the pytest plugin both
|
|
17
18
|
`sandbox/run.py` and `bin/runners/pytest.mjs` read results from. A test
|
|
18
19
|
named `test_<kind>_<n>__...` is keyed to that spec scenario.
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
#
|
|
5
5
|
# python3 sandbox/run.py public|private|own
|
|
6
6
|
#
|
|
7
|
-
# Any python3 starts it; the suite runs on the project's .venv/bin/python
|
|
7
|
+
# Any python3 starts it; the suite runs on the project's .venv/bin/python
|
|
8
|
+
# (.venv/Scripts/python.exe on Windows),
|
|
8
9
|
# which holds pytest. The grader runs the same suites through
|
|
9
10
|
# bin/runners/pytest.mjs, which this file cannot reach; the two must agree
|
|
10
11
|
# on the shape. A file that fails before any test reports is `crashed`: it
|
|
@@ -27,7 +28,8 @@ if suite not in SUITES:
|
|
|
27
28
|
print("usage: python3 sandbox/run.py public|private|own", file=sys.stderr)
|
|
28
29
|
sys.exit(2)
|
|
29
30
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
30
|
-
|
|
31
|
+
# Windows keeps a venv's interpreter in Scripts/.
|
|
32
|
+
PYTHON = os.path.join(ROOT, ".venv", "Scripts", "python.exe") if os.name == "nt" else os.path.join(ROOT, ".venv", "bin", "python")
|
|
31
33
|
if not os.path.exists(PYTHON):
|
|
32
34
|
PYTHON = sys.executable
|
|
33
35
|
directory = os.path.join(ROOT, SUITES[suite])
|
|
@@ -47,7 +49,7 @@ def list_tests(at):
|
|
|
47
49
|
dirs[:] = [d for d in dirs if d != "__pycache__"]
|
|
48
50
|
for name in files:
|
|
49
51
|
if name.startswith("test_") and name.endswith(".py"):
|
|
50
|
-
found.append(os.path.relpath(os.path.join(base, name), ROOT))
|
|
52
|
+
found.append(os.path.relpath(os.path.join(base, name), ROOT).replace(os.sep, "/"))
|
|
51
53
|
return sorted(found)
|
|
52
54
|
|
|
53
55
|
|