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.
@@ -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`); its
65
- // variant is the backend's name unless --model names a model.
66
- const backend = typeof options.backend === "string" ? options.backend : undefined;
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 : (backend ?? "claude-cli") === "claude-cli" ? DEFAULT_MODEL : undefined;
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
@@ -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) => (Array.isArray(command) ? command : ["sh", "-c", 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 { process.kill(-process.pid, "SIGKILL"); } catch {}
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
- process.kill(-tether.pid, "SIGKILL");
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: true,
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
- inner = spawn(bin, args, { env, stdio: ["ignore", "inherit", "inherit"] });
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}`);
@@ -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 child = spawn(bin, argv, { stdio: ["ignore", "pipe", "pipe"], detached: true });
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);
@@ -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 = "python3",
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.includes("/") && !isAbsolute(pythonOption) ? join(root, pythonOption) : pythonOption;
47
+ const python = /[\\/]/.test(pythonOption) && !isAbsolute(pythonOption) ? join(root, pythonOption) : pythonOption;
47
48
  const env = {
48
49
  ...process.env,
49
50
  PYTHONDONTWRITEBYTECODE: "1",
@@ -10,8 +10,9 @@
10
10
 
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { existsSync, realpathSync } from "node:fs";
13
- import { delimiter, dirname, join } from "node:path";
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 { ...process.env, PATH: [...bins, process.env.PATH].join(delimiter) };
91
+ return prependPath(process.env, bins);
91
92
  }
92
93
 
93
94
  /**
94
- * Runs the build: an argv array as given, a string through `sh -c` with the
95
- * repository's binaries on PATH, or nothing when it is null.
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
- ? spawnSync(command[0], command.slice(1), { cwd, encoding: "utf8" })
101
- : spawnSync("sh", ["-c", command], { cwd, encoding: "utf8", env: withBins(cwd) });
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.1.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
- "engines": {
50
- "node": ">=22.12.0"
51
+ "scripts": {
52
+ "test": "node test/smoke.mjs"
51
53
  },
52
- "os": [
53
- "darwin",
54
- "linux"
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 --test "tests/own/**/*.test.js"` runs them.
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.
@@ -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 --env-file=sandbox.env --test "tests/own/**/*.test.js"`
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
@@ -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 --test "tests/own/**/*.test.js"`
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
@@ -11,7 +11,7 @@
11
11
  "typescript": "5.9.3"
12
12
  },
13
13
  "engines": {
14
- "node": ">=22.12.0"
14
+ "node": "^20.19.0 || >=22.0.0"
15
15
  }
16
16
  },
17
17
  "node_modules/typescript": {
@@ -4,12 +4,12 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
7
- "node": ">=22.12.0"
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 --env-file=sandbox.env --test \"tests/public/**/*.test.js\""
12
+ "test": "npm run build && node sandbox/test.mjs"
13
13
  },
14
14
  "devDependencies": {
15
15
  "typescript": "5.9.3"
@@ -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
- const file = relative(root, source);
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` runs pytest directly.
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
- PYTHON = os.path.join(ROOT, ".venv", "bin", "python")
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