// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and // other Node contributors). See LICENSE-node. // node:internal/test_runner/runner.js: run(). Every option validates exactly like // Node's (same ERR_* codes and messages) regardless of what this kernel can actually // do with it; only after every option has validated does this decide whether it can // run at all. // // run({ isolation: "process" }, the default) refuses (DESIGN.md §Refused, not faked): // Node spawns `node --test-reporter= --test-reporter-destination=...` child // processes per test file and reparses their stdout as a stream of v8.Serializer // frames (FileTest#parseMessage/#processRawBuffer, this file's biggest chunk — all of // it dropped here). This kernel has no CLI flag parser (no --test-reporter, no way to // tell a child "report through this one built-in reporter") and no v8.Serializer. It // refuses once validation finishes, not before: a malformed `options.files` still // throws ERR_INVALID_ARG_TYPE, never the refusal, matching Node's own order (Node // would get just as far before isolation ever comes into play — isolation only // decides which branch executes the already-validated options). // // run({ watch: true }) and run({ coverage: true }) refuse the same way: watch needs a // FilesWatcher over fs.watch's recursive/rename semantics (not implemented — DESIGN.md // §fs), coverage needs the inspector (refused — DESIGN.md §Refused). // // run({ isolation: "none" }) loads every file in THIS process and is fully ported: // globPatterns/the default test-file glob, `files`, cwd, shard, setup(), concurrency/ // timeout/signal (all three flow straight into the root Test's own options, which // already validates them), and the exact file-load/placeholder-test protocol Node's // runFiles() closure for isolation:'none' uses (confirmed against real Node 24.16.0 — // see the three cases below). `only`/testNamePatterns/testSkipPatterns validate and // convert exactly like Node's, but (confirmed against real Node 24.16.0, see the // commands this header's companion report cites) **do not actually filter anything** // for isolation:'none' — that's not a shortcut here, it's Node's own real behavior: // runner.js's run() never copies these three options into the `globalOptions` object // isolation:'none' builds its root from (they're consumed only by getRunArgs(), which // builds an isolation:'process' child's `--test-only`/`--test-name-pattern=`/ // `--test-skip-pattern=` argv). harness.ts's createTestTree/test.ts's // willBeFilteredByName exist and work (a future CLI parser's --test-name-pattern would // reach them through commandLineDefaults()); run()'s own options just never reach that // path, on purpose, to match. import { invalidArgType, invalidArgValue, outOfRange } from "../errors"; import { unsupported } from "../refuse"; import type { GuestProcess } from "../process"; import { createTestTree, startSubtestAfterBootstrap } from "./harness"; import { Test } from "./test"; import { TestsStream } from "./tests_stream"; import { commandLineDefaults, convertStringToRegExp, kDefaultPattern, type GlobalTestOptions } from "./utils"; /** What runner.ts needs from outside test_runner/: the real guest process (cwd, * stderr, exit — for the "no files matched a literal pattern" case), a glob search * (builtins.ts wires this to fs.ts's globSync), and a way to load one file as a * module in this process (builtins.ts wires this to the same * `globalThis[Symbol.for("nib.runtime")].importFrom` hook a guest's own dynamic * `import()` is rewritten to call — see src/kernel/transform.ts — so a loaded test * file resolves exactly like a real ESM or CJS import, CJS and ESM alike, with no * separate code path here). */ export interface RunnerDeps { proc: GuestProcess; globSync: (patterns: string[], cwd: string) => string[]; importFile: (cwd: string, absolutePath: string) => Promise; } // ---- Node's internal/validators.js, kept local to this module (the established // pattern here — see histogram.ts, readline/promises.ts, test.ts's own copy): no // shared validateObject/validateBoolean/validateArray/validateFunction/validateOneOf/ // validateInteger/validateStringArray/validatePath exists in src/runtime. ---- function validateObject(value: unknown, name: string): void { if (value === null || Array.isArray(value) || typeof value !== "object") throw invalidArgType(name, "of type object", value); } function validateBoolean(value: unknown, name: string): void { if (typeof value !== "boolean") throw invalidArgType(name, "of type boolean", value); } function validateArray(value: unknown, name: string): asserts value is unknown[] { if (!Array.isArray(value)) throw invalidArgType(name, "an instance of Array", value); } function validateFunction(value: unknown, name: string): void { if (typeof value !== "function") throw invalidArgType(name, "of type function", value); } function validateOneOf(value: T, name: string, oneOf: readonly T[]): void { if (!oneOf.includes(value)) { const allowed = oneOf.map((v) => (typeof v === "string" ? `'${v}'` : String(v))).join(", "); throw invalidArgValue(name, value, `must be one of: ${allowed}`); } } function validateInteger(value: unknown, name: string, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER): void { if (typeof value !== "number") throw invalidArgType(name, "of type number", value); if (!Number.isInteger(value)) throw outOfRange(name, "an integer", value); if (value < min || value > max) throw outOfRange(name, `>= ${min} && <= ${max}`, value); } function validateUint32(value: unknown, name: string): void { if (typeof value !== "number") throw invalidArgType(name, "of type number", value); if (!Number.isInteger(value)) throw outOfRange(name, "an integer", value); if (value < 0 || value > 4_294_967_295) throw outOfRange(name, ">= 0 && <= 4294967295", value); } function validateString(value: unknown, name: string): void { if (typeof value !== "string") throw invalidArgType(name, "of type string", value); } function validateStringArray(value: unknown, name: string): void { validateArray(value, name); value.forEach((v, i) => { if (typeof v !== "string") throw invalidArgType(`${name}[${i}]`, "of type string", v); }); } /** Node's internal/fs/utils.js validatePath, trimmed to the string half (a run() * option is never a Buffer/URL here in practice; the type-check message still names * all three, matching Node). */ function validatePath(value: unknown, name: string): void { if (typeof value !== "string" && !(value instanceof Uint8Array)) { throw invalidArgType(name, "of type string or an instance of Buffer or URL", value); } if (typeof value === "string" && value.includes("\u0000")) { throw invalidArgValue(name, value, "must be a string, Uint8Array, or URL without null bytes"); } } /** A pattern with no glob metachar anywhere (Node's Glob#hasMagic): createTestFileList * treats that as a literal filename and exits the process when it finds nothing, * instead of silently running zero files. */ const GLOB_MAGIC = /[*?{}[\]]/; function normalizePatterns(value: unknown, name: string): RegExp[] | null { if (value == null) return null; const arr = Array.isArray(value) ? value : [value]; return arr.map((v, i) => { if (v instanceof RegExp) return v; if (typeof v === "string") return convertStringToRegExp(v, `${name}[${i}]`); throw invalidArgType(`${name}[${i}]`, "of type string or an instance of RegExp", v); }); } function normalizeStringArrayOption(value: unknown, name: string): void { if (value == null) return; validateStringArray(Array.isArray(value) ? value : [value], name); } /** Resolve a (possibly relative) test-file path against cwd, lexically — no fs access, * matching path.resolve's semantics for an already-POSIX, already-simple path (this * kernel's fs is POSIX-only; see DESIGN.md §path.win32). */ function resolveAbs(cwd: string, p: string): string { const base = p.startsWith("/") ? p : `${cwd}/${p}`; const out: string[] = []; for (const seg of base.split("/")) { if (seg === "" || seg === ".") continue; if (seg === "..") out.pop(); else out.push(seg); } return `/${out.join("/")}`; } interface Shard { index: number; total: number; } /** Node's createTestFileList: `files` wins outright; otherwise glob `globPatterns` * (default kDefaultPattern) under cwd, excluding node_modules, sorted. A user-supplied * pattern list with zero magic chars and zero matches is Node's "you misspelled a * literal filename" guard — it prints and exits the process rather than running * nothing (confirmed against real Node 24.16.0: `run({ globPatterns: ['nope.js'] })` * prints `Could not find 'nope.js'` to stderr and the process exits 1, never emitting * any test event at all). */ function resolveTestFiles(files: string[] | undefined, globPatterns: string[] | undefined, cwd: string, deps: RunnerDeps, shard: Shard | null): string[] { let testFiles: string[]; if (files != null) { testFiles = files; } else { const hasUserSuppliedPattern = globPatterns != null; const patterns = globPatterns && globPatterns.length > 0 ? globPatterns : [kDefaultPattern]; const results = deps.globSync(patterns, cwd); if (hasUserSuppliedPattern && results.length === 0 && patterns.every((p) => !GLOB_MAGIC.test(p))) { deps.proc.stderr.write(`Could not find '${patterns.join(", ")}'\n`); deps.proc.exit(1); } testFiles = [...results].sort(); } return shard ? testFiles.filter((_, index) => index % shard.total === shard.index - 1) : testFiles; } /** Node's run()'s isolation:'none' runFiles closure: import every test file in * declared order (sequential — `await cascadedLoader.import(...)` per file, never * concurrent), each attaching its own top-level test()/describe() calls to `root` * (harness.ts's currentParent() resolves to `root` because createTestTree just made * it the module's globalRoot). A file that registers zero top-level tests — whether * it threw while loading or just has none — gets a placeholder Test named after its * path; a throw fails that placeholder instead of aborting the whole run (confirmed * against real Node 24.16.0: a syntax/throw-at-load file reports as one failing test * named after the file, and every other file still loads and runs). The placeholder's * startSubtestAfterBootstrap() call is fire-and-forget, same as an ordinary top-level * `test()` call. * * Before any of that, this replaces `root.harness.bootstrapPromise` with one that * only resolves once every file below has loaded (`finishBootstrap()`, after the * loop) — Node's actual fix for the file-loading race, not a kernel-only workaround: * every top-level test(), here or in direct execution, is funneled through * harness.ts's startSubtestAfterBootstrap, which awaits this exact promise before * calling subtest.start(). So no top-level test from any file can start — let alone * finish and trip finalize()'s "every top-level test is done" check — until every * file has had its chance to register its own tests first. */ async function runIsolatedNone(root: Test, testFiles: string[], cwd: string, deps: RunnerDeps): Promise { let finishBootstrap!: () => void; const bootstrapSettled = new Promise((resolve) => (finishBootstrap = resolve)); const harness = root.harness!; harness.bootstrapPromise = harness.bootstrapPromise ? Promise.all([harness.bootstrapPromise, bootstrapSettled]).then(() => {}) : bootstrapSettled; let topLevelTestCount = 0; for (const testFile of testFiles) { const abs = resolveAbs(cwd, testFile); root.entryFile = abs; let threw = false; let importError: unknown; try { await deps.importFile(cwd, abs); } catch (err) { threw = true; importError = err; } if (topLevelTestCount === root.subtests.length) { const subtest = root.createSubtest(Test, testFile, {}, undefined, { loc: [1, 1, abs] }); if (threw) subtest.fail(importError); void startSubtestAfterBootstrap(subtest); } topLevelTestCount = root.subtests.length; } root.entryFile = undefined; finishBootstrap(); await root.processPendingSubtests(); } export function run(optionsArg: unknown, deps: RunnerDeps): TestsStream { // Node's run(options = kEmptyObject): a default parameter only substitutes on // `undefined`, never on `null` — `run(null)` must still fail validateObject(), // confirmed against real Node 24.16.0 (ERR_INVALID_ARG_TYPE, not treated as {}). const raw = optionsArg === undefined ? {} : optionsArg; validateObject(raw, "options"); const options = raw as Record; const testNamePatternsOpt = options.testNamePatterns; const testSkipPatternsOpt = options.testSkipPatterns; const shardOpt = options.shard as { index?: unknown; total?: unknown } | null | undefined; const coverageExcludeGlobs = options.coverageExcludeGlobs; const coverageIncludeGlobs = options.coverageIncludeGlobs; const concurrency = options.concurrency as number | boolean | undefined; const timeout = options.timeout as number | undefined; const signal = options.signal as AbortSignal | undefined; const files = options.files as string[] | undefined; const forceExit = options.forceExit as boolean | undefined; const isolation = (options.isolation as string | undefined) ?? "process"; const watch = options.watch as boolean | undefined; const setup = options.setup as ((reporter: TestsStream) => unknown) | undefined; const globalSetupPath = options.globalSetupPath; const only = options.only as boolean | undefined; const globPatterns = options.globPatterns as string[] | undefined; const coverage = (options.coverage as boolean | undefined) ?? false; const lineCoverage = (options.lineCoverage as number | undefined) ?? 0; const branchCoverage = (options.branchCoverage as number | undefined) ?? 0; const functionCoverage = (options.functionCoverage as number | undefined) ?? 0; const suppliedRandomize = options.randomize as boolean | undefined; const suppliedRandomSeed = options.randomSeed as number | undefined; const execArgv = (options.execArgv as string[] | undefined) ?? []; const argv = (options.argv as string[] | undefined) ?? []; const cwd = (options.cwd as string | undefined) ?? deps.proc.cwd(); const rerunFailuresFilePath = options.rerunFailuresFilePath; const env = options.env; if (files != null) validateArray(files, "options.files"); if (watch != null) validateBoolean(watch, "options.watch"); if (forceExit != null) { validateBoolean(forceExit, "options.forceExit"); if (forceExit && watch) throw invalidArgValue("options.forceExit", watch, "is not supported with watch mode"); } if (only != null) validateBoolean(only, "options.only"); if (globPatterns != null) validateArray(globPatterns, "options.globPatterns"); if (suppliedRandomize != null) validateBoolean(suppliedRandomize, "options.randomize"); if (suppliedRandomSeed != null) validateUint32(suppliedRandomSeed, "options.randomSeed"); let randomize = suppliedRandomize; const randomSeed = suppliedRandomSeed; if (randomSeed != null) randomize = true; if (watch) { if (randomSeed != null) throw invalidArgValue("options.randomSeed", randomSeed, "is not supported with watch mode"); if (randomize) throw invalidArgValue("options.randomize", randomize, "is not supported with watch mode"); } if (rerunFailuresFilePath != null) { validatePath(rerunFailuresFilePath, "options.rerunFailuresFilePath"); if (randomSeed != null) throw invalidArgValue("options.randomSeed", randomSeed, "is not supported with rerun failures mode"); if (randomize) throw invalidArgValue("options.randomize", randomize, "is not supported with rerun failures mode"); } validateString(cwd, "options.cwd"); if ((globPatterns?.length ?? 0) > 0 && (files?.length ?? 0) > 0) { throw invalidArgValue("options.globPatterns", globPatterns, "is not supported when specifying 'options.files'"); } let shard: Shard | null = null; if (shardOpt != null) { validateObject(shardOpt, "options.shard"); shard = { index: shardOpt.index as number, total: shardOpt.total as number }; validateInteger(shard.total, "options.shard.total", 1); validateInteger(shard.index, "options.shard.index", 1, shard.total); if (watch) throw invalidArgValue("options.shard", watch, "shards not supported with watch mode"); } if (setup != null) validateFunction(setup, "options.setup"); // Validated and converted for error-parity with Node; not wired into harness.config // for isolation:'none' — see this file's header comment. normalizePatterns(testNamePatternsOpt, "options.testNamePatterns"); normalizePatterns(testSkipPatternsOpt, "options.testSkipPatterns"); validateOneOf(isolation, "options.isolation", ["process", "none"]); validateBoolean(coverage, "options.coverage"); normalizeStringArrayOption(coverageExcludeGlobs, "options.coverageExcludeGlobs"); normalizeStringArrayOption(coverageIncludeGlobs, "options.coverageIncludeGlobs"); validateInteger(lineCoverage, "options.lineCoverage", 0, 100); validateInteger(branchCoverage, "options.branchCoverage", 0, 100); validateInteger(functionCoverage, "options.functionCoverage", 0, 100); validateStringArray(argv, "options.argv"); validateStringArray(execArgv, "options.execArgv"); if (globalSetupPath != null) validatePath(globalSetupPath, "options.globalSetupPath"); if (env != null) { // Node calls validateObject(env) with no `name` here — an internal-errors.js bug // (ERR_INTERNAL_ASSERTION 'name' must be a string, confirmed against real Node // 24.16.0 for any non-object `env`) rather than real behavior worth reproducing. // This gives the useful ERR_INVALID_ARG_TYPE instead. validateObject(env, "options.env"); if (isolation === "none") throw invalidArgValue("options.env", env, "is not supported with isolation='none'"); } // ---- kernel-specific refusals (DESIGN.md §Refused, not faked) — only after every // option above has validated, matching how far a real call would get in Node too. ---- if (isolation !== "none") { throw unsupported( "test", "run() with isolation: 'process' (the default)", "spawns `node --test-reporter=...` child processes and reads back the v8-serializer reporter's bytes; " + "this kernel has no CLI flag parser and no v8.Serializer. Pass { isolation: 'none' } to run tests in this process", ); } if (coverage) { throw unsupported("test", "run({ coverage: true })", "code coverage needs the inspector, which this kernel does not expose"); } if (watch) { throw unsupported( "test", "run({ watch: true })", "file-watch test runs need a FilesWatcher over fs.watch's recursive/rename semantics, which this kernel does not implement", ); } const globalOptions: GlobalTestOptions = commandLineDefaults(cwd); const root = createTestTree({ concurrency, timeout, signal }, globalOptions, { autoReport: false }); const testFiles = resolveTestFiles(files, globPatterns, cwd, deps, shard); void (async () => { if (typeof setup === "function") await setup(root.reporter); await runIsolatedNone(root, testFiles, cwd, deps); })(); return root.reporter; }