From 8a3c0df92811e37c0b41b0f82cfcabb3bb84666a Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Mon, 2 Nov 2026 03:00:53 -0500 Subject: [PATCH] Port node:test and node:test/reporters A guest can now run a test file directly (`node file.test.js`) the way agents and package scripts use node:test. Ported from Node 24.16.0's lib/internal/test_runner: - Test, Suite and hooks, only/skip/todo, concurrency, timeout, signal, plan, t.assert, waitFor and expectFailure, with Node's option validation. - The harness: the bootstrap and build-phase gates, so async describe() builders settle before any test runs, and the exit code. - The spec, tap, dot, junit and lcov reporters, wired through compose() as Node does. - mock (fn, method, getter, setter, property), mock.timers and snapshots. - run({ isolation: "none" }), loading test files in this process. Refused: run() with process isolation (needs Node CLI flags and v8.Serializer), coverage (needs the inspector). mock.module is absent, as in Node without its flag. Without async context, a test registered after an await inside an async describe() lands under the root. Stream fixes found on the way: compose() no longer wraps stages that are already streams, Duplex.from(readable) no longer stalls after an empty read, push() gates 'readable' the way Node does, and pause()/resume() skip destroyed streams. setTimeout and setImmediate gained util.promisify.custom. Goldens recorded under Node 24.16.0, with an opt-in duration mask for the default reporter; full offline suite 613/613. --- packages/kernel/DESIGN.md | 14 + packages/kernel/compat/divergences.json | 2 - packages/kernel/scripts/golden-files.ts | 29 + packages/kernel/scripts/golden.mjs | 15 +- packages/kernel/src/runtime/builtins.ts | 39 + packages/kernel/src/runtime/errors.ts | 43 +- packages/kernel/src/runtime/main.ts | 8 + packages/kernel/src/runtime/process.ts | 12 +- packages/kernel/src/runtime/stream.ts | 219 ++- packages/kernel/src/runtime/test-reporters.ts | 20 + packages/kernel/src/runtime/test.ts | 90 ++ .../kernel/src/runtime/test_runner/assert.ts | 35 + .../kernel/src/runtime/test_runner/colors.ts | 32 + .../kernel/src/runtime/test_runner/harness.ts | 288 ++++ .../src/runtime/test_runner/mock/mock.ts | 409 ++++++ .../runtime/test_runner/mock/mock_timers.ts | 575 ++++++++ .../src/runtime/test_runner/reporter/dot.ts | 38 + .../src/runtime/test_runner/reporter/junit.ts | 124 ++ .../src/runtime/test_runner/reporter/lcov.ts | 69 + .../src/runtime/test_runner/reporter/spec.ts | 89 ++ .../src/runtime/test_runner/reporter/tap.ts | 198 +++ .../src/runtime/test_runner/reporter/utils.ts | 113 ++ .../kernel/src/runtime/test_runner/runner.ts | 374 +++++ .../src/runtime/test_runner/snapshot.ts | 256 ++++ .../src/runtime/test_runner/test-failure.ts | 22 + .../kernel/src/runtime/test_runner/test.ts | 1297 +++++++++++++++++ .../src/runtime/test_runner/tests_stream.ts | 115 ++ .../kernel/src/runtime/test_runner/utils.ts | 138 ++ packages/kernel/src/shared/builtin-ids.ts | 4 +- packages/kernel/src/shared/builtin-names.ts | 2 + packages/kernel/tests/builtins.spec.ts | 34 + .../guests/builtin-stream-compose-order.cjs | 30 + .../fixtures/guests/builtin-stream-ticks.cjs | 137 ++ .../guests/builtin-test-async-describe.cjs | 28 + .../fixtures/guests/builtin-test-core.cjs | 79 + .../fixtures/guests/builtin-test-esm.mjs | 17 + .../guests/builtin-test-globals-intact.cjs | 17 + .../fixtures/guests/builtin-test-mock.cjs | 185 +++ .../fixtures/guests/builtin-test-order.cjs | 16 + .../guests/builtin-test-reporters-dot.cjs | 20 + .../guests/builtin-test-reporters-junit.cjs | 33 + .../guests/builtin-test-reporters-lcov.cjs | 29 + .../guests/builtin-test-reporters-spec.cjs | 29 + .../guests/builtin-test-reporters-tap.cjs | 30 + .../guests/builtin-test-run-errors.cjs | 61 + .../fixtures/guests/builtin-test-run-esm.mjs | 30 + .../guests/builtin-test-run-files.cjs | 51 + .../fixtures/guests/builtin-test-run-glob.cjs | 18 + .../guests/builtin-test-run-nomatch.cjs | 11 + .../guests/builtin-test-run-order.cjs | 34 + .../guests/builtin-test-run-shard.cjs | 24 + .../guests/builtin-test-scheme-only.cjs | 12 + .../fixtures/guests/builtin-test-snapshot.cjs | 108 ++ .../fixtures/guests/builtin-test-timers.cjs | 155 ++ .../tests/fixtures/guests/run-dep-a.cjs | 7 + .../tests/fixtures/guests/run-dep-b.mjs | 5 + .../tests/fixtures/guests/run-dep-c.cjs | 2 + .../tests/fixtures/guests/run-dep-d.cjs | 2 + .../tests/fixtures/guests/run-dep-empty.cjs | 10 + .../tests/fixtures/guests/run-dep-throws.cjs | 3 + .../fixtures/guests/run-glob/widget.test.cjs | 4 + .../tests/fixtures/guests/run-order-dep-a.cjs | 16 + .../tests/fixtures/guests/run-order-dep-b.mjs | 7 + .../tests/fixtures/guests/run-order-dep-c.cjs | 10 + .../builtin-stream-compose-order.cjs.json | 22 + .../guests/builtin-stream-ticks.cjs.json | 74 + .../builtin-test-async-describe.cjs.json | 49 + .../goldens/guests/builtin-test-core.cjs.json | 55 + .../goldens/guests/builtin-test-esm.mjs.json | 22 + .../builtin-test-globals-intact.cjs.json | 27 + .../goldens/guests/builtin-test-mock.cjs.json | 82 ++ .../guests/builtin-test-order.cjs.json | 33 + .../builtin-test-reporters-dot.cjs.json | 18 + .../builtin-test-reporters-junit.cjs.json | 30 + .../builtin-test-reporters-lcov.cjs.json | 29 + .../builtin-test-reporters-spec.cjs.json | 28 + .../builtin-test-reporters-tap.cjs.json | 54 + .../guests/builtin-test-run-errors.cjs.json | 50 + .../guests/builtin-test-run-esm.mjs.json | 16 + .../guests/builtin-test-run-files.cjs.json | 30 + .../guests/builtin-test-run-glob.cjs.json | 15 + .../guests/builtin-test-run-nomatch.cjs.json | 15 + .../guests/builtin-test-run-order.cjs.json | 38 + .../guests/builtin-test-run-shard.cjs.json | 22 + .../guests/builtin-test-scheme-only.cjs.json | 13 + .../guests/builtin-test-snapshot.cjs.json | 45 + .../guests/builtin-test-timers.cjs.json | 65 + packages/kernel/tests/guest.ts | 13 +- 88 files changed, 6719 insertions(+), 49 deletions(-) create mode 100644 packages/kernel/src/runtime/test-reporters.ts create mode 100644 packages/kernel/src/runtime/test.ts create mode 100644 packages/kernel/src/runtime/test_runner/assert.ts create mode 100644 packages/kernel/src/runtime/test_runner/colors.ts create mode 100644 packages/kernel/src/runtime/test_runner/harness.ts create mode 100644 packages/kernel/src/runtime/test_runner/mock/mock.ts create mode 100644 packages/kernel/src/runtime/test_runner/mock/mock_timers.ts create mode 100644 packages/kernel/src/runtime/test_runner/reporter/dot.ts create mode 100644 packages/kernel/src/runtime/test_runner/reporter/junit.ts create mode 100644 packages/kernel/src/runtime/test_runner/reporter/lcov.ts create mode 100644 packages/kernel/src/runtime/test_runner/reporter/spec.ts create mode 100644 packages/kernel/src/runtime/test_runner/reporter/tap.ts create mode 100644 packages/kernel/src/runtime/test_runner/reporter/utils.ts create mode 100644 packages/kernel/src/runtime/test_runner/runner.ts create mode 100644 packages/kernel/src/runtime/test_runner/snapshot.ts create mode 100644 packages/kernel/src/runtime/test_runner/test-failure.ts create mode 100644 packages/kernel/src/runtime/test_runner/test.ts create mode 100644 packages/kernel/src/runtime/test_runner/tests_stream.ts create mode 100644 packages/kernel/src/runtime/test_runner/utils.ts create mode 100644 packages/kernel/tests/fixtures/guests/builtin-stream-compose-order.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-stream-ticks.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-async-describe.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-core.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-esm.mjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-globals-intact.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-mock.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-order.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-reporters-dot.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-reporters-junit.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-reporters-lcov.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-reporters-spec.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-reporters-tap.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-run-errors.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-run-esm.mjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-run-files.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-run-glob.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-run-nomatch.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-run-order.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-run-shard.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-scheme-only.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-snapshot.cjs create mode 100644 packages/kernel/tests/fixtures/guests/builtin-test-timers.cjs create mode 100644 packages/kernel/tests/fixtures/guests/run-dep-a.cjs create mode 100644 packages/kernel/tests/fixtures/guests/run-dep-b.mjs create mode 100644 packages/kernel/tests/fixtures/guests/run-dep-c.cjs create mode 100644 packages/kernel/tests/fixtures/guests/run-dep-d.cjs create mode 100644 packages/kernel/tests/fixtures/guests/run-dep-empty.cjs create mode 100644 packages/kernel/tests/fixtures/guests/run-dep-throws.cjs create mode 100644 packages/kernel/tests/fixtures/guests/run-glob/widget.test.cjs create mode 100644 packages/kernel/tests/fixtures/guests/run-order-dep-a.cjs create mode 100644 packages/kernel/tests/fixtures/guests/run-order-dep-b.mjs create mode 100644 packages/kernel/tests/fixtures/guests/run-order-dep-c.cjs create mode 100644 packages/kernel/tests/goldens/guests/builtin-stream-compose-order.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-stream-ticks.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-async-describe.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-core.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-esm.mjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-globals-intact.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-mock.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-order.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-reporters-dot.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-reporters-junit.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-reporters-lcov.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-reporters-spec.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-reporters-tap.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-run-errors.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-run-esm.mjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-run-files.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-run-glob.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-run-nomatch.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-run-order.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-run-shard.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-scheme-only.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-snapshot.cjs.json create mode 100644 packages/kernel/tests/goldens/guests/builtin-test-timers.cjs.json diff --git a/packages/kernel/DESIGN.md b/packages/kernel/DESIGN.md index 1f9dd67..4db01f5 100644 --- a/packages/kernel/DESIGN.md +++ b/packages/kernel/DESIGN.md @@ -652,6 +652,20 @@ Lessons from the ESM slice, recorded as invariants: `util.isDeepStrictEqual` and `async_hooks.createHook` load and throw `ERR_NIB_UNSUPPORTED` naming the module (`src/runtime/refuse.ts`). An unknown digest throws Node's own "Digest method not supported"; one Node has and this runtime lacks (sha384) refuses. + `node:test`'s `run()` is one function with a partial refusal, not a wholesale one: it + validates every option exactly like Node's regardless of what follows, and only after + that refuses `isolation: 'process'` (the default — Node spawns `node + --test-reporter=...` children and reads back the v8-serializer reporter's bytes; + this kernel has neither a CLI flag parser nor `v8.Serializer`), `coverage: true` + (needs the inspector) and `watch: true` (needs a `FilesWatcher` over `fs.watch`'s + recursive/rename semantics, not implemented). `run({ isolation: 'none' })` with + neither of the other two set actually runs, in this process — the function itself is + never `markRefused()` (`src/runtime/refuse.ts`), so the surface walker sees a plain + function, same as Node's, and `compat/divergences.json` carries no entry for it; the + three refusals are instead asserted directly against the kernel in + `tests/builtins.spec.ts`, since real Node does not throw for any of them (it spawns + children, enables coverage, or starts a watcher) and so none of the three can be + golden'd (`src/runtime/test_runner/runner.ts`). - **AsyncLocalStorage propagates only where it is told to.** A context is an immutable map from each store to its value; `run`/`exit` swap it for one synchronous call, and `AsyncResource` (`bind`, `runInAsyncScope`) and `AsyncLocalStorage.bind`/`snapshot` carry diff --git a/packages/kernel/compat/divergences.json b/packages/kernel/compat/divergences.json index 50a05dd..803bae9 100644 --- a/packages/kernel/compat/divergences.json +++ b/packages/kernel/compat/divergences.json @@ -218,8 +218,6 @@ "string_decoder.StringDecoder#lastTotal": "internal: decoder state, undocumented", "string_decoder.StringDecoder#text": "internal: undocumented", "sys": "deprecated: DEP0025, an alias of util", - "test": "todo: pure JS in Node; port from lib/ like readline", - "test/reporters": "todo: pure JS in Node; port from lib/ like readline", "tls.SecureContext": "refuse: there is no TLS: guest servers speak plain HTTP over the Service Worker bridge", "tls.Server": "accepted: refused, not faked (DESIGN.md): the class exists so imports link and instanceof is false, and nothing constructs it, so its members are moot", "tls.TLSSocket": "accepted: refused, not faked (DESIGN.md): the class exists so imports link and instanceof is false, and nothing constructs it, so its members are moot", diff --git a/packages/kernel/scripts/golden-files.ts b/packages/kernel/scripts/golden-files.ts index a0436ba..3cfc654 100644 --- a/packages/kernel/scripts/golden-files.ts +++ b/packages/kernel/scripts/golden-files.ts @@ -23,6 +23,35 @@ export interface GuestGolden { signal?: string; stdout: string; stderr: string; + /** Narrow, opt-in masking applied identically by scripts/golden.mjs (before writing) + * and tests/guest.ts's goldenTest (to the kernel's live output before comparing) — + * never applied globally. "test-durations" covers node:test's own non-determinism: + * `duration_ms ` (TAP/diagnostic fields) and `(ms)` (spec/dot + * reporter text) become `duration_ms N` / `(Nms)`, and stack frames inside Node's + * own internals (`node:internal/...`, `node:async_hooks`) are dropped — this + * runtime has no such frames to match, by construction. */ + mask?: "test-durations"; +} + +/** See GuestGolden#mask. node:test's own stack traces name the calling frame after + * how the function happens to be invoked (Node: "TestContext.", via + * runInAsyncScope; this runtime: "Test.eval [as fn]", via its own indirect-eval CJS + * wrapper — DESIGN.md's "CJS stack lines are the guest's own"), so a frame naming + * difference alone is not a real behavioral gap — only the file:line:col is. */ +export function maskTestDurations(text: string): string { + return text + .replace(/duration_ms -?[0-9]+(\.[0-9]+)?/g, "duration_ms N") + .replace(/\(-?[0-9]+(\.[0-9]+)?ms\)/g, "(Nms)") + .split("\n") + // Internal call frames neither side can match the other's: Node's own + // (node:internal/..., node:async_hooks) and this runtime's own bundle + // (.../dist/runtime/runtime.js, wherever that's served from). + .filter((line) => !/\bat .*\((?:node:(?:internal\/|async_hooks)|[^)]*\/dist\/runtime\/runtime\.js)/.test(line)) + // A remaining frame's function-name label (the part before the location) is a V8 + // calling-convention artifact of each runtime's own CJS wrapper; only the + // location (file:line:col) is a real assertion about where the error happened. + .map((line) => line.replace(/^(\s*at )[^(]+(\(.*\))$/, "$1 $2")) + .join("\n"); } /** An app's recorded HTTP answers. The `Exchange` shape is tests/http-golden.ts's. */ diff --git a/packages/kernel/scripts/golden.mjs b/packages/kernel/scripts/golden.mjs index eb6e171..7a47760 100644 --- a/packages/kernel/scripts/golden.mjs +++ b/packages/kernel/scripts/golden.mjs @@ -18,7 +18,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { goldenPath, KINDS, listGoldens, readGolden, writeGolden } from "./golden-files.ts"; +import { goldenPath, KINDS, listGoldens, maskTestDurations, readGolden, writeGolden } from "./golden-files.ts"; import { recordHttp } from "./golden-http.mjs"; import { recordWs } from "./golden-ws.mjs"; import { assertPinnedNode, oracleEnv } from "./pinned-node.mjs"; @@ -35,17 +35,22 @@ const version = spawnSync(node, ["-v"], { encoding: "utf8" }).stdout.trim(); /** Run a guest the way the kernel runs it: mounted at /app, with `stdin` piped then * closed. PIDs and the checkout path are masked. */ -function recordGuest({ guest, deps, stdin }) { +function recordGuest({ guest, deps, stdin, mask: maskKind }) { const before = new Set(readdirSync(GUESTS)); const r = spawnSync(node, [guest], { cwd: GUESTS, encoding: "utf8", input: stdin ?? "", env: oracleEnv() }); // Guests share this directory and some list it, so what one leaves changes another. const left = readdirSync(GUESTS).filter((n) => !before.has(n)); if (left.length) throw new Error(`${guest} left ${left.join(", ")} in tests/fixtures/guests; clean up at its end`); - const mask = (text) => text.replaceAll(GUESTS, "/app").replace(/\(node:\d+\)/g, "(node:PID)"); + const mask = (text) => { + let out = text.replaceAll(GUESTS, "/app").replace(/\(node:\d+\)/g, "(node:PID)"); + if (maskKind === "test-durations") out = maskTestDurations(out); + return out; + }; return { guest, ...(deps ? { deps } : {}), ...(stdin !== undefined ? { stdin } : {}), + ...(maskKind ? { mask: maskKind } : {}), node: version, code: r.status, ...(r.signal ? { signal: r.signal } : {}), @@ -58,7 +63,7 @@ function recordGuest({ guest, deps, stdin }) { // fields compared as text (guest streams) or as pretty JSON (structured results). const KIND = { guests: { - inputs: ["guest", "deps", "stdin"], + inputs: ["guest", "deps", "stdin", "mask"], record: async (inputs) => recordGuest(inputs), compared: { code: "value", signal: "value", stdout: "text", stderr: "text" }, }, @@ -122,7 +127,7 @@ for (const [kind, name] of targets) { const old = exists ? readGolden(kind, name) : undefined; const inputs = old ? Object.fromEntries(fields.filter((f) => old[f] !== undefined).map((f) => [f, old[f]])) - : { guest: name, ...(flag("deps") ? { deps: flag("deps").split(",") } : {}), ...(flag("stdin") !== undefined ? { stdin: JSON.parse(flag("stdin")) } : {}) }; + : { guest: name, ...(flag("deps") ? { deps: flag("deps").split(",") } : {}), ...(flag("stdin") !== undefined ? { stdin: JSON.parse(flag("stdin")) } : {}), ...(flag("mask") ? { mask: flag("mask") } : {}) }; const got = await record(inputs); const diff = old ? differences(compared, old, got) : [" new"]; if (diff.length) { diff --git a/packages/kernel/src/runtime/builtins.ts b/packages/kernel/src/runtime/builtins.ts index 4119765..b3af392 100644 --- a/packages/kernel/src/runtime/builtins.ts +++ b/packages/kernel/src/runtime/builtins.ts @@ -37,6 +37,8 @@ import { makePath } from "./path"; import { timersPromises } from "./timers"; import { makeReadline } from "./readline/index"; import { sea } from "./sea"; +import { makeTest } from "./test"; +import { makeTestReporters } from "./test-reporters"; import { tls } from "./tls"; import { traceEvents } from "./trace_events"; import { tty } from "./tty"; @@ -92,6 +94,43 @@ const FACTORIES = { "stream/promises": (ctx) => (loadBuiltin("stream", ctx) as typeof Stream).promises, "stream/web": () => streamWeb, string_decoder: () => ({ StringDecoder }), + // mock.timers patches globalThis, node:timers' own module object and + // node:timers/promises' own module object independently (none of the three alias + // each other — see mock_timers.ts's TimerHooks); t.assert.snapshot/fileSnapshot read + // and write through the kernel's own fs/path, like Node's require('fs')/require( + // 'path'). Both reach past test.ts into this file's own loadBuiltin/globalThis + // through closures, rather than test.ts importing loadBuiltin itself and creating a + // builtins.ts <-> test.ts import cycle. + test: (ctx) => + makeTest(ctx.process, { + timers: { + globalTarget: globalThis as unknown as Record, + nodeTimers: () => loadBuiltin("timers", ctx) as Record, + nodeTimersPromises: () => loadBuiltin("timers/promises", ctx) as ReturnType, + abortSignal: () => (typeof AbortSignal === "undefined" ? undefined : (AbortSignal as unknown as { timeout: unknown })), + }, + fs: { + readFileSync: (path, encoding) => (loadBuiltin("fs", ctx) as ReturnType).readFileSync(path, encoding) as string, + writeFileSync: (path, data, encoding) => (loadBuiltin("fs", ctx) as ReturnType).writeFileSync(path, data, encoding), + mkdirSync: (path, options) => void (loadBuiltin("fs", ctx) as ReturnType).mkdirSync(path, options), + dirname: (path) => (loadBuiltin("path", ctx) as ReturnType).dirname(path), + }, + run: { + proc: ctx.process, + globSync: (patterns, cwd) => + (loadBuiltin("fs", ctx) as ReturnType).globSync(patterns, { cwd, exclude: (name: string) => name === "node_modules" }) as string[], + // run({isolation:'none'})'s file loader reuses the exact mechanism a guest's + // own dynamic import() is rewritten to call (src/kernel/transform.ts): the + // test file resolves and loads through the real kernel resolver, CJS and ESM + // alike, with no separate code path for "a file run() loads" vs "a file a + // guest module imports". + importFile: (cwd, absolutePath) => + (globalThis as unknown as Record (specifier: string) => Promise }>)[ + Symbol.for("nib.runtime") + ]!.importFrom(`${cwd}/`)(absolutePath), + }, + }), + "test/reporters": () => makeTestReporters(), timers: (ctx) => ({ ...ctx.timers, promises: loadBuiltin("timers/promises", ctx) }), "timers/promises": (ctx) => timersPromises(ctx.timers), tls: () => tls, diff --git a/packages/kernel/src/runtime/errors.ts b/packages/kernel/src/runtime/errors.ts index f9194d2..8b5967d 100644 --- a/packages/kernel/src/runtime/errors.ts +++ b/packages/kernel/src/runtime/errors.ts @@ -28,25 +28,62 @@ export function webEntryNotEsm(entry: string, format: string): Error { return Object.assign(e, { code: "ERR_NIB_WEB_ENTRY_NOT_ESM" }); } -/** Node's ERR_INVALID_ARG_TYPE, with its description of the received value. */ +/** Node's ERR_INVALID_ARG_TYPE, with its description of the received value. A dotted + * `name` (e.g. "options.foo") reads as a "property", otherwise an "argument" — Node's + * internal/errors.js picks the word the same way. */ export function invalidArgType(name: string, expected: string, value: unknown): TypeError { const got = value === null || value === undefined ? ` Received ${value}` : typeof value === "function" ? ` Received function ${(value as { name?: string }).name || "anonymous"}` : typeof value === "object" ? ` Received an instance of ${(value as object).constructor?.name ?? "Object"}` : ` Received type ${typeof value} (${typeof value === "string" ? `'${value}'` : String(value)})`; - return Object.assign(new TypeError(`The "${name}" argument must be ${expected}.${got}`), { code: "ERR_INVALID_ARG_TYPE" }); + const kind = name.includes(".") ? "property" : "argument"; + return Object.assign(new TypeError(`The "${name}" ${kind} must be ${expected}.${got}`), { code: "ERR_INVALID_ARG_TYPE" }); } /** Node's ERR_INVALID_ARG_VALUE: `name` dotted (e.g. "options.foo") reads as a * property, otherwise an argument. inspect's output is truncated at 128 chars, as * Node's own message builder does. */ -export function invalidArgValue(name: string, value: unknown, reason = "is invalid"): TypeError { +export function invalidArgValue(name: string, value: unknown, reason: unknown = "is invalid"): TypeError { let shown = inspect(value); if (shown.length > 128) shown = `${shown.slice(0, 128)}...`; const kind = name.includes(".") ? "property" : "argument"; return Object.assign(new TypeError(`The ${kind} '${name}' ${reason}. Received ${shown}`), { code: "ERR_INVALID_ARG_VALUE" }); } +/** Node's internal/errors.js addNumericalSeparator: underscore-group a big integer's + * digits the way ERR_OUT_OF_RANGE prints a received value that overflows 2**32. */ +function addNumericalSeparator(val: string): string { + let res = ""; + let i = val.length; + const start = val[0] === "-" ? 1 : 0; + for (; i >= start + 4; i -= 3) res = `_${val.slice(i - 3, i)}${res}`; + return `${val.slice(0, i)}${res}`; +} + +/** Node's ERR_OUT_OF_RANGE. `replaceDefaultBoolean` swaps in a caller-built sentence for + * the usual "The value of ... is out of range." lead-in, as Node's own helper does. */ +export function outOfRange(name: string, range: string, value: unknown, replaceDefaultBoolean = false): RangeError { + let msg = replaceDefaultBoolean ? name : `The value of "${name}" is out of range.`; + let received: string; + if (Number.isInteger(value) && Math.abs(value as number) > 2 ** 32) { + received = addNumericalSeparator(String(value)); + } else if (typeof value === "bigint") { + received = String(value); + if (value > 2n ** 32n || value < -(2n ** 32n)) received = addNumericalSeparator(received); + received += "n"; + } else { + received = inspect(value); + } + msg += ` It must be ${range}. Received ${received}`; + return Object.assign(new RangeError(msg), { code: "ERR_OUT_OF_RANGE" }); +} + +/** Node's ERR_INVALID_STATE: `Invalid state: ${message}`, e.g. mock.timers.enable() + * called twice or tick() before enable(). */ +export function invalidState(message: string): Error { + return Object.assign(new Error(`Invalid state: ${message}`), { code: "ERR_INVALID_STATE" }); +} + /** Node's ERR_INVALID_RETURN_VALUE (e.g. assert.rejects' promiseFn not returning a Promise). */ export function invalidReturnValue(expected: string, name: string, value: unknown): TypeError { const got = value === null ? "null" diff --git a/packages/kernel/src/runtime/main.ts b/packages/kernel/src/runtime/main.ts index b3566bb..ca7a54e 100644 --- a/packages/kernel/src/runtime/main.ts +++ b/packages/kernel/src/runtime/main.ts @@ -264,6 +264,14 @@ self.onmessage = (ev: MessageEvent): void => { proc.on("SIGWINCH", function refreshStderrOnSigWinch() { stderr._refreshSize(); }); } timers = createTimers(loop, (message, type) => proc.emitWarning(message, type)); + // Node's lib/timers.js: util.promisify(setTimeout) and util.promisify(setImmediate) are + // the timers/promises forms, resolved lazily so they are the same functions. + for (const name of ["setTimeout", "setImmediate"] as const) { + Object.defineProperty(timers[name], Symbol.for("nodejs.util.promisify.custom"), { + enumerable: true, + get: () => (loadBuiltin("timers/promises", ctx) as Record)[name], + }); + } const setup = processRuntimes[msg.runtime]; setup.installGlobals(timers, proc); diff --git a/packages/kernel/src/runtime/process.ts b/packages/kernel/src/runtime/process.ts index d1d9682..16cfc2d 100644 --- a/packages/kernel/src/runtime/process.ts +++ b/packages/kernel/src/runtime/process.ts @@ -3,7 +3,7 @@ // argv[1] the entry path. The version strings name the Node line this runtime // targets for compatibility (DESIGN §Event loop); they are not a claim to be Node. import type { KernelToRuntimeInit, Signal, SyscallSpec } from "../shared/protocol"; -import { isBuiltinId } from "../shared/builtin-ids"; +import { SCHEME_ONLY_BUILTINS, isBuiltinId } from "../shared/builtin-ids"; import { uvError } from "../shared/nodeerr"; import { ARCH, NODE_VERSION, PLATFORM } from "../shared/platform"; import { SIGNALS, isSignal } from "../shared/protocol"; @@ -322,12 +322,16 @@ export function makeProcess(deps: ProcessDeps): GuestProcess { hrtime, uptime: () => performance.now() / 1000, // Node resolves a bare id ('fs') and its 'node:'-prefixed form the same way, - // except for a scheme-only built-in (none implemented here yet), which only - // resolves with the prefix. An unknown id is undefined, never a throw. + // except for a scheme-only built-in (node:test, node:test/reporters, node:sea), + // which only resolves with the prefix — confirmed against real Node 24.16.0: + // process.getBuiltinModule('test') is undefined, process.getBuiltinModule('node:test') + // is the real module. An unknown id is undefined, never a throw. getBuiltinModule(id: unknown): unknown { if (typeof id !== "string") throw invalidArgType("id", "of type string", id); - const bare = id.startsWith("node:") ? id.slice(5) : id; + const prefixed = id.startsWith("node:"); + const bare = prefixed ? id.slice(5) : id; if (!isBuiltinId(bare)) return undefined; + if (!prefixed && SCHEME_ONLY_BUILTINS.has(bare)) return undefined; return deps.getBuiltinModule(bare); }, emitWarning(warning: string | Error, typeOrOptions?: string | WarningOptions, code?: string): void { diff --git a/packages/kernel/src/runtime/stream.ts b/packages/kernel/src/runtime/stream.ts index 62cf6dc..c61ed0a 100644 --- a/packages/kernel/src/runtime/stream.ts +++ b/packages/kernel/src/runtime/stream.ts @@ -122,6 +122,19 @@ export class Readable extends (Stream as unknown as EventEmitterConstructor) { #endScheduled = false; #flowScheduled = false; #reading = false; + /** Node's kNeedReadable/kEmittedReadable/kReadableListening: whether a push should + * bother emitting 'readable' at all (only true while something is actually waiting + * on it — a first 'readable' listener attaching, or read() having just found nothing), + * whether that emission is already scheduled (so a second push before it fires + * doesn't queue a redundant one), and whether the first 'readable' listener's + * one-time setup already ran. Without this gating every push while paused queues + * its own nextTick, even when an earlier one is still pending or nothing is + * listening — extra hops Node never takes (see lib/internal/streams/readable.js's + * `emitReadable`/`emitReadable_`), which was exactly the extra tick a composed, + * already-flowing Readable showed up behind other, unrelated stream setup. */ + #needReadable = false; + #emittedReadable = false; + #readableListening = false; /** Node's `state.sync`: true until the first _read returns and during each _read. * A push while sync buffers instead of emitting 'data' on the caller's stack. */ #sync = true; @@ -237,7 +250,7 @@ export class Readable extends (Stream as unknown as EventEmitterConstructor) { this.#wake(); if (this.#flowing && !this.#sync) this.#flow(); else if (this.#flowing) this.#scheduleFlow(); - else if (this.#length === 0) this.#emitReadableSoon(); + else if (this.#length === 0) this.#emitReadable(); return false; } if (this.#ended) { @@ -263,7 +276,7 @@ export class Readable extends (Stream as unknown as EventEmitterConstructor) { this.#buffer.push(item); this.#length += this.readableObjectMode ? 1 : (item as Buffer | string).length; if (this.#flowing) this.#scheduleFlow(); - else this.#emitReadableSoon(); + else if (this.#needReadable) this.#emitReadable(); } this.#wake(); this.#maybeReadMore(); @@ -287,11 +300,17 @@ export class Readable extends (Stream as unknown as EventEmitterConstructor) { if (this.#length === 0 || this.#length - this.#howMuch(size) < this.readableHighWaterMark) this.#maybeRead(true); const chunk = this.#take(size); if (chunk === null) { + // Node's howMuchToRead: nothing to give back means nothing is buffered (or not + // enough while paused), so the next push should wake whoever's waiting again. + if (!this.#flowing && !this.#ended) this.#needReadable = true; if (this.#ended) this.#scheduleEnd(); return null; } this.#didRead = true; - if (this.#length === 0 && this.#ended) this.#scheduleEnd(); + if (this.#length === 0) { + if (this.#ended) this.#scheduleEnd(); + else if (!this.#flowing) this.#needReadable = true; + } this.emit("data", chunk); return chunk; } @@ -306,9 +325,15 @@ export class Readable extends (Stream as unknown as EventEmitterConstructor) { override on(event: string | symbol, fn: (...args: any[]) => void): this { super.on(event, fn); if (event === "data" && this.#flowing !== false) this.resume(); - if (event === "readable" && !this.#endEmitted) { + // Node's kReadableListening: this one-time setup (arming needReadable, forcing + // paused mode) runs only for the *first* 'readable' listener — a second one + // attaching later rides on the state the first already set up. + if (event === "readable" && !this.#endEmitted && !this.#readableListening) { + this.#readableListening = true; + this.#needReadable = true; this.#flowing = false; - if (this.#length > 0 || this.#ended) this.#emitReadableSoon(); + this.#emittedReadable = false; + if (this.#length > 0 || this.#ended) this.#emitReadable(); // Node's nReadingNextTick: a 'readable' listener starts the source reading. else if (!this.#reading) nextTick(() => this.read(0)); } @@ -319,6 +344,7 @@ export class Readable extends (Stream as unknown as EventEmitterConstructor) { } resume(): this { + if (this.#destroyed) return this; if (!this.#flowing) { this.#flowing = true; this.#scheduleFlow(); @@ -328,6 +354,12 @@ export class Readable extends (Stream as unknown as EventEmitterConstructor) { } pause(): this { + // Node's pause(): a destroyed stream (as `pt` here already is by the time its + // 'finish'-triggered unpipe() calls this) never emits 'pause' — confirmed against + // real Node 24.16.0 (lib/internal/streams/readable.js's `Readable.prototype.pause`), + // where this guard is what keeps a pipe's auto-unpipe-on-finish from firing a + // spurious 'pause' once the source has already ended and auto-destroyed. + if (this.#destroyed) return this; if (this.#flowing !== false) { this.#flowing = false; this.emit("pause"); @@ -601,6 +633,9 @@ export class Readable extends (Stream as unknown as EventEmitterConstructor) { if (this.#ended || this.#reading || this.#destroyed || !belowMark) return; this.#reading = true; this.#sync = true; + // Node's read(): an empty buffer about to call _read definitely needs a 'readable' + // (or more 'data') once something arrives. + if (this.#length === 0) this.#needReadable = true; this._read(this.readableHighWaterMark); this.#sync = false; } @@ -633,11 +668,24 @@ export class Readable extends (Stream as unknown as EventEmitterConstructor) { }); } - #emitReadableSoon(): void { - if (this.listenerCount("readable") === 0) return; - nextTick(() => { - if (!this.#destroyed) this.emit("readable"); - }); + /** Node's emitReadable: marks a 'readable' emission as owed and, unless one is + * already pending (#emittedReadable), schedules the one that will pay it off. */ + #emitReadable(): void { + this.#needReadable = false; + if (!this.#emittedReadable) { + this.#emittedReadable = true; + nextTick(() => this.#emitReadableNow()); + } + } + + /** Node's emitReadable_: fires 'readable' only if there's still something to show + * for it (a push drained in the meantime leaves this a no-op), then re-arms + * needReadable for whatever pushes next while paused and below the mark. */ + #emitReadableNow(): void { + this.#emittedReadable = false; + if (!this.#destroyed && (this.#length > 0 || this.#ended)) this.emit("readable"); + if (!this.#flowing && !this.#ended && this.#length <= this.readableHighWaterMark) this.#needReadable = true; + if (this.#flowing) this.#flow(); } #scheduleEnd(): void { @@ -963,11 +1011,71 @@ function fromGeneratorFunction(fn: GeneratorStage): Duplex { return duplex; } -/** Duplex.from(body): a stream passed through, a generator function wrapped via - * fromGeneratorFunction, a WHATWG { readable, writable } pair bridged natively, or an - * iterable exposed read-only through Readable.from (Node leaves its writable side dead). */ +/** Wraps a plain (non-Duplex) Readable as a Duplex whose readable side pulls from it + * directly — the same 'readable'-event/read() pull Node's _duplexify and this module's + * own compose() use for a tail stage — instead of draining it through async iteration. + * Async iteration would add a microtask hop per chunk (the generator's internal await) + * that Node's real Duplex.from(readable) never takes, which is exactly the extra tick + * that showed up composing an already-flowing Readable: confirmed by comparing + * lib/internal/streams/duplexify.js's `isReadableNodeStream(body)` fast path (checked + * before its generic `isIterable(body)` one) against real Node 24.16.0. */ +function duplexFromReadable(r: Readable): Duplex { + let onreadable: (() => void) | null = null; + let duplex!: Duplex; + const pull = (): void => { + for (;;) { + const buf = r.read(); + if (buf === null) { + onreadable = pull; + return; + } + if (!duplex.push(buf)) return; + } + }; + duplex = new Duplex({ objectMode: r.readableObjectMode, read: pull }); + r.on("readable", () => { + const cb = onreadable; + onreadable = null; + cb?.(); + }); + r.once("end", () => duplex.push(null)); + r.once("error", (err: Error) => duplex.destroy(err)); + // Node's own Duplexify(options.writable === false): the writable side is already + // over, so a write to it fails the normal "write after end" way. + duplex.end(); + return duplex; +} + +/** Wraps a plain (non-Duplex) Writable as a Duplex whose writable side forwards + * directly to it; the readable side is already over (nothing to read), matching + * Node's Duplexify(options.readable === false). */ +function duplexFromWritable(w: Writable): Duplex { + const duplex: Duplex = new Duplex({ + objectMode: w.writableObjectMode, + read() {}, + write(chunk, encoding, callback) { + if (w.write(chunk, encoding)) callback(); + else w.once("drain", () => callback()); + }, + final(callback) { + w.end(); + w.once("finish", () => callback()); + }, + }); + w.once("error", (err: Error) => duplex.destroy(err)); + duplex.push(null); + return duplex; +} + +/** Duplex.from(body): a stream passed through, a plain Readable/Writable wrapped + * pull/push-style (never through async iteration — see duplexFromReadable), a + * generator function wrapped via fromGeneratorFunction, a WHATWG { readable, writable } + * pair bridged natively, or an iterable exposed read-only through Readable.from (Node + * leaves its writable side dead). */ function duplexFrom(body: unknown): Duplex { if (body instanceof Duplex) return body; + if (body instanceof Readable) return duplexFromReadable(body); + if (body instanceof Writable) return duplexFromWritable(body); if (typeof body === "function") return fromGeneratorFunction(body as GeneratorStage); if (body && typeof body === "object" && "readable" in body && "writable" in body && (body as { readable: unknown }).readable instanceof ReadableStream && (body as { writable: unknown }).writable instanceof WritableStream) { @@ -985,10 +1093,36 @@ function duplexFrom(body: unknown): Duplex { /** stream.compose(...stages): chains streams and generator functions into one Duplex * whose writable side feeds the first stage and readable side yields the last stage's - * output; any stage erroring destroys every other stage. */ + * output; any stage erroring destroys every other stage. + * + * The readable side is wired as a *pull*, the way Node's own internal/streams/compose.js + * does it: `last`'s data is read via `last.read()` from inside `proxy`'s own `_read`, + * re-armed by `last`'s 'readable' event, never by listening for `last`'s 'data' event. + * This isn't a cosmetic choice: Node's Readable only emits 'data' synchronously off a + * push() when something is already flowing *with a 'data' listener attached*; a + * 'readable' listener instead puts the stream in paused mode, so every chunk `last` + * pushes first goes through its push()'s buffered path, whose `emitReadable` is always + * `process.nextTick`-deferred (lib/internal/streams/readable.js). That hop is what lets + * other already-queued synchronous work (e.g. node:test's harness starting the next + * test, which runs its body synchronously up to its first await) run before a composed + * reporter's formatted line actually reaches its destination — confirmed against real + * Node 24.16.0 by diffing node:test's own `compose(rootReporter, reporter).pipe(dest)` + * wiring (lib/internal/test_runner/utils.js's setupTestReporters) against a guest that + * logs between two tests (tests/fixtures/guests/builtin-test-order.cjs). */ export function compose(...stages: unknown[]): Duplex { if (stages.length === 0) throw nodeError("ERR_MISSING_ARGS", 'The "streams" argument must be specified'); - const streams = stages.map((s) => duplexFrom(s)); + // Node's compose() only runs a stage through Duplex.from when it's a function; a + // stage that's already a Readable/Writable/Duplex is pipe()d as-is (lib/internal/ + // streams/compose.js builds its internal `pipeline(streams, onfinished)` straight + // from the given stages, only substituting `Duplex.from(streams[n])` when + // `typeof streams[n] === 'function'`). Wrapping an already-a-stream stage anyway — + // which our own Duplex.from does, correctly, for *its* callers — routes an + // already-flowing Readable's data through an extra push()/'readable' round trip + // real Node never takes for a stage that's already a stream: confirmed by comparing + // NODE_DEBUG=stream output for this guest's `compose(src, upper())` (src's "composed 2" + // push reaches upper()'s own push synchronously in Node, never through a deferred + // 'readable') against this file's prior behavior of always wrapping every stage. + const streams = stages.map((s) => (s instanceof Duplex || s instanceof Readable || s instanceof Writable ? s : duplexFrom(s))) as unknown as Duplex[]; const destroyAll = (err: Error): void => { for (const s of streams) if (!s.destroyed) s.destroy(err); }; @@ -997,30 +1131,57 @@ export function compose(...stages: unknown[]): Duplex { const first = streams[0]!; const last = streams[streams.length - 1]!; - const proxy: Duplex = new Duplex({ - writableObjectMode: first.writableObjectMode, - readableObjectMode: last.readableObjectMode, + // Node's compose(): `writable`/`readable` come from what `head`/`tail` actually are, + // not from wrapping them — a bare Readable passed in (readable.compose(genFn), or + // compose(someReadable, ...)) has no writable side at all. + const writableSide = first instanceof Writable; + const readableSide = last instanceof Readable; + let onreadable: (() => void) | null = null; + let proxy!: Duplex; + const pull = (): void => { + for (;;) { + const buf = last.read(); + if (buf === null) { + onreadable = pull; + return; + } + if (!proxy.push(buf)) return; + } + }; + proxy = new Duplex({ + writableObjectMode: (first as unknown as { writableObjectMode?: boolean }).writableObjectMode ?? false, + readableObjectMode: (last as unknown as { readableObjectMode?: boolean }).readableObjectMode ?? false, write(chunk, encoding, callback) { - first.write(chunk, encoding, callback); + (first as unknown as Writable).write(chunk, encoding, callback); }, final(callback) { - first.end(callback); - }, - read() { - last.resume(); + (first as unknown as Writable).end(callback); }, + read: readableSide ? pull : () => {}, destroy(error, callback) { if (error) destroyAll(error); callback(error); }, }); - if ((first as unknown as { writable?: boolean }).writable === false) { - Object.defineProperty(proxy, "writable", { configurable: true, get: () => false }); + // Node's Duplexify(options.writable === false / options.readable === false): force + // that side into its already-finished state directly, the way a stage that was + // never writable (or never readable) reports it — not through write()/push(), which + // would call methods `first`/`last` don't have. + if (!writableSide) { + const ws = (proxy as unknown as { _writableState: WritableState })._writableState; + ws.ending = true; + ws.ended = true; + ws.finished = true; + } + if (!readableSide) proxy.push(null); + if (readableSide) { + last.on("readable", () => { + const cb = onreadable; + onreadable = null; + cb?.(); + }); + last.once("end", () => proxy.push(null)); } - last.on("data", (chunk: unknown) => { - if (!proxy.push(chunk)) last.pause(); - }); - last.once("end", () => proxy.push(null)); last.once("error", (err: Error) => proxy.destroy(err)); return proxy; } diff --git a/packages/kernel/src/runtime/test-reporters.ts b/packages/kernel/src/runtime/test-reporters.ts new file mode 100644 index 0000000..f2814a7 --- /dev/null +++ b/packages/kernel/src/runtime/test-reporters.ts @@ -0,0 +1,20 @@ +// node:test/reporters, after Node's lib/test/reporters.js: dot/tap/junit are plain +// async-generator transforms over an event-object source; spec/lcov are classes Node +// exposes as callable factories (`reporters.spec()` constructs a `new SpecReporter()`). +// Scheme-only (SCHEME_ONLY_BUILTINS): `require("node:test/reporters")` works, +// `require("test/reporters")` does not. +import { dot } from "./test_runner/reporter/dot"; +import { junit } from "./test_runner/reporter/junit"; +import { tap } from "./test_runner/reporter/tap"; +import { SpecReporter } from "./test_runner/reporter/spec"; +import { LcovReporter } from "./test_runner/reporter/lcov"; + +export function makeTestReporters() { + return { + dot, + junit, + tap, + spec: (): SpecReporter => new SpecReporter(), + lcov: (): LcovReporter => new LcovReporter(), + }; +} diff --git a/packages/kernel/src/runtime/test.ts b/packages/kernel/src/runtime/test.ts new file mode 100644 index 0000000..728bcae --- /dev/null +++ b/packages/kernel/src/runtime/test.ts @@ -0,0 +1,90 @@ +// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and +// other Node contributors). See LICENSE-node. +// node:test, after Node's lib/test.js: `module.exports = test` (test itself is also +// `test.test`), plus describe/it aliases, before/after/beforeEach/afterEach, and the +// lazy `mock`/`snapshot`/`assert`/`run` getters. `test_runner/` mirrors Node's +// lib/internal/test_runner/ the way src/runtime/readline/ mirrors Node's lib/readline.js +// + lib/internal/readline/. Registered scheme-only in builtins.ts (SCHEME_ONLY_BUILTINS): +// `require("node:test")` works, bare `require("test")` fails, matching Node. +import type { GuestProcess } from "./process"; +import { bindProcess, test as runTest, suite, before, after, beforeEach, afterEach } from "./test_runner/harness"; +import { createMockTracker } from "./test_runner/mock/mock"; +import { bindTimerHooks, type TimerHooks } from "./test_runner/mock/mock_timers"; +import { getAssertionMap, register } from "./test_runner/assert"; +import { bindSnapshotFs, setDefaultSnapshotSerializers, setResolveSnapshotPath, type SnapshotFsHooks } from "./test_runner/snapshot"; +import { run as runTests, type RunnerDeps } from "./test_runner/runner"; +import type { TestsStream } from "./test_runner/tests_stream"; + +export interface TestHooks { + timers: TimerHooks; + fs: SnapshotFsHooks; + run: RunnerDeps; +} + +export interface TestModule { + (name?: unknown, options?: unknown, fn?: unknown): Promise; + test: TestModule; + describe: typeof suite; + it: TestModule; + suite: typeof suite; + before: typeof before; + after: typeof after; + beforeEach: typeof beforeEach; + afterEach: typeof afterEach; + run: (options?: unknown) => TestsStream; + mock: ReturnType; + snapshot: { setDefaultSnapshotSerializers: unknown; setResolveSnapshotPath: unknown }; + assert: { register: typeof register }; +} + +/** One instance per worker: node:test needs the real guest `process` (its exit code, + * its argv[1] for the root test's location, its uncaughtException/unhandledRejection + * events) before any test()/describe() call can lazily bootstrap the root, plus the + * timer and fs hooks mock.timers and t.assert.snapshot/fileSnapshot need to reach + * past this module into builtins.ts's loadBuiltin without a circular import. */ +export function makeTest(process: GuestProcess, hooks: TestHooks): TestModule { + bindProcess(process); + bindTimerHooks(hooks.timers); + bindSnapshotFs(hooks.fs); + + const test = runTest as unknown as TestModule; + Object.assign(test, { + after, + afterEach, + before, + beforeEach, + describe: suite, + it: test, + suite, + test, + // run({ isolation: "none" }) loads test files in this process (test_runner/ + // runner.ts); run({ isolation: "process" }, the default) refuses there — see its + // header comment — once option validation has already run. + run: (options?: unknown) => runTests(options, hooks.run), + }); + + let lazyMock: ReturnType | undefined; + Object.defineProperty(test, "mock", { + configurable: true, + enumerable: true, + get() { + lazyMock ??= createMockTracker(); + return lazyMock; + }, + }); + + Object.defineProperty(test, "snapshot", { + configurable: true, + enumerable: true, + value: { setDefaultSnapshotSerializers, setResolveSnapshotPath }, + }); + + Object.defineProperty(test, "assert", { + configurable: true, + enumerable: true, + value: { register }, + }); + void getAssertionMap; + + return test; +} diff --git a/packages/kernel/src/runtime/test_runner/assert.ts b/packages/kernel/src/runtime/test_runner/assert.ts new file mode 100644 index 0000000..b10f35c --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/assert.ts @@ -0,0 +1,35 @@ +// 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/assert.js: the map of names TestContext#assert copies onto +// each test's `t.assert`, seeded from node:assert and extendable through +// `test.assert.register(name, fn)`. +import { assert } from "../assert"; +import { invalidArgType } from "../errors"; + +const METHODS_TO_COPY = [ + "deepEqual", "deepStrictEqual", "doesNotMatch", "doesNotReject", "doesNotThrow", "equal", "fail", "ifError", + "match", "notDeepEqual", "notDeepStrictEqual", "notEqual", "partialDeepStrictEqual", "rejects", "strictEqual", + "throws", +] as const; + +type AssertMap = Map unknown>; + +let assertMap: AssertMap | undefined; + +export function getAssertionMap(): AssertMap { + if (assertMap === undefined) { + assertMap = new Map(); + const methods = assert as unknown as Record unknown>; + for (const name of METHODS_TO_COPY) { + const method = methods[name]; + if (method) assertMap.set(name, method); + } + } + return assertMap; +} + +export function register(name: unknown, fn: unknown): void { + if (typeof name !== "string") throw invalidArgType("name", "of type string", name); + if (typeof fn !== "function") throw invalidArgType("fn", "of type function", fn); + getAssertionMap().set(name, fn as (...args: unknown[]) => unknown); +} diff --git a/packages/kernel/src/runtime/test_runner/colors.ts b/packages/kernel/src/runtime/test_runner/colors.ts new file mode 100644 index 0000000..2b91687 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/colors.ts @@ -0,0 +1,32 @@ +// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and +// other Node contributors). See LICENSE-node. +// node:internal/util/colors.js, the subset the test reporters use. Node's own module +// decides once per process (via `refresh()`, called by SpecReporter's constructor and +// at module load) whether to colorize, checking `process.stderr` (not stdout — a real +// Node quirk, kept here so a guest that forces color on stdout but not stderr, or vice +// versa, still gets Node's answer). Reads the guest's own global `process`, the same +// ambient reference Node's internal modules use (they're privileged code sharing the +// realm with the rest of the bootstrap, same as here — see DESIGN.md's module map). +import { shouldColorize } from "../tty"; + +declare const process: { stderr: unknown } | undefined; + +function colorize(): boolean { + try { + return shouldColorize(process?.stderr); + } catch { + return false; + } +} + +export function shouldColorizeStderr(): boolean { + return colorize(); +} + +export const red = (): string => (colorize() ? "\u001b[31m" : ""); +export const green = (): string => (colorize() ? "\u001b[32m" : ""); +export const white = (): string => (colorize() ? "\u001b[39m" : ""); +export const yellow = (): string => (colorize() ? "\u001b[33m" : ""); +export const gray = (): string => (colorize() ? "\u001b[90m" : ""); +export const blue = (): string => (colorize() ? "\u001b[34m" : ""); +export const reset = (): string => (colorize() ? "\u001b[0m" : ""); diff --git a/packages/kernel/src/runtime/test_runner/harness.ts b/packages/kernel/src/runtime/test_runner/harness.ts new file mode 100644 index 0000000..7818b69 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/harness.ts @@ -0,0 +1,288 @@ +// 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/harness.js: the lazy bootstrap (the root `Test` is created on +// the first call to test()/describe()/before()/..., not at require time), process-exit +// handling (a `beforeExit` listener runs the root test and waits for pending subtests, +// as the kernel's own loop already does for ordinary 'beforeExit'/'exit' semantics — +// see src/runtime/loop.ts and src/runtime/main.ts), and uncaughtException/ +// unhandledRejection routing to whichever test is currently running. +// +// Divergence (recorded in compat/divergences.json under "test"): Node attributes an +// async error to its owning test via async_hooks.createHook, tagging every async +// resource at creation with testResources.set(asyncId, currentTest). That hook type is +// refused here (DESIGN.md's refusals: async_hooks.createHook needs engine support the +// browser does not expose). The closest honest equivalent is test.ts's +// `getCurrentlyRunningTest()`: "whichever test is currently running when the error +// arrives" is a correct attribution for every ordinary case; it only differs from +// Node's for an async resource that outlives the test that created it (a stray timer, an +// unresolved promise) and fires *after* that test finished but before another started — +// Node still attributes that to the dead test (and reports a "ran after the test ended" +// diagnostic on the root instead of failing it); here it attributes to the root test +// directly, which is also what Node does when a resource has no owner at all. +import { testFailure } from "./test-failure"; +import { + Test, Suite, getCurrentlyRunningTest, currentBuildingParent, + type Harness, type TestFn, type TestOptions, +} from "./test"; +import { commandLineDefaults, getCallerLocation, type GlobalTestOptions } from "./utils"; +import { SpecReporter } from "./reporter/spec"; +import { compose, type PipeDestination } from "../stream"; +import type { GuestProcess } from "../process"; + +export const kCancelledByParent = "cancelledByParent"; + +let globalRoot: Test | null = null; +let installed = false; + +/** One instance per worker (module state): the process this harness is wired to. Set by + * the builtin factory (test.ts's top-level assembler) before any test()/describe() can + * run, since every runtime worker that loads node:test needs its own process object + * rather than a module-global `process`. */ +let boundProcess: GuestProcess | null = null; +export function bindProcess(proc: GuestProcess): void { + boundProcess = proc; +} + +/** Node's internal/test_runner/harness.js createTestTree: builds a fresh root Test + * from rootOptions (concurrency/timeout/signal/entryFile/loc) and globalOptions (the + * harness.config every descendant reads — see commandLineDefaults/GlobalTestOptions), + * replacing the module-scoped `globalRoot` the way Node's own does (confirmed against + * real Node 24.16.0: a run() call's root is what any top-level test()/describe() in a + * file it loads attaches to next, because createTestTree always reassigns the same + * module-level variable lazyBootstrapRoot() reads). + * + * `autoReport` is false for run()'s root only: direct execution + * (`node file.test.js`) is Node's one unconfigured-reporter default — no CLI flag + * parser exists here (DESIGN: refused), so it is always `compose(root.reporter, + * SpecReporter).pipe(stdout)` plus a 'test:summary' listener that fails the process on + * an unsuccessful run. run()'s API hands the caller back `root.reporter` undecorated + * (confirmed against real Node 24.16.0: a failing run({isolation:'none'}) test leaves + * process.exitCode untouched — the CLI, not the API, sets it when it consumes the + * returned stream itself) — the caller composes/pipes/listens on it however it likes. */ +export function createTestTree(rootOptions: TestOptions, globalOptions: GlobalTestOptions, opts: { autoReport?: boolean } = {}): Test { + const proc = boundProcess!; + const isFilteringByName = globalOptions.testNamePatterns !== null || globalOptions.testSkipPatterns !== null; + // Node's harness.js: `const buildPhaseDeferred = PromiseWithResolvers()`, stashed as + // harness.buildPromise. See Harness#buildPromise in test.ts for what this gates. + let resolveBuildPhase!: () => void; + const buildPromise = new Promise((res) => (resolveBuildPhase = res)); + const harness: Harness = { + config: { + timeout: globalOptions.timeout, + only: globalOptions.only, + cwd: globalOptions.cwd, + updateSnapshots: globalOptions.testUpdateSnapshots, + testNamePatterns: globalOptions.testNamePatterns, + testSkipPatterns: globalOptions.testSkipPatterns, + }, + counters: { tests: 0, failed: 0, passed: 0, cancelled: 0, skipped: 0, todo: 0, topLevel: 0, suites: 0 }, + success: true, + // Direct execution (`node file.test.js`, no --test flag) and run({isolation: + // 'none'}) both always filter by `only`, confirmed against real Node 24.16.0: + // harness.js's isFilteringByOnly is the CLI-dependent expression `(isolation === + // 'process' || NODE_TEST_CONTEXT) ? only : true`, and neither condition is ever + // true for either path here — isolation is never 'process' (that refuses) and + // NODE_TEST_CONTEXT is never set (only an isolation:'process' child gets it). + isFilteringByOnly: true, + isFilteringByName, + buildPromise, + buildSuites: [], + isWaitingForBuildPhase: false, + // Node's harness.js waitForBuildPhase(): await every top-level Suite's buildSuite + // collected so far, then resolve buildPromise so every startSubtestAfterBootstrap + // call waiting on it (and so every top-level test) can finally start. Node's own + // version awaits them together through `SafePromiseAllReturnVoid` — a primordial + // that iterates by hand rather than the native Promise.all combinator — not + // `Promise.all` itself; a build that throws keeps the same stack either way, but + // V8 tags a rejection awaited *through the native combinator* with an extra + // `at async Promise.all (index N)` frame that SafePromiseAllReturnVoid's manual + // iteration never produces (confirmed against real Node 24.16.0's golden here — + // see tests/fixtures/guests/builtin-test-async-describe.cjs's "throws while + // building" — and against this runtime before this loop replaced a `Promise.all` + // call). Looping and awaiting each in turn is otherwise equivalent: every entry is + // already running independently, so this only changes the bookkeeping of waiting + // for them, not when any of them actually settles. + async waitForBuildPhase(): Promise { + for (const buildSuite of harness.buildSuites!) await buildSuite; + resolveBuildPhase(); + }, + }; + // Node's harness.bootstrapPromise = harness.runBootstrap(): runBootstrap only does + // real async work for options.globalSetupPath (unwired here — run()'s own + // globalSetupPath option validates but is never consumed, see runner.ts), so this + // seeds the same already-resolved promise runBootstrap() would produce for every + // path this kernel reaches. startSubtestAfterBootstrap (below) awaits it before + // starting any top-level test; runner.ts's runIsolatedNone replaces it with one that + // stays pending until every test file has loaded. + harness.bootstrapPromise = Promise.resolve(); + const root = new Test({ ...rootOptions, harness, name: "" }); + root.startTime = performance.now(); + setupProcessState(root); + if (opts.autoReport !== false) { + // Node's parseCommandLine().setup(): with no --test-reporter, the one reporter is + // "spec" (lib/internal/test_runner/utils.js's kDefaultReporter) piped to stdout + // (kDefaultDestination) via setupTestReporters's `compose(rootReporter, + // reporter).pipe(destination)` — not a plain `root.reporter.pipe(spec).pipe(stdout)`. + // That matters: compose()'s pull-based wiring (see stream.ts's compose) defers each + // formatted line by the same nextTick hop Node's does, letting the next test's own + // synchronous console.log interleave first (DESIGN: confirmed against real Node + // 24.16.0; see tests/fixtures/guests/builtin-test-order.cjs). + compose(root.reporter, new SpecReporter() as unknown as PipeDestination) + .pipe(proc.stdout as unknown as PipeDestination); + root.reporter.on("test:summary", (data: unknown) => { + if (!(data as { success?: boolean }).success) proc.exitCode = 1; + }); + } + globalRoot = root; + return root; +} + +function setupProcessState(root: Test): void { + if (installed) return; + installed = true; + const proc = boundProcess!; + + const handleAsyncError = (eventName: "uncaughtException" | "unhandledRejection") => (err: unknown): void => { + const test = getCurrentlyRunningTest() ?? root; + if (!test || test.finished) { + root.diagnostic( + `Error: A resource generated asynchronous activity after the test ended. ` + + `This activity created the error "${String(err)}" which triggered an ${eventName} event, ` + + "caught by the test runner.", + ); + root.harness!.success = false; + proc.exitCode = 1; + return; + } + test.fail(testFailure(err, eventName)); + test.abortController.abort(); + }; + + proc.on("uncaughtException", handleAsyncError("uncaughtException")); + proc.on("unhandledRejection", handleAsyncError("unhandledRejection")); + + // Node's exitHandler, invoked on 'beforeExit': if the root never got any top-level + // tests but registered global before/after hooks (`before()`/`after()` called at + // module scope, outside any test()), run it once so those hooks still execute. Then + // wait for any still-pending subtests (there should be none by the time the kernel's + // loop considers itself idle, since every test here holds the loop — see + // src/runtime/loop.ts — but a ref'ed handle a test created deliberately can still be + // keeping the process alive) before giving root.postRun() the final word. + proc.on("beforeExit", () => { + void (async () => { + if (root.subtests.length === 0 && (root.hooks.before.length > 0 || root.hooks.after.length > 0)) { + await root.run(); + } + if (root.subtestsPromise !== null) { + // Node's harness.js keeps a ref'd setInterval alive for exactly this wait + // (`const keepAlive = setInterval(() => {}, TIMEOUT_MAX)`): without it, an + // unref'd per-test timeout timer (test.ts's stopTest) never gets a chance to + // fire — nothing else here holds the kernel's loop open while a test's own + // promise never settles (confirmed against real Node 24.16.0: deleting this + // and keeping only the unref'd timeout timer makes a hung test's timeout + // silently never fire and the process exit immediately instead). + const keepAlive = setInterval(() => {}, 2_147_483_647); + await root.subtestsPromise.promise; + clearInterval(keepAlive); + } + root.postRun(testFailure( + "Promise resolution is still pending but the event loop has already resolved", + kCancelledByParent, + )); + })(); + }); +} + +function lazyBootstrapRoot(): Test { + if (!globalRoot) { + const proc = boundProcess!; + const entryFile = proc.argv[1]; + createTestTree({ entryFile, loc: entryFile ? [1, 1, entryFile] : undefined }, commandLineDefaults(proc.cwd())); + } + return globalRoot!; +} + +/** Where a bare top-level test()/describe()/before()/... call attaches: the Suite + * currently (synchronously) building its body (test.ts's currentBuildingParent), or + * the lazily-bootstrapped root. */ +function currentParent(): Test { + return currentBuildingParent() ?? lazyBootstrapRoot(); +} + +/** Node's harness.js startSubtestAfterBootstrap: where every top-level test()/ + * describe() call actually starts a subtest, gated on two separate promises in + * sequence. + * + * First `root.harness.bootstrapPromise` (nulled out once awaited, so only the first + * wave of callers actually waits) — the entire race fix for run()'s isolation:'none' + * (runner.ts's runIsolatedNone): no top-level test can start — and so none can finish + * and trigger finalize()'s "every top-level test is done" check — before that promise + * resolves, which it only does once every test file has loaded. + * + * Then `root.harness.buildPromise`: every top-level describe()'s own `buildSuite` + * promise is pushed onto `harness.buildSuites` here, as each one registers; the first + * registration in a synchronous turn also queues a microtask (`waitForBuildPhase`) + * that awaits all of them together once that turn's synchronous code has finished + * running (and so every top-level call made in it has had a chance to push its own + * buildSuite first). Only once every one of those builds has settled does + * `waitForBuildPhase` resolve `buildPromise`, letting every startSubtestAfterBootstrap + * call that's parked here resume and finally call `subtest.start()` — in the order + * they were registered. + * + * Without this second gate (ported here after 6d4b5df, which only had the first), a + * top-level describe() whose builder callback awaits (an "async describe") lets + * sibling top-level tests that build synchronously start and finish first — so the + * spec reporter prints them, flat, before the async describe's own subtests were even + * attached (confirmed against real Node 24.16.0: see + * tests/fixtures/guests/builtin-test-async-describe.cjs). */ +export async function startSubtestAfterBootstrap(subtest: Test): Promise { + const harness = subtest.root.harness!; + if (harness.buildPromise) { + if (harness.bootstrapPromise) { + await harness.bootstrapPromise; + harness.bootstrapPromise = null; + } + if (subtest instanceof Suite) harness.buildSuites!.push(subtest.buildSuite); + if (!harness.isWaitingForBuildPhase) { + harness.isWaitingForBuildPhase = true; + queueMicrotask(() => { + void harness.waitForBuildPhase!(); + }); + } + await harness.buildPromise; + harness.buildPromise = null; + } + await subtest.start(); +} + +function runInParentContext(Factory: typeof Test) { + function run(name: unknown, options: unknown, fn: unknown, overrides: Partial): Promise { + const parent = currentParent(); + const subtest = parent.createSubtest(Factory, name, options, fn, overrides); + if (parent instanceof Suite) return Promise.resolve(); + return startSubtestAfterBootstrap(subtest); + } + + const test = (name?: unknown, options?: unknown, fn?: unknown): Promise => + run(name, options, fn, { loc: getCallerLocation() }); + for (const keyword of ["expectFailure", "skip", "todo", "only"] as const) { + (test as unknown as Record)[keyword] = (name?: unknown, options?: unknown, fn?: unknown) => + run(name, options, fn, { [keyword]: true, loc: getCallerLocation() }); + } + return test as typeof test & { expectFailure: typeof test; skip: typeof test; todo: typeof test; only: typeof test }; +} + +function hook(hookName: "before" | "after" | "beforeEach" | "afterEach") { + return (fn: TestFn, options?: unknown): void => { + const parent = currentParent(); + parent.createHook(hookName, fn, { ...(options as object), parent, hookType: hookName, loc: getCallerLocation() }); + }; +} + +export const test = runInParentContext(Test); +export const suite = runInParentContext(Suite); +export const before = hook("before"); +export const after = hook("after"); +export const beforeEach = hook("beforeEach"); +export const afterEach = hook("afterEach"); +export { lazyBootstrapRoot }; diff --git a/packages/kernel/src/runtime/test_runner/mock/mock.ts b/packages/kernel/src/runtime/test_runner/mock/mock.ts new file mode 100644 index 0000000..efb6e16 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/mock/mock.ts @@ -0,0 +1,409 @@ +// 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/mock/mock.js: MockFunctionContext, MockPropertyContext and +// MockTracker (fn/method/getter/setter/property/reset/restoreAll/timers). +// +// mock.module() is deliberately absent, not refused: Node itself deletes +// MockTracker.prototype.module unless `--experimental-test-module-mocks` is passed +// (mock.js's `if (!enableModuleMocking) delete MockTracker.prototype.module;`), and +// that flag defaults to false — confirmed against real Node 24.16.0 +// (`typeof require('node:test').mock.module === 'undefined'` with no flags). Since +// every CLI flag here takes Node's default (DESIGN: no flag parser), the exact match +// is to not add the method at all; compat/node-24.16.0.json's manifest agrees (no +// "test.mock#module" entry to match). +import { invalidArgType, invalidArgValue, outOfRange } from "../../errors"; +import { MockTimers } from "./mock_timers"; + +const kEmptyObject = Object.freeze({}); +function kDefaultFunction(): void {} + +// No shared validateFunction/validateObject/validateBoolean/validateInteger copy +// exists in src/runtime (every module that needs internal/validators.js checks keeps +// its own small copy — see test.ts's validateUint32/validateNumber, histogram.ts's +// validateInteger, util/parse-args.ts's validateBoolean/validateObject). +function validateFunction(value: unknown, name: string): asserts value is (...args: unknown[]) => unknown { + if (typeof value !== "function") throw invalidArgType(name, "of type function", value); +} +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 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 validateStringOrSymbol(value: unknown, name: string): void { + if (typeof value !== "string" && typeof value !== "symbol") throw invalidArgType(name, "one of type string or symbol", value); +} +function validateTimes(value: unknown, name: string): void { + if (value === Infinity) return; + validateInteger(value, name, 1); +} + +export interface MockCall { + arguments: unknown[]; + error: unknown; + result: unknown; + stack: Error; + target: unknown; + this: unknown; +} + +interface Restore { + descriptor?: PropertyDescriptor; + object?: object; + methodName?: string | symbol; + original?: (...args: unknown[]) => unknown; +} + +/** Node deletes `trackCall`/`nextImpl` off the prototype after capturing them, so a + * guest's own `mock.fn().mock` object never exposes them; the two bound helpers below + * (module-private, not on the class) stand in for that capture-then-delete. */ +export class MockFunctionContext { + #calls: MockCall[] = []; + #mocks = new Map unknown>(); + #implementation: (...args: unknown[]) => unknown; + #restore: Restore; + #times: number; + + constructor(implementation: (...args: unknown[]) => unknown, restore: Restore, times: number) { + this.#implementation = implementation; + this.#restore = restore; + this.#times = times; + } + + get calls(): MockCall[] { + return this.#calls.slice(0); + } + + callCount(): number { + return this.#calls.length; + } + + mockImplementation(implementation: unknown): void { + validateFunction(implementation, "implementation"); + this.#implementation = implementation; + } + + mockImplementationOnce(implementation: unknown, onCall?: number): void { + validateFunction(implementation, "implementation"); + const nextCall = this.#calls.length; + const call = onCall ?? nextCall; + validateInteger(call, "onCall", nextCall); + this.#mocks.set(call, implementation); + } + + restore(): void { + const { descriptor, object, original, methodName } = this.#restore; + if (typeof methodName === "string" || typeof methodName === "symbol") { + Object.defineProperty(object!, methodName, descriptor!); + } else { + this.#implementation = original!; + } + } + + resetCalls(): void { + this.#calls = []; + } + + #trackCall(call: MockCall): void { + this.#calls.push(call); + } + + #nextImpl(): (...args: unknown[]) => unknown { + const nextCall = this.#calls.length; + const mock = this.#mocks.get(nextCall); + const impl = mock ?? this.#implementation; + if (nextCall + 1 === this.#times) this.restore(); + this.#mocks.delete(nextCall); + return impl; + } + + /** Exposed to MockTracker#setupMock only — the proxy traps below are the only other + * callers, mirroring Node's capture-then-delete of trackCall/nextImpl. */ + static _trackCall(ctx: MockFunctionContext, call: MockCall): void { + ctx.#trackCall(call); + } + static _nextImpl(ctx: MockFunctionContext): (...args: unknown[]) => unknown { + return ctx.#nextImpl(); + } +} + +export class MockPropertyContext { + #object: Record; + #propertyName: string | symbol; + #value: unknown; + #originalValue: unknown; + #descriptor: PropertyDescriptor; + #accesses: Array<{ type: "get" | "set"; value: unknown; stack: Error }> = []; + #onceValues = new Map(); + + constructor(object: Record, propertyName: string | symbol, value?: unknown) { + this.#object = object; + this.#propertyName = propertyName; + this.#originalValue = object[propertyName]; + this.#value = arguments.length > 2 ? value : this.#originalValue; + const descriptor = Object.getOwnPropertyDescriptor(object, propertyName); + if (!descriptor) throw invalidArgValue("propertyName", propertyName, "is not a property of the object"); + this.#descriptor = descriptor; + + const { configurable, enumerable } = descriptor; + Object.defineProperty(object, propertyName, { + configurable, + enumerable, + get: () => { + const nextValue = this.#getAccessValue(this.#value); + this.#accesses.push({ type: "get", value: nextValue, stack: new Error() }); + return nextValue; + }, + set: this.mockImplementation.bind(this), + }); + } + + get accesses(): Array<{ type: "get" | "set"; value: unknown; stack: Error }> { + return this.#accesses.slice(0); + } + + accessCount(): number { + return this.#accesses.length; + } + + mockImplementation(value: unknown): void { + if (!this.#descriptor.writable) throw invalidArgValue("propertyName", this.#propertyName, "cannot be set"); + const nextValue = this.#getAccessValue(value); + this.#accesses.push({ type: "set", value: nextValue, stack: new Error() }); + this.#value = nextValue; + } + + #getAccessValue(value: unknown): unknown { + const accessIndex = this.#accesses.length; + if (this.#onceValues.has(accessIndex)) { + const v = this.#onceValues.get(accessIndex); + this.#onceValues.delete(accessIndex); + return v; + } + return value; + } + + mockImplementationOnce(value: unknown, onAccess?: number): void { + const nextAccess = this.#accesses.length; + const accessIndex = onAccess ?? nextAccess; + validateInteger(accessIndex, "onAccess", nextAccess); + this.#onceValues.set(accessIndex, value); + } + + resetAccesses(): void { + this.#accesses = []; + } + + restore(): void { + Object.defineProperty(this.#object, this.#propertyName, { ...this.#descriptor, value: this.#originalValue }); + } +} + +export class MockTracker { + #mocks: Array<{ restore(): void }> = []; + #timers: MockTimers | undefined; + + get timers(): MockTimers { + return (this.#timers ??= new MockTimers()); + } + + fn( + original: unknown = function (): void {}, + implementation: unknown = original, + options: unknown = kEmptyObject, + ): (...args: unknown[]) => unknown { + if (original !== null && typeof original === "object") { + options = original; + original = function (): void {}; + implementation = original; + } else if (implementation !== null && typeof implementation === "object") { + options = implementation; + implementation = original; + } + + validateFunction(original, "original"); + validateFunction(implementation, "implementation"); + validateObject(options, "options"); + const { times = Infinity } = options as { times?: number }; + validateTimes(times, "options.times"); + const ctx = new MockFunctionContext(implementation, { original: original as (...args: unknown[]) => unknown }, times); + return this.#setupMock(ctx, original as (...args: unknown[]) => unknown); + } + + method( + objectOrFunction: unknown, + methodName: unknown, + implementation: unknown = kDefaultFunction, + options: unknown = kEmptyObject, + ): (...args: unknown[]) => unknown { + validateStringOrSymbol(methodName, "methodName"); + if (typeof objectOrFunction !== "function") validateObject(objectOrFunction, "object"); + + if (implementation !== null && typeof implementation === "object") { + options = implementation; + implementation = kDefaultFunction; + } + + validateFunction(implementation, "implementation"); + validateObject(options, "options"); + + const { getter = false, setter = false, times = Infinity } = options as { getter?: boolean; setter?: boolean; times?: number }; + validateBoolean(getter, "options.getter"); + validateBoolean(setter, "options.setter"); + validateTimes(times, "options.times"); + + if (setter && getter) throw invalidArgValue("options.setter", setter, "cannot be used with 'options.getter'"); + + const target = objectOrFunction as Record; + const descriptor = findMethodOnPrototypeChain(target, methodName as string | symbol); + + let original: unknown; + if (getter) original = descriptor?.get; + else if (setter) original = descriptor?.set; + else original = descriptor?.value; + + if (typeof original !== "function") throw invalidArgValue("methodName", original, "must be a method"); + + const restore: Restore = { descriptor, object: target, methodName: methodName as string | symbol }; + const impl = implementation === kDefaultFunction ? original : implementation; + const ctx = new MockFunctionContext(impl as (...args: unknown[]) => unknown, restore, times); + const mock = this.#setupMock(ctx, original as (...args: unknown[]) => unknown); + const mockDescriptor: PropertyDescriptor = { configurable: descriptor!.configurable, enumerable: descriptor!.enumerable }; + + if (getter) { + mockDescriptor.get = mock; + mockDescriptor.set = descriptor!.set; + } else if (setter) { + mockDescriptor.get = descriptor!.get; + mockDescriptor.set = mock; + } else { + mockDescriptor.writable = descriptor!.writable; + mockDescriptor.value = mock; + } + + Object.defineProperty(target, methodName as string | symbol, mockDescriptor); + return mock; + } + + getter( + object: unknown, + methodName: unknown, + implementation: unknown = kDefaultFunction, + options: unknown = kEmptyObject, + ): (...args: unknown[]) => unknown { + if (implementation !== null && typeof implementation === "object") { + options = implementation; + implementation = kDefaultFunction; + } else { + validateObject(options, "options"); + } + const { getter = true } = options as { getter?: boolean }; + if (getter === false) throw invalidArgValue("options.getter", getter, "cannot be false"); + return this.method(object, methodName, implementation, { ...(options as object), getter }); + } + + setter( + object: unknown, + methodName: unknown, + implementation: unknown = kDefaultFunction, + options: unknown = kEmptyObject, + ): (...args: unknown[]) => unknown { + if (implementation !== null && typeof implementation === "object") { + options = implementation; + implementation = kDefaultFunction; + } else { + validateObject(options, "options"); + } + const { setter = true } = options as { setter?: boolean }; + if (setter === false) throw invalidArgValue("options.setter", setter, "cannot be false"); + return this.method(object, methodName, implementation, { ...(options as object), setter }); + } + + property(object: unknown, propertyName: unknown, value?: unknown): unknown { + validateObject(object, "object"); + validateStringOrSymbol(propertyName, "propertyName"); + + const ctx = + arguments.length > 2 + ? new MockPropertyContext(object as Record, propertyName as string | symbol, value) + : new MockPropertyContext(object as Record, propertyName as string | symbol); + this.#mocks.push({ restore: () => ctx.restore() }); + + return new Proxy(object as object, { + get(target, property, receiver) { + if (property === "mock") return ctx; + return Reflect.get(target, property, receiver); + }, + }); + } + + reset(): void { + this.restoreAll(); + this.#timers?.reset(); + this.#mocks = []; + } + + restoreAll(): void { + for (const { restore } of this.#mocks) restore(); + } + + #setupMock(ctx: MockFunctionContext, fnToMatch: (...args: unknown[]) => unknown): (...args: unknown[]) => unknown { + const mock = new Proxy(fnToMatch, { + apply(_fn, thisArg, argList) { + const fn = MockFunctionContext._nextImpl(ctx); + let result: unknown; + let error: unknown; + try { + result = Reflect.apply(fn, thisArg, argList); + } catch (err) { + error = err; + throw err; + } finally { + MockFunctionContext._trackCall(ctx, { arguments: argList, error, result, stack: new Error(), target: undefined, this: thisArg }); + } + return result; + }, + construct(target, argList, newTarget) { + const realTarget = MockFunctionContext._nextImpl(ctx); + let result: unknown; + let error: unknown; + try { + result = Reflect.construct(realTarget as unknown as new (...a: unknown[]) => unknown, argList, newTarget); + } catch (err) { + error = err; + throw err; + } finally { + MockFunctionContext._trackCall(ctx, { arguments: argList, error, result, stack: new Error(), target, this: result }); + } + return result as object; + }, + get(target, property, receiver) { + if (property === "mock") return ctx; + return Reflect.get(target, property, receiver); + }, + }); + + this.#mocks.push({ restore: () => ctx.restore() }); + return mock; + } +} + +function findMethodOnPrototypeChain(instance: object, methodName: string | symbol): PropertyDescriptor | undefined { + let host: object | null = instance; + let descriptor: PropertyDescriptor | undefined; + while (host !== null) { + descriptor = Object.getOwnPropertyDescriptor(host, methodName); + if (descriptor) break; + host = Object.getPrototypeOf(host); + } + return descriptor; +} + +export function createMockTracker(): MockTracker { + return new MockTracker(); +} diff --git a/packages/kernel/src/runtime/test_runner/mock/mock_timers.ts b/packages/kernel/src/runtime/test_runner/mock/mock_timers.ts new file mode 100644 index 0000000..6fc6483 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/mock/mock_timers.ts @@ -0,0 +1,575 @@ +// 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/mock/mock_timers.js: fake setTimeout/setInterval/ +// setImmediate/Date/scheduler.wait/AbortSignal.timeout, patched onto globalThis, the +// node:timers module object and the node:timers/promises module object the same way +// Node patches all three independently (there is no aliasing to rely on — see +// TimerHooks below). A simple sorted-array queue stands in for Node's binary-heap +// PriorityQueue: same tie-break (runAt, then id), different storage, invisible to the +// public API. +import { invalidArgType, invalidArgValue, invalidState, outOfRange } from "../../errors"; + +const kInitialEpoch = 0; +const TIMEOUT_MAX = 2 ** 31 - 1; +const MAX_UINT32 = 4_294_967_295; + +export const SUPPORTED_APIS = ["setTimeout", "setInterval", "setImmediate", "Date", "scheduler.wait", "AbortSignal.timeout"] as const; +type SupportedApi = (typeof SUPPORTED_APIS)[number]; + +// No shared validate* copies exist in src/runtime (every module that needs +// internal/validators.js checks keeps its own small copy — see test.ts's +// validateUint32/validateNumber/validateAbortSignal, this module's own near-duplicates +// of the same three plus validateStringArray). +function validateNumber(value: unknown, name: string, min?: number, max?: number): void { + if (typeof value !== "number") throw invalidArgType(name, "of type number", value); + if ((min != null && value < min) || (max != null && value > max) || ((min != null || max != null) && Number.isNaN(value))) { + const range = `${min != null ? `>= ${min}` : ""}${min != null && max != null ? " && " : ""}${max != null ? `<= ${max}` : ""}`; + throw outOfRange(name, range, value); + } +} +function validateUint32(value: unknown, name: string, positive = false): void { + if (typeof value !== "number") throw invalidArgType(name, "of type number", value); + if (!Number.isInteger(value)) throw outOfRange(name, "an integer", value); + const min = positive ? 1 : 0; + if (value < min || value > MAX_UINT32) throw outOfRange(name, `>= ${min} && <= ${MAX_UINT32}`, value); +} +function validateStringArray(value: unknown, name: string): void { + if (!Array.isArray(value)) throw invalidArgType(name, "an instance of Array", value); + for (let i = 0; i < value.length; i++) if (typeof value[i] !== "string") throw invalidArgType(`${name}[${i}]`, "of type string", value[i]); +} +function validateAbortSignal(signal: unknown, name: string): void { + if (signal !== undefined && (signal === null || typeof signal !== "object" || !("aborted" in signal))) { + throw invalidArgType(name, "an instance of AbortSignal", signal); + } +} +function abortIt(signal: AbortSignal): Error { + return Object.assign(new Error("The operation was aborted"), { name: "AbortError", code: "ABORT_ERR", cause: signal.reason }); +} + +/** What `enable()`/`reset()` need to reach, independently: the globals Node installs + * (globalThis.setTimeout, ...), node:timers' own module object (require("timers") is + * not the same object as globalThis — assigning one does not change the other), and + * node:timers/promises' module object. Bound once per worker from builtins.ts, the + * way harness.ts's bindProcess binds the process object. */ +export interface TimerHooks { + globalTarget: Record; + nodeTimers(): Record; + nodeTimersPromises(): { setTimeout: unknown; setInterval: unknown; setImmediate: unknown; scheduler: { wait: unknown } }; + abortSignal(): { timeout: unknown } | undefined; +} + +let hooks: TimerHooks | null = null; +export function bindTimerHooks(h: TimerHooks): void { + hooks = h; +} + +interface TimeoutOpts { + id: number; + callback: (...args: unknown[]) => void; + runAt: number; + interval: number | undefined; + args: unknown[]; + clear: (t: Timeout) => void; +} + +class Timeout { + id: number; + callback: (...args: unknown[]) => void; + runAt: number; + interval: number | undefined; + args: unknown[]; + #clear: (t: Timeout) => void; + + constructor(opts: TimeoutOpts) { + this.id = opts.id; + this.callback = opts.callback; + this.runAt = opts.runAt; + this.interval = opts.interval; + this.args = opts.args; + this.#clear = opts.clear; + } + + hasRef(): boolean { + return true; + } + ref(): this { + return this; + } + unref(): this { + return this; + } + refresh(): this { + return this; + } + close(): this { + this.#clear(this); + return this; + } + [Symbol.dispose](): void { + this.#clear(this); + } +} + +/** Node's PriorityQueue, collapsed to a sorted array: same (runAt, then id) order, + * same operations (peek/peekBottom/shift/remove), no binary heap. */ +class TimerQueue { + #items: Timeout[] = []; + get size(): number { + return this.#items.length; + } + insert(t: Timeout): void { + let i = this.#items.length; + while (i > 0 && compareTimers(this.#items[i - 1]!, t) > 0) i--; + this.#items.splice(i, 0, t); + } + peek(): Timeout | undefined { + return this.#items[0]; + } + peekBottom(): Timeout | undefined { + return this.#items[this.#items.length - 1]; + } + shift(): Timeout | undefined { + return this.#items.shift(); + } + remove(t: Timeout): void { + const i = this.#items.indexOf(t); + if (i !== -1) this.#items.splice(i, 1); + } + clear(): void { + this.#items = []; + } +} +function compareTimers(a: Timeout, b: Timeout): number { + return a.runAt - b.runAt || (a.id - b.id); +} + +type Descriptor = PropertyDescriptor | undefined; + +export class MockTimers { + #realSetTimeout: Descriptor; + #realClearTimeout: Descriptor; + #realSetInterval: Descriptor; + #realClearInterval: Descriptor; + #realSetImmediate: Descriptor; + #realClearImmediate: Descriptor; + + #realTimersSetTimeout: Descriptor; + #realTimersClearTimeout: Descriptor; + #realTimersSetInterval: Descriptor; + #realTimersClearInterval: Descriptor; + #realTimersSetImmediate: Descriptor; + #realTimersClearImmediate: Descriptor; + + #realPromisifiedSetTimeout: Descriptor; + #realPromisifiedSetInterval: Descriptor; + #realPromisifiedSetImmediate: Descriptor; + #realTimersPromisifiedSchedulerWait: ((...args: unknown[]) => unknown) | undefined; + + #nativeDateDescriptor: Descriptor; + #realAbortSignalTimeout: Descriptor; + + #timersInContext: SupportedApi[] = []; + #isEnabled = false; + #currentTimer = 1; + #now = kInitialEpoch; + + #executionQueue = new TimerQueue(); + + #setTimeout = (callback: (...args: unknown[]) => void, delay: unknown, ...args: unknown[]): Timeout => + this.#createTimer(false, callback, delay, ...args); + #clearTimeout = (t: unknown): void => this.#clearTimer(t); + #setInterval = (callback: (...args: unknown[]) => void, delay: unknown, ...args: unknown[]): Timeout => + this.#createTimer(true, callback, delay, ...args); + #clearInterval = (t: unknown): void => this.#clearTimer(t); + #clearImmediate = (t: unknown): void => this.#clearTimer(t); + + #restoreSetImmediate(): void { + const h = hooks!; + Object.defineProperty(h.globalTarget, "setImmediate", this.#realSetImmediate!); + Object.defineProperty(h.globalTarget, "clearImmediate", this.#realClearImmediate!); + const nodeTimers = h.nodeTimers(); + Object.defineProperty(nodeTimers, "setImmediate", this.#realTimersSetImmediate!); + Object.defineProperty(nodeTimers, "clearImmediate", this.#realTimersClearImmediate!); + Object.defineProperty(h.nodeTimersPromises(), "setImmediate", this.#realPromisifiedSetImmediate!); + } + #restoreOriginalSetInterval(): void { + const h = hooks!; + Object.defineProperty(h.globalTarget, "setInterval", this.#realSetInterval!); + Object.defineProperty(h.globalTarget, "clearInterval", this.#realClearInterval!); + const nodeTimers = h.nodeTimers(); + Object.defineProperty(nodeTimers, "setInterval", this.#realTimersSetInterval!); + Object.defineProperty(nodeTimers, "clearInterval", this.#realTimersClearInterval!); + Object.defineProperty(h.nodeTimersPromises(), "setInterval", this.#realPromisifiedSetInterval!); + } + #restoreOriginalSchedulerWait(): void { + hooks!.nodeTimersPromises().scheduler.wait = this.#realTimersPromisifiedSchedulerWait; + } + #restoreOriginalSetTimeout(): void { + const h = hooks!; + Object.defineProperty(h.globalTarget, "setTimeout", this.#realSetTimeout!); + Object.defineProperty(h.globalTarget, "clearTimeout", this.#realClearTimeout!); + const nodeTimers = h.nodeTimers(); + Object.defineProperty(nodeTimers, "setTimeout", this.#realTimersSetTimeout!); + Object.defineProperty(nodeTimers, "clearTimeout", this.#realTimersClearTimeout!); + Object.defineProperty(h.nodeTimersPromises(), "setTimeout", this.#realPromisifiedSetTimeout!); + } + #storeOriginalSetImmediate(): void { + const h = hooks!; + this.#realSetImmediate = Object.getOwnPropertyDescriptor(h.globalTarget, "setImmediate"); + this.#realClearImmediate = Object.getOwnPropertyDescriptor(h.globalTarget, "clearImmediate"); + const nodeTimers = h.nodeTimers(); + this.#realTimersSetImmediate = Object.getOwnPropertyDescriptor(nodeTimers, "setImmediate"); + this.#realTimersClearImmediate = Object.getOwnPropertyDescriptor(nodeTimers, "clearImmediate"); + this.#realPromisifiedSetImmediate = Object.getOwnPropertyDescriptor(h.nodeTimersPromises(), "setImmediate"); + } + #storeOriginalSetInterval(): void { + const h = hooks!; + this.#realSetInterval = Object.getOwnPropertyDescriptor(h.globalTarget, "setInterval"); + this.#realClearInterval = Object.getOwnPropertyDescriptor(h.globalTarget, "clearInterval"); + const nodeTimers = h.nodeTimers(); + this.#realTimersSetInterval = Object.getOwnPropertyDescriptor(nodeTimers, "setInterval"); + this.#realTimersClearInterval = Object.getOwnPropertyDescriptor(nodeTimers, "clearInterval"); + this.#realPromisifiedSetInterval = Object.getOwnPropertyDescriptor(h.nodeTimersPromises(), "setInterval"); + } + #storeOriginalSchedulerWait(): void { + this.#realTimersPromisifiedSchedulerWait = hooks!.nodeTimersPromises().scheduler.wait as (...args: unknown[]) => unknown; + } + #storeOriginalSetTimeout(): void { + const h = hooks!; + this.#realSetTimeout = Object.getOwnPropertyDescriptor(h.globalTarget, "setTimeout"); + this.#realClearTimeout = Object.getOwnPropertyDescriptor(h.globalTarget, "clearTimeout"); + const nodeTimers = h.nodeTimers(); + this.#realTimersSetTimeout = Object.getOwnPropertyDescriptor(nodeTimers, "setTimeout"); + this.#realTimersClearTimeout = Object.getOwnPropertyDescriptor(nodeTimers, "clearTimeout"); + this.#realPromisifiedSetTimeout = Object.getOwnPropertyDescriptor(h.nodeTimersPromises(), "setTimeout"); + } + #storeOriginalAbortSignalTimeout(): void { + const target = hooks!.abortSignal(); + if (target) this.#realAbortSignalTimeout = Object.getOwnPropertyDescriptor(target, "timeout"); + } + #restoreOriginalAbortSignalTimeout(): void { + const target = hooks!.abortSignal(); + if (target && this.#realAbortSignalTimeout) Object.defineProperty(target, "timeout", this.#realAbortSignalTimeout); + } + + #createTimer(isInterval: boolean, callback: (...args: unknown[]) => void, delay: unknown, ...args: unknown[]): Timeout { + // Node's own #createTimer doesn't coerce or clamp `delay` beyond this one check + // (confirmed against real Node 24.16.0: setTimeout(fn, -5) and setTimeout(fn) with + // no delay both fire on the very next tick(), not on tick(1)). + let ms = delay as number; + if (ms > TIMEOUT_MAX) ms = 1; + + const timerId = this.#currentTimer++; + const timer = new Timeout({ + id: timerId, + callback, + runAt: this.#now + ms, + interval: isInterval ? ms : undefined, + args, + clear: (t) => this.#executionQueue.remove(t), + }); + this.#executionQueue.insert(timer); + return timer; + } + + #clearTimer(timer: unknown): void { + if (timer instanceof Timeout) { + this.#executionQueue.remove(timer); + timer.interval = undefined; + } + } + + #createDate(): DateConstructor { + const NativeDate = this.#nativeDateDescriptor!.value as DateConstructor; + if ((NativeDate as unknown as { isMock?: boolean }).isMock) throw invalidState("Date is already being mocked!"); + const self = this; + + function MockDate(this: unknown, ...dateArgs: unknown[]): Date | string { + if (!(new.target)) return new NativeDate(self.#now).toString(); + switch (dateArgs.length) { + case 0: + return new NativeDate(self.#now) as unknown as Date; + case 1: + return new NativeDate(dateArgs[0] as number) as unknown as Date; + default: + return new (NativeDate as unknown as new (...a: unknown[]) => Date)(...dateArgs); + } + } + const ctor = MockDate as unknown as DateConstructor & { isMock: boolean }; + const { prototype: _proto, ...dateProps } = Object.getOwnPropertyDescriptors(NativeDate); + Object.defineProperties(ctor, dateProps); + (ctor as unknown as { now(): number }).now = () => self.#now; + ctor.toString = () => NativeDate.toString(); + Object.defineProperties(ctor, { + isMock: { enumerable: true, configurable: false, writable: false, value: true }, + }); + Object.defineProperty(ctor, "prototype", { value: NativeDate.prototype }); + ctor.parse = NativeDate.parse; + ctor.UTC = NativeDate.UTC; + return ctor; + } + + async *#setIntervalPromisified(interval: number, result: unknown, options?: { signal?: AbortSignal }): AsyncGenerator { + const signal = options?.signal; + if (signal) { + validateAbortSignal(signal, "options.signal"); + if (signal.aborted) throw abortIt(signal); + } + let notYielded = 0; + let wake: (() => void) | undefined; + const timer = this.#createTimer( + true, + () => { + notYielded++; + wake?.(); + wake = undefined; + }, + interval, + ); + const onAbort = (): void => { + wake?.(); + wake = undefined; + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + while (!signal?.aborted) { + if (notYielded === 0) await new Promise((resolve) => (wake = resolve)); + for (; notYielded > 0; notYielded--) yield result; + } + throw abortIt(signal!); + } finally { + signal?.removeEventListener("abort", onAbort); + this.#clearInterval(timer); + } + } + + #setImmediate(callback: (...args: unknown[]) => void, ...args: unknown[]): Timeout { + return this.#createTimer(false, callback, -1, ...args); + } + + async #promisifyTimer(opts: { + start: (resolve: (v: unknown) => void, ms: unknown) => Timeout; + clear: (t: Timeout) => void; + ms: unknown; + result: unknown; + options?: { signal?: AbortSignal }; + }): Promise { + const signal = opts.options?.signal; + if (signal) { + validateAbortSignal(signal, "options.signal"); + if (signal.aborted) throw abortIt(signal); + } + let resolve!: (v: unknown) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const onAbort = (): void => reject(abortIt(signal!)); + signal?.addEventListener("abort", onAbort, { once: true }); + const timer = opts.start(resolve, opts.ms); + try { + await promise; + return opts.result; + } finally { + signal?.removeEventListener("abort", onAbort); + opts.clear(timer); + } + } + + #setImmediatePromisified(result: unknown, options?: { signal?: AbortSignal }): Promise { + return this.#promisifyTimer({ + start: (resolve) => this.#setImmediate(() => resolve(undefined)), + clear: (t) => this.#clearImmediate(t), + ms: -1, + result, + options, + }); + } + + #setTimeoutPromisified(ms: unknown, result: unknown, options?: { signal?: AbortSignal }): Promise { + return this.#promisifyTimer({ + start: (resolve, delay) => this.#setTimeout(() => resolve(undefined), delay), + clear: (t) => this.#clearTimeout(t), + ms, + result, + options, + }); + } + + #assertTimersAreEnabled(): void { + if (!this.#isEnabled) throw invalidState("You should enable MockTimers first by calling the .enable function"); + } + #assertTimeArg(time: number): void { + if (time < 0) throw invalidArgValue("time", "positive integer", time); + } + #isValidDateWithGetTime(maybeDate: unknown): boolean { + try { + Date.prototype.getTime.call(maybeDate); + return true; + } catch { + return false; + } + } + + #toggleEnableTimers(activate: boolean): void { + const toFake: Record void> = { + "scheduler.wait": () => { + this.#storeOriginalSchedulerWait(); + hooks!.nodeTimersPromises().scheduler.wait = (delay: unknown, options?: { signal?: AbortSignal }) => + this.#setTimeoutPromisified(delay, undefined, options); + }, + setTimeout: () => { + this.#storeOriginalSetTimeout(); + const h = hooks!; + h.globalTarget.setTimeout = this.#setTimeout; + h.globalTarget.clearTimeout = this.#clearTimeout; + const nodeTimers = h.nodeTimers(); + nodeTimers.setTimeout = this.#setTimeout; + nodeTimers.clearTimeout = this.#clearTimeout; + h.nodeTimersPromises().setTimeout = (ms: unknown, result: unknown, options?: { signal?: AbortSignal }) => + this.#setTimeoutPromisified(ms, result, options); + }, + setInterval: () => { + this.#storeOriginalSetInterval(); + const h = hooks!; + h.globalTarget.setInterval = this.#setInterval; + h.globalTarget.clearInterval = this.#clearInterval; + const nodeTimers = h.nodeTimers(); + nodeTimers.setInterval = this.#setInterval; + nodeTimers.clearInterval = this.#clearInterval; + h.nodeTimersPromises().setInterval = (after: number, result: unknown, options?: { signal?: AbortSignal }) => + this.#setIntervalPromisified(after, result, options); + }, + setImmediate: () => { + this.#storeOriginalSetImmediate(); + const h = hooks!; + h.globalTarget.setImmediate = (callback: (...a: unknown[]) => void, ...a: unknown[]) => this.#setImmediate(callback, ...a); + h.globalTarget.clearImmediate = this.#clearImmediate; + const nodeTimers = h.nodeTimers(); + nodeTimers.setImmediate = (callback: (...a: unknown[]) => void, ...a: unknown[]) => this.#setImmediate(callback, ...a); + nodeTimers.clearImmediate = this.#clearImmediate; + h.nodeTimersPromises().setImmediate = (result: unknown, options?: { signal?: AbortSignal }) => + this.#setImmediatePromisified(result, options); + }, + Date: () => { + this.#nativeDateDescriptor = Object.getOwnPropertyDescriptor(hooks!.globalTarget, "Date"); + hooks!.globalTarget.Date = this.#createDate(); + }, + "AbortSignal.timeout": () => { + const target = hooks!.abortSignal(); + if (!target) return; + this.#storeOriginalAbortSignalTimeout(); + const mock = this; + Object.defineProperty(target, "timeout", { + configurable: true, + writable: true, + value: function timeout(delay: unknown) { + validateUint32(delay, "delay", false); + const controller = new AbortController(); + mock.#setTimeout(() => controller.abort(), delay); + return controller.signal; + }, + }); + }, + }; + const toReal: Record void> = { + "scheduler.wait": () => this.#restoreOriginalSchedulerWait(), + setTimeout: () => this.#restoreOriginalSetTimeout(), + setInterval: () => this.#restoreOriginalSetInterval(), + setImmediate: () => this.#restoreSetImmediate(), + Date: () => Object.defineProperty(hooks!.globalTarget, "Date", this.#nativeDateDescriptor!), + "AbortSignal.timeout": () => this.#restoreOriginalAbortSignalTimeout(), + }; + const target = activate ? toFake : toReal; + for (const timer of this.#timersInContext) target[timer](); + this.#isEnabled = activate; + } + + /** Advances the virtual clock by `time` ms (default 1), running every timer due by + * the new time, in (runAt, id) order — reinserting an interval's next occurrence as + * it fires, the way Node's tick() does. */ + tick(time = 1): void { + this.#assertTimersAreEnabled(); + this.#assertTimeArg(time); + + this.#now += time; + let timer = this.#executionQueue.peek(); + while (timer) { + if (timer.runAt > this.#now) break; + timer.callback.apply(undefined, timer.args); + + const afterCallback = this.#executionQueue.peek(); + if (afterCallback?.id === timer.id) this.#executionQueue.shift(); + + if (timer.interval !== undefined) { + timer.runAt += timer.interval; + this.#executionQueue.insert(timer); + } + timer = this.#executionQueue.peek(); + } + } + + /** Enables the given `apis` (default: all of SUPPORTED_APIS), replacing each one's + * real implementation with the fake one and setting the virtual clock to `now` + * (default 0, or a Date's time). */ + enable(options: { apis?: string[]; now?: number | Date } = { apis: [...SUPPORTED_APIS], now: 0 }): void { + const opts = { ...options }; + if (this.#isEnabled) throw invalidState("MockTimers is already enabled!"); + + if (typeof opts.now === "number" && Number.isNaN(opts.now)) { + throw invalidArgValue("now", opts.now, `epoch must be a positive integer received ${opts.now}`); + } + + const apis = opts.apis ?? [...SUPPORTED_APIS]; + validateStringArray(apis, "options.apis"); + for (const timer of apis) { + if (!(SUPPORTED_APIS as readonly string[]).includes(timer)) { + throw invalidArgValue("options.apis", timer, `option ${timer} is not supported`); + } + } + this.#timersInContext = apis as SupportedApi[]; + + const now = opts.now ?? 0; + if (this.#isValidDateWithGetTime(now)) { + this.#now = (now as Date).getTime(); + } else { + validateNumber(now, "initialTime"); + this.#assertTimeArg(now as number); + this.#now = now as number; + } + + this.#toggleEnableTimers(true); + } + + /** Sets the virtual clock to `time` (default 0) without running due timers. */ + setTime(time: number = kInitialEpoch): void { + validateNumber(time, "time"); + this.#assertTimeArg(time); + this.#assertTimersAreEnabled(); + this.#now = time; + } + + [Symbol.dispose](): void { + this.reset(); + } + + /** Disables any enabled timers and clears the execution queue. A no-op when timers + * are not enabled (Node's own reset() is too, so a test's teardown calling it + * defensively never newly fails). */ + reset(): void { + if (!this.#isEnabled) return; + this.#toggleEnableTimers(false); + this.#timersInContext = []; + this.#now = kInitialEpoch; + this.#executionQueue.clear(); + } + + /** Runs every timer up to and including the one with the furthest `runAt`. */ + runAll(): void { + this.#assertTimersAreEnabled(); + const longestTimer = this.#executionQueue.peekBottom(); + if (!longestTimer) return; + this.tick(longestTimer.runAt - this.#now); + } +} diff --git a/packages/kernel/src/runtime/test_runner/reporter/dot.ts b/packages/kernel/src/runtime/test_runner/reporter/dot.ts new file mode 100644 index 0000000..65a559a --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/reporter/dot.ts @@ -0,0 +1,38 @@ +// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and +// other Node contributors). See LICENSE-node. +// node:test/reporters' dot: internal/test_runner/reporter/dot.js. `process.stdout.columns` +// is undefined on a piped/non-TTY stream in Node too, so getLineLength() falls back to +// 20 there — the same path every golden guest here takes (stdout is never a terminal +// when a guest's output is captured for a golden). +import * as colors from "../colors"; +import { formatTestReport } from "./utils"; + +interface DotEvent { type: string; data: Record } + +function getLineLength(): number { + const columns = (globalThis as unknown as { process?: { stdout?: { columns?: number } } }).process?.stdout?.columns; + return Math.max(columns ?? 20, 20); +} + +export async function* dot(source: AsyncIterable): AsyncGenerator { + let count = 0; + let columns = getLineLength(); + const failedTests: Record[] = []; + for await (const { type, data } of source) { + if (type === "test:pass") yield `${colors.green()}.${colors.reset()}`; + if (type === "test:fail") { + yield `${colors.red()}X${colors.reset()}`; + failedTests.push(data); + } + if ((type === "test:fail" || type === "test:pass") && ++count === columns) { + yield "\n"; + columns = getLineLength(); + count = 0; + } + } + yield "\n"; + if (failedTests.length > 0) { + yield `\n${colors.red()}Failed tests:${colors.white()}\n\n`; + for (const test of failedTests) yield formatTestReport("test:fail", test); + } +} diff --git a/packages/kernel/src/runtime/test_runner/reporter/junit.ts b/packages/kernel/src/runtime/test_runner/reporter/junit.ts new file mode 100644 index 0000000..0ba67db --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/reporter/junit.ts @@ -0,0 +1,124 @@ +// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and +// other Node contributors). See LICENSE-node. +// node:test/reporters' junit: internal/test_runner/reporter/junit.js, building a tiny +// XML tree as it walks the event stream and serializing it at the end. +import { inspect } from "../../inspect"; + +interface JUnitEvent { type: string; data: Record } + +interface Node { + tag?: string; + attrs: Record; + nesting?: number; + parent?: Node | null; + children?: Node[]; + comment?: string; +} + +function escapeAttribute(s = ""): string { + return escapeContent(s.replaceAll("\n", " ").replaceAll('"', """)); +} +function escapeContent(s = ""): string { + return s.replace(/(&)(?!#\d{1,7};)/g, "&").replaceAll("<", "<"); +} +function escapeComment(s = ""): string { + return s.replaceAll("--", "--"); +} + +function treeToXML(tree: Node | string): string { + if (typeof tree === "string") return `${escapeContent(tree)}\n`; + const { tag, attrs, nesting = 0, children, comment } = tree; + const indent = "\t".repeat(nesting + 1); + if (comment) return `${indent}\n`; + const attrsString = Object.entries(attrs).map(([key, value]) => `${key}="${escapeAttribute(String(value))}"`).join(" "); + if (!children?.length) return `${indent}<${tag} ${attrsString}/>\n`; + const childrenString = children.map(treeToXML).join(""); + return `${indent}<${tag} ${attrsString}>\n${childrenString}${indent}\n`; +} + +function isFailure(node: Node | undefined): boolean { + return Boolean((node?.children && node.children.some((c) => c.tag === "failure")) || node?.attrs?.failures); +} +function isSkipped(node: Node | undefined): boolean { + return Boolean((node?.children && node.children.some((c) => c.tag === "skipped")) || node?.attrs?.skipped); +} + +export async function* junit(source: AsyncIterable): AsyncGenerator { + yield '\n'; + yield "\n"; + let currentSuite: Node | null = null; + const roots: Node[] = []; + // os.hostname() is always "localhost" here (src/runtime/os.ts) — no DNS/network + // identity exists in a browser tab — so this is just that literal inlined. + const hostname = "localhost"; + + function startTest(event: { data: Record }): void { + const originalSuite = currentSuite; + currentSuite = { attrs: { name: event.data.name }, nesting: event.data.nesting as number, parent: currentSuite, children: [] }; + if (originalSuite?.children) originalSuite.children.push(currentSuite); + if (!currentSuite.parent) roots.push(currentSuite); + } + + for await (const event of source) { + switch (event.type) { + case "test:start": + startTest(event); + break; + case "test:pass": + case "test:fail": { + if (!currentSuite) startTest({ data: { name: "root", nesting: 0 } }); + if (currentSuite!.attrs.name !== event.data.name || currentSuite!.nesting !== event.data.nesting) startTest(event); + const currentTest = currentSuite!; + if (currentSuite!.nesting === event.data.nesting) currentSuite = currentSuite!.parent ?? null; + const details = event.data.details as { duration_ms?: number; error?: Record } | undefined; + currentTest.attrs.time = ((details?.duration_ms ?? 0) / 1000).toFixed(6); + const nonCommentChildren = (currentTest.children ?? []).filter((c) => c.comment == null); + if (nonCommentChildren.length > 0) { + currentTest.tag = "testsuite"; + currentTest.attrs.disabled = 0; + currentTest.attrs.errors = 0; + currentTest.attrs.tests = nonCommentChildren.length; + currentTest.attrs.failures = (currentTest.children ?? []).filter(isFailure).length; + currentTest.attrs.skipped = (currentTest.children ?? []).filter(isSkipped).length; + currentTest.attrs.hostname = hostname; + } else { + currentTest.tag = "testcase"; + currentTest.attrs.classname = (event.data as { classname?: string }).classname ?? "test"; + if (event.data.file) currentTest.attrs.file = event.data.file; + if (event.data.skip) { + (currentTest.children ??= []).push({ + nesting: (event.data.nesting as number) + 1, tag: "skipped", + attrs: { type: "skipped", message: event.data.skip }, + }); + } + if (event.data.todo) { + (currentTest.children ??= []).push({ + nesting: (event.data.nesting as number) + 1, tag: "skipped", + attrs: { type: "todo", message: event.data.todo }, + }); + } + if (event.type === "test:fail") { + const error = details?.error as { failureType?: string; code?: string; message?: string } | undefined; + (currentTest.children ??= []).push({ + nesting: (event.data.nesting as number) + 1, + tag: "failure", + attrs: { type: error?.failureType || error?.code, message: (error?.message ?? "").trim() }, + children: [inspect(error, { breakLength: Infinity }) as unknown as Node], + }); + currentTest.attrs.failure = error?.message ?? ""; + } + } + break; + } + case "test:diagnostic": { + const parent = currentSuite?.children ?? roots; + parent.push({ attrs: {}, nesting: event.data.nesting as number, comment: event.data.message as string }); + break; + } + default: + break; + } + } + for (const suite of roots) yield treeToXML(suite); + yield "\n"; +} diff --git a/packages/kernel/src/runtime/test_runner/reporter/lcov.ts b/packages/kernel/src/runtime/test_runner/reporter/lcov.ts new file mode 100644 index 0000000..574fde3 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/reporter/lcov.ts @@ -0,0 +1,69 @@ +// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and +// other Node contributors). See LICENSE-node. +// node:test/reporters' lcov: internal/test_runner/reporter/lcov.js. Pure formatting +// over a `test:coverage` event's summary — this runtime never emits that event itself +// (coverage needs the inspector, refused: compat/divergences.json "test.coverage" / +// run({ coverage: true })), but the format is independent of where the summary came +// from, so it's ported and exercised with a hand-built `test:coverage` event (see +// tests/fixtures/guests/builtin-test-reporters-lcov.cjs). +import { Transform } from "../../stream"; +import { relative } from "./utils"; + +interface CoverageFile { + path: string; + functions: { name?: string; line: number; count: number }[]; + totalFunctionCount: number; + coveredFunctionCount: number; + branches: { line: number; count: number }[]; + totalBranchCount: number; + coveredBranchCount: number; + lines: { line: number; count: number }[]; + coveredLineCount: number; + totalLineCount: number; +} + +interface CoverageSummary { + workingDirectory: string; + files: CoverageFile[]; +} + +export class LcovReporter extends Transform { + constructor() { + super({ writableObjectMode: true }); + } + + override _transform(chunk: unknown, _encoding: string, callback: (err?: Error | null, data?: unknown) => void): void { + const event = chunk as { type: string; data: { summary: CoverageSummary } }; + if (event.type !== "test:coverage") return callback(null); + let lcov = ""; + lcov += "TN:\n"; + const { workingDirectory } = event.data.summary; + try { + for (const file of event.data.summary.files) { + lcov += `SF:${relative(workingDirectory, file.path)}\n`; + let fnda = ""; + file.functions.forEach((func, j) => { + const name = func.name || `anonymous_${j}`; + lcov += `FN:${func.line},${name}\n`; + fnda += `FNDA:${func.count},${name}\n`; + }); + lcov += fnda; + lcov += `FNF:${file.totalFunctionCount}\n`; + lcov += `FNH:${file.coveredFunctionCount}\n`; + file.branches.forEach((branch, j) => { + lcov += `BRDA:${branch.line},${j},0,${branch.count}\n`; + }); + lcov += `BRF:${file.totalBranchCount}\n`; + lcov += `BRH:${file.coveredBranchCount}\n`; + const sortedLines = [...file.lines].sort((a, b) => a.line - b.line); + for (const l of sortedLines) lcov += `DA:${l.line},${l.count}\n`; + lcov += `LH:${file.coveredLineCount}\n`; + lcov += `LF:${file.totalLineCount}\n`; + lcov += "end_of_record\n"; + } + } catch (error) { + return callback(error as Error); + } + return callback(null, lcov); + } +} diff --git a/packages/kernel/src/runtime/test_runner/reporter/spec.ts b/packages/kernel/src/runtime/test_runner/reporter/spec.ts new file mode 100644 index 0000000..d77724a --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/reporter/spec.ts @@ -0,0 +1,89 @@ +// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and +// other Node contributors). See LICENSE-node. +// node:test/reporters' default: internal/test_runner/reporter/spec.js, a Transform that +// buffers `test:start` events until their matching pass/fail arrives (so a passing +// suite's children print indented under its `▶ name` line) and collects failures for a +// trailing "failing tests:" section. +import { Transform } from "../../stream"; +import * as colors from "../colors"; +import { kSubtestsFailed } from "../test"; +import { formatTestReport, indent, relative, reporterColorMap, reporterUnicodeSymbolMap } from "./utils"; + +type Event = { type: string; data: Record }; +type StartEvent = { type: "test:start"; data: Record }; + +export class SpecReporter extends Transform { + #stack: StartEvent[] = []; + #failedTests: Record[] = []; + #cwd: string; + + constructor() { + super({ writableObjectMode: true }); + this.#cwd = (globalThis as unknown as { process?: { cwd(): string } }).process?.cwd() ?? "/"; + } + + #formatFailedTestResults(): string { + if (this.#failedTests.length === 0) return ""; + const results = [`\n${reporterColorMap["test:fail"]}${reporterUnicodeSymbolMap["test:fail"]}failing tests:${colors.white()}\n`]; + for (const test of this.#failedTests) { + const formattedErr = formatTestReport("test:fail", test); + if (test.file) { + const relPath = relative(this.#cwd, test.file as string); + results.push(`test at ${relPath}:${test.line}:${test.column}`); + } + results.push(formattedErr); + } + this.#failedTests = []; + return results.join("\n"); + } + + #handleTestReportEvent(type: string, data: Record): string { + const subtest = this.#stack.shift(); + let prefix = ""; + if (subtest) { + while (this.#stack.length) { + const parent = this.#stack.pop()!; + const msg = parent.data; + prefix += `${indent(msg.nesting as number)}${reporterUnicodeSymbolMap["arrow:right"]}${msg.name}\n`; + } + } + const indentation = indent(data.nesting as number); + return `${formatTestReport(type, data, false, prefix, indentation)}\n`; + } + + #handleEvent({ type, data }: Event): string | undefined { + switch (type) { + case "test:fail": { + const details = data.details as { error?: { failureType?: string } } | undefined; + if (details?.error?.failureType !== kSubtestsFailed) this.#failedTests.push(data); + return this.#handleTestReportEvent(type, data); + } + case "test:pass": + return this.#handleTestReportEvent(type, data); + case "test:start": + this.#stack.unshift({ type: "test:start", data }); + return undefined; + case "test:stderr": + case "test:stdout": + return data.message as string; + case "test:diagnostic": { + const diagnosticColor = reporterColorMap[data.level as string] || reporterColorMap["test:diagnostic"]; + return `${diagnosticColor}${indent(data.nesting as number)}${reporterUnicodeSymbolMap[type]}${data.message}${colors.white()}\n`; + } + case "test:summary": + if (data.file === undefined) return this.#formatFailedTestResults(); + return undefined; + default: + return undefined; + } + } + + override _transform(chunk: unknown, _encoding: string, callback: (err?: Error | null, data?: unknown) => void): void { + const { type, data } = chunk as Event; + callback(null, this.#handleEvent({ type, data })); + } + + override _flush(callback: (err?: Error | null, data?: unknown) => void): void { + callback(null, this.#formatFailedTestResults()); + } +} diff --git a/packages/kernel/src/runtime/test_runner/reporter/tap.ts b/packages/kernel/src/runtime/test_runner/reporter/tap.ts new file mode 100644 index 0000000..394bbe7 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/reporter/tap.ts @@ -0,0 +1,198 @@ +// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and +// other Node contributors). See LICENSE-node. +// node:test/reporters' tap: internal/test_runner/reporter/tap.js, an async generator +// over the event stream that prints TAP version 13 with a YAML diagnostic block per +// result. No coverage block is emitted (coverage is refused here). +import { inspect } from "../../inspect"; +import { kUnwrapErrors } from "../test"; + +const kDefaultIndent = " "; +const kFrameStartRegExp = /^ {4}at /; + +interface TapEvent { type: string; data: Record } + +export async function* tap(source: AsyncIterable): AsyncGenerator { + yield "TAP version 13\n"; + for await (const { type, data } of source) { + switch (type) { + case "test:fail": { + yield reportTest(data.nesting as number, data.testNumber as number, "not ok", data.name as string, + data.skip, data.todo, data.expectFailure); + const location = data.file ? `${data.file}:${data.line}:${data.column}` : null; + yield reportDetails(data.nesting as number, data.details as Record, location); + break; + } + case "test:pass": + yield reportTest(data.nesting as number, data.testNumber as number, "ok", data.name as string, + data.skip, data.todo, data.expectFailure); + yield reportDetails(data.nesting as number, data.details as Record, null); + break; + case "test:plan": + yield `${indent(data.nesting as number)}1..${data.count}\n`; + break; + case "test:start": + yield `${indent(data.nesting as number)}# Subtest: ${tapEscape(data.name as string)}\n`; + break; + case "test:stderr": + case "test:stdout": { + const lines = (data.message as string).split(/\n|\r\n/); + for (const line of lines) { + if (line.length === 0) continue; + yield `# ${tapEscape(line)}\n`; + } + break; + } + case "test:diagnostic": + yield `${indent(data.nesting as number)}# ${tapEscape(data.message as string)}\n`; + break; + case "test:interrupted": { + const tests = data.tests as { name: string; file?: string; line?: number; column?: number }[]; + for (const t of tests) { + let msg = `Interrupted while running: ${t.name}`; + if (t.file) msg += ` at ${t.file}:${t.line}:${t.column}`; + yield `# ${tapEscape(msg)}\n`; + } + break; + } + default: + break; + } + } +} + +function reportTest( + nesting: number, testNumber: number, status: string, name: string, + skip: unknown, todo: unknown, expectFailure: unknown, +): string { + let line = `${indent(nesting)}${status} ${testNumber}`; + if (name) line += ` ${tapEscape(`- ${name}`)}`; + if (skip !== undefined) line += ` # SKIP${typeof skip === "string" && skip.length ? ` ${tapEscape(skip)}` : ""}`; + else if (todo !== undefined) line += ` # TODO${typeof todo === "string" && todo.length ? ` ${tapEscape(todo)}` : ""}`; + else if (expectFailure !== undefined) line += ` # EXPECTED FAILURE${typeof expectFailure === "string" ? ` ${tapEscape(expectFailure)}` : ""}`; + return `${line}\n`; +} + +function reportDetails(nesting: number, data: Record = {}, location: string | null): string { + const { error, duration_ms } = data; + const pad = indent(nesting); + let details = `${pad} ---\n`; + details += jsToYaml(pad, "duration_ms", duration_ms, new Set()); + details += jsToYaml(pad, "type", data.type, new Set()); + if (location) details += jsToYaml(pad, "location", location, new Set()); + details += jsToYaml(pad, null, error, new Set()); + details += `${pad} ...\n`; + return details; +} + +const memo = new Map(); +function indent(nesting: number): string { + let value = memo.get(nesting); + if (value === undefined) { + value = kDefaultIndent.repeat(nesting); + memo.set(nesting, value); + } + return value; +} + +function tapEscape(input: string): string { + return input + .replaceAll("\b", "\\b").replaceAll("\f", "\\f").replaceAll("\t", "\\t") + .replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\v", "\\v") + .replaceAll("\\", "\\\\").replaceAll("#", "\\#"); +} + +function isErrorLike(v: unknown): v is Error { + return Boolean(v) && typeof v === "object" && v instanceof Error; +} + +function isAssertionLike(value: unknown): value is { expected: unknown; actual: unknown } { + return Boolean(value) && typeof value === "object" && "expected" in (value as object) && "actual" in (value as object); +} + +function jsToYaml(pad: string, name: string | null, value: unknown, seen: Set): string { + if (value === undefined) return ""; + const prefix = `${pad} ${name}:`; + if (value === null) return `${prefix} ~\n`; + + if (typeof value !== "object") { + if (typeof value !== "string") return `${prefix} ${inspect(value, { breakLength: Infinity })}\n`; + const lines = value.split(/\n|\r\n/); + if (lines.length === 1) return `${prefix} ${inspect(value, { breakLength: Infinity })}\n`; + let str = `${prefix} |-\n`; + for (const line of lines) str += `${pad} ${line}\n`; + return str; + } + + seen.add(value); + const entries = Object.entries(value as object); + const isErrorObj = isErrorLike(value); + let propsIndent = pad; + let result = ""; + + if (name != null) { + result += prefix; + if (value instanceof Date) result += ` ${value.toISOString()}`; + result += "\n"; + propsIndent += " "; + } + + for (const [key, v] of entries) { + if (isErrorObj && (key === "cause" || key === "code")) continue; + if (seen.has(v)) { + result += `${propsIndent} ${key}: \n`; + continue; + } + result += jsToYaml(propsIndent, key, v, seen); + } + + if (isErrorObj) { + const e = value as Error & { + cause?: unknown; code?: string; failureType?: string; expected?: unknown; actual?: unknown; operator?: string; + }; + let errMsg: unknown = e.message ?? ""; + let errName = e.name; + let errStack = e.stack; + let errCode = e.code; + let errExpected = e.expected; + let errActual = e.actual; + let errOperator = e.operator; + let errIsAssertion = isAssertionLike(e); + + if (e.code === "ERR_TEST_FAILURE" && e.failureType !== undefined && kUnwrapErrors.has(e.failureType)) { + const cause = e.cause as (Error & { code?: string; expected?: unknown; actual?: unknown; operator?: string }) | undefined; + errStack = cause?.stack ?? errStack; + errCode = cause?.code ?? errCode; + errName = cause?.name ?? errName; + errMsg = cause?.message ?? errMsg; + if (isAssertionLike(cause)) { + errExpected = cause!.expected; + errActual = cause!.actual; + errOperator = cause!.operator ?? errOperator; + errIsAssertion = true; + } + } + + result += jsToYaml(pad, "error", errMsg, seen); + if (errCode) result += jsToYaml(pad, "code", errCode, seen); + if (errName && errName !== "Error") result += jsToYaml(pad, "name", errName, seen); + if (errIsAssertion) { + result += jsToYaml(pad, "expected", errExpected, new Set(seen)); + result += jsToYaml(pad, "actual", errActual, new Set(seen)); + if (errOperator) result += jsToYaml(pad, "operator", errOperator, seen); + } + if (typeof errStack === "string") { + const frames: string[] = []; + for (const frame of errStack.split(/\n|\r\n/)) { + const processed = frame.replace(kFrameStartRegExp, ""); + if (processed.length > 0 && processed.length !== frame.length) frames.push(processed); + } + if (frames.length > 0) { + const frameDelimiter = `\n${pad} `; + result += `${pad} stack: |-${frameDelimiter}`; + result += `${frames.join(frameDelimiter)}\n`; + } + } + } + + return result; +} diff --git a/packages/kernel/src/runtime/test_runner/reporter/utils.ts b/packages/kernel/src/runtime/test_runner/reporter/utils.ts new file mode 100644 index 0000000..52311a5 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/reporter/utils.ts @@ -0,0 +1,113 @@ +// 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/reporter/utils.js: the symbol/colour tables and +// `formatTestReport` the spec and dot reporters share. +import { inspect } from "../../inspect"; +import * as colors from "../colors"; + +const indentMemo = new Map(); + +export const reporterUnicodeSymbolMap: Record = { + "test:fail": "✖ ", + "test:pass": "✔ ", + "test:diagnostic": "ℹ ", + "test:coverage": "ℹ ", + "arrow:right": "▶ ", + "hyphen:minus": "﹣ ", + "warning:alert": "⚠ ", +}; + +export const reporterColorMap: Record = { + get "test:fail"(): string { + return colors.red(); + }, + get "test:pass"(): string { + return colors.green(); + }, + get "test:diagnostic"(): string { + return colors.blue(); + }, + get info(): string { + return colors.blue(); + }, + get warn(): string { + return colors.yellow(); + }, + get error(): string { + return colors.red(); + }, +} as unknown as Record; + +/** A tiny POSIX path.relative, ported from src/runtime/path.ts's `posix.relative` (not + * reused directly: that one lives behind makePath(cwd, env), a factory this leaf + * reporter code has no reason to carry around just for one pure function). Used only + * to shorten absolute file paths in reporter output, same as Node's reporters do. */ +export function relative(from: string, to: string): string { + if (from === to) return ""; + const clean = (p: string): string => { + const abs = p.startsWith("/") ? p : `/${p}`; + const parts = abs.split("/").filter((s) => s.length > 0 && s !== "."); + const out: string[] = []; + for (const part of parts) { + if (part === "..") out.pop(); + else out.push(part); + } + return `/${out.join("/")}`; + }; + const a = clean(from); + const b = clean(to); + if (a === b) return ""; + const aParts = a.split("/").filter(Boolean); + const bParts = b.split("/").filter(Boolean); + let common = 0; + while (common < aParts.length && common < bParts.length && aParts[common] === bParts[common]) common++; + const up = aParts.slice(common).map(() => ".."); + const down = bParts.slice(common); + const result = [...up, ...down].join("/"); + return result.length ? result : "."; +} + +export function indent(nesting: number): string { + let value = indentMemo.get(nesting); + if (value === undefined) { + value = " ".repeat(nesting); + indentMemo.set(nesting, value); + } + return value; +} + +function formatError(error: unknown, pad: string): string { + const e = error as { code?: string; cause?: unknown }; + const err = e?.code === "ERR_TEST_FAILURE" ? e.cause : error; + const message = inspect(err, { colors: colors.shouldColorizeStderr(), breakLength: Infinity }) + .split(/\r?\n/).join(`\n${pad} `); + return `\n${pad} ${message}\n`; +} + +export function formatTestReport( + type: string, data: Record, showErrorDetails = true, prefix = "", pad = "", +): string { + let color = reporterColorMap[type] ?? colors.white(); + let symbol = reporterUnicodeSymbolMap[type] ?? " "; + const { skip, todo, expectFailure } = data as { skip?: unknown; todo?: unknown; expectFailure?: unknown }; + const details = data.details as { duration_ms?: number; error?: unknown } | undefined; + const duration_ms = details?.duration_ms ? ` ${colors.gray()}(${details.duration_ms}ms)${colors.white()}` : ""; + let title = `${data.name as string}${duration_ms}`; + + if (skip !== undefined) { + title += ` # ${typeof skip === "string" && skip.length ? skip : "SKIP"}`; + color = colors.gray(); + symbol = reporterUnicodeSymbolMap["hyphen:minus"]!; + } else if (todo !== undefined) { + title += ` # ${typeof todo === "string" && todo.length ? todo : "TODO"}`; + if (type === "test:fail") { + color = colors.yellow(); + symbol = reporterUnicodeSymbolMap["warning:alert"]!; + } + } else if (expectFailure !== undefined) { + title += " # EXPECTED FAILURE"; + } + + const err = showErrorDetails && details?.error ? formatError(details.error, pad) : ""; + return `${prefix}${pad}${color}${symbol}${title}${colors.white()}${err}`; +} diff --git a/packages/kernel/src/runtime/test_runner/runner.ts b/packages/kernel/src/runtime/test_runner/runner.ts new file mode 100644 index 0000000..b002972 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/runner.ts @@ -0,0 +1,374 @@ +// 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; +} diff --git a/packages/kernel/src/runtime/test_runner/snapshot.ts b/packages/kernel/src/runtime/test_runner/snapshot.ts new file mode 100644 index 0000000..93b0d17 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/snapshot.ts @@ -0,0 +1,256 @@ +// 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/snapshot.js: SnapshotFile (one per entry file, backed by a +// `.snapshot` sibling written as plain JS — `exports[\`key\`] = \`value\`;` lines), +// SnapshotManager (resolves the file, serializes, compares or records), and the two +// `test.snapshot.*` setters. `t.assert.snapshot`/`t.assert.fileSnapshot` are wired in +// test.ts's TestContext#assert getter via createAssert()/createFileAssert(), one +// SnapshotManager per harness (test_runner/test.ts's lazy assert-map bootstrap), +// matching Node's own lazyAssertObject(harness). +// +// Node evaluates the snapshot file with vm.createContext + runInContext, handing it a +// bare `exports` object; a Function constructor given only `exports` as a parameter is +// the same sandboxing this runtime-privileged code needs (it is not guest code +// reached through the CSP'd virtual origin — see DESIGN.md's refusals section for what +// *is* refused there). +import { assert } from "../assert"; +import { invalidArgType, invalidState } from "../errors"; + +const kMissingSnapshotTip = "Missing snapshots can be generated by rerunning the command with the --test-update-snapshots flag."; +const defaultSerializers: Array<(value: unknown) => unknown> = [(value) => JSON.stringify(value, null, 2)]; + +function defaultResolveSnapshotPath(testPath: unknown): unknown { + if (typeof testPath !== "string") return testPath; + return `${testPath}.snapshot`; +} + +let resolveSnapshotPathFn: (testPath: unknown) => unknown = defaultResolveSnapshotPath; +let serializerFns: Array<(value: unknown) => unknown> = defaultSerializers; + +// No shared validateFunction/validateObject/validateString/validateArray copy exists +// in src/runtime; this module's small local copies mirror internal/validators.js. +function validateFunction(value: unknown, name: string): asserts value is (...args: unknown[]) => unknown { + if (typeof value !== "function") throw invalidArgType(name, "of type function", value); +} +function validateObject(value: unknown, name: string): void { + if (value === null || Array.isArray(value) || typeof value !== "object") throw invalidArgType(name, "of type object", value); +} +function validateString(value: unknown, name: string): void { + if (typeof value !== "string") throw invalidArgType(name, "of type string", value); +} +function validateArray(value: unknown, name: string): void { + if (!Array.isArray(value)) throw invalidArgType(name, "an instance of Array", value); +} +function validateFunctionArray(fns: unknown, name: string): void { + validateArray(fns, name); + for (let i = 0; i < (fns as unknown[]).length; i++) validateFunction((fns as unknown[])[i], `${name}[${i}]`); +} + +export function setResolveSnapshotPath(fn: unknown): void { + validateFunction(fn, "fn"); + resolveSnapshotPathFn = fn as (testPath: unknown) => unknown; +} + +export function setDefaultSnapshotSerializers(serializers: unknown): void { + validateFunctionArray(serializers, "serializers"); + serializerFns = (serializers as Array<(value: unknown) => unknown>).slice(0); +} + +/** fs/path sync hooks SnapshotFile needs, bound once per worker from test.ts's + * makeTest the way harness.ts's bindProcess binds the guest process. Snapshot files + * are read and written through the kernel's own fs, like Node's require('fs'). */ +export interface SnapshotFsHooks { + readFileSync(path: string, encoding: "utf8"): string; + writeFileSync(path: string, data: string, encoding: "utf8"): void; + mkdirSync(path: string, options: { recursive: boolean }): void; + dirname(path: string): string; +} +let fsHooks: SnapshotFsHooks | null = null; +export function bindSnapshotFs(hooks: SnapshotFsHooks): void { + fsHooks = hooks; +} + +function throwReadError(err: unknown, filename: string): never { + let msg = `Cannot read snapshot file '${filename}.'`; + if ((err as { code?: string } | null)?.code === "ENOENT") msg += ` ${kMissingSnapshotTip}`; + throw Object.assign(invalidState(msg), { cause: err, filename }); +} +function throwWriteError(err: unknown, filename: string): never { + throw Object.assign(invalidState(`Cannot write snapshot file '${filename}.'`), { cause: err, filename }); +} +function throwSerializationError(input: unknown, err: unknown): never { + throw Object.assign(invalidState("The provided serializers did not generate a string."), { input, cause: err }); +} + +function serializeValue(value: unknown, serializers: Array<(value: unknown) => unknown>): unknown { + let v = value; + for (const fn of serializers) v = fn(v); + return v; +} + +/** Node's escapeSnapshotKey/templateEscape: both escape backslashes, backticks and + * `${` the same way (a snapshot id and a snapshot value are both embedded in a + * template literal on write) — escapeSnapshotKey additionally runs the id through + * JSON.stringify first (so a key with newlines or quotes stays on one line) and + * strips the surrounding quotes JSON.stringify added. */ +function escapeSnapshotKey(str: string): string { + let result = JSON.stringify(str).slice(1, -1); + result = result.replaceAll("`", "\\`").replaceAll("${", "\\${"); + return result; +} +function templateEscape(str: unknown): string { + let result = String(str); + result = result.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${"); + return result; +} + +class SnapshotFile { + snapshotFile: string; + snapshots: Record = Object.create(null); + #nameCounts = new Map(); + loaded = false; + + constructor(snapshotFile: string) { + this.snapshotFile = snapshotFile; + } + + getSnapshot(id: string): string { + if (!(id in this.snapshots)) { + throw Object.assign(invalidState(`Snapshot '${id}' not found in '${this.snapshotFile}.' ${kMissingSnapshotTip}`), { + snapshot: id, + filename: this.snapshotFile, + }); + } + return this.snapshots[id]!; + } + + setSnapshot(id: string, value: string): void { + this.snapshots[escapeSnapshotKey(id)] = value; + } + + nextId(name: string): string { + const count = this.#nameCounts.get(name) ?? 1; + this.#nameCounts.set(name, count + 1); + return `${name} ${count}`; + } + + readFile(): void { + if (this.loaded) return; + try { + const source = fsHooks!.readFileSync(this.snapshotFile, "utf8"); + const exportsObj: Record = Object.create(null); + new Function("exports", source)(exportsObj); + if (exportsObj === null || typeof exportsObj !== "object") throw invalidState(`Malformed snapshot file '${this.snapshotFile}'.`); + for (const key in exportsObj) this.snapshots[key] = templateEscape(exportsObj[key]); + this.loaded = true; + } catch (err) { + throwReadError(err, this.snapshotFile); + } + } + + writeFile(): void { + try { + const keys = Object.keys(this.snapshots).sort(); + const output = keys.map((key) => `exports[\`${key}\`] = \`${this.snapshots[key]}\`;\n`).join("\n"); + fsHooks!.mkdirSync(fsHooks!.dirname(this.snapshotFile), { recursive: true }); + fsHooks!.writeFileSync(this.snapshotFile, output, "utf8"); + } catch (err) { + throwWriteError(err, this.snapshotFile); + } + } +} + +export interface SnapshotAssertContext { + filePath?: string; + fullName: string; +} + +export class SnapshotManager { + updateSnapshots: boolean; + #cache = new Map(); + + constructor(updateSnapshots: boolean) { + this.updateSnapshots = updateSnapshots; + } + + resolveSnapshotFile(entryFile: string | undefined): SnapshotFile { + let snapshotFile = this.#cache.get(entryFile ?? ""); + if (snapshotFile === undefined) { + const resolved = resolveSnapshotPathFn(entryFile); + if (typeof resolved !== "string") throw Object.assign(invalidState("Invalid snapshot filename."), { filename: resolved }); + snapshotFile = new SnapshotFile(resolved); + snapshotFile.loaded = this.updateSnapshots; + this.#cache.set(entryFile ?? "", snapshotFile); + } + return snapshotFile; + } + + serialize(input: unknown, serializers: Array<(value: unknown) => unknown> = serializerFns): string { + try { + const value = serializeValue(input, serializers); + return `\n${templateEscape(value)}\n`; + } catch (err) { + throwSerializationError(input, err); + } + } + + serializeWithoutEscape(input: unknown, serializers: Array<(value: unknown) => unknown> = serializerFns): string { + try { + return serializeValue(input, serializers) as string; + } catch (err) { + throwSerializationError(input, err); + } + } + + writeSnapshotFiles(): void { + if (!this.updateSnapshots) return; + for (const snapshotFile of this.#cache.values()) snapshotFile.writeFile(); + } + + createAssert(): (this: SnapshotAssertContext, actual: unknown, options?: unknown) => void { + const manager = this; + return function snapshotAssertion(this: SnapshotAssertContext, actual: unknown, options: unknown = {}): void { + validateObject(options, "options"); + const { serializers = serializerFns } = options as { serializers?: Array<(value: unknown) => unknown> }; + validateFunctionArray(serializers, "options.serializers"); + const { filePath, fullName } = this; + const snapshotFile = manager.resolveSnapshotFile(filePath); + const value = manager.serialize(actual, serializers); + const id = snapshotFile.nextId(fullName); + if (manager.updateSnapshots) { + snapshotFile.setSnapshot(id, value); + } else { + snapshotFile.readFile(); + assert.strictEqual(value, snapshotFile.getSnapshot(id)); + } + }; + } + + createFileAssert(): (this: SnapshotAssertContext, actual: unknown, path: unknown, options?: unknown) => void { + const manager = this; + return function fileSnapshotAssertion(this: SnapshotAssertContext, actual: unknown, path: unknown, options: unknown = {}): void { + validateString(path, "path"); + validateObject(options, "options"); + const { serializers = serializerFns } = options as { serializers?: Array<(value: unknown) => unknown> }; + validateFunctionArray(serializers, "options.serializers"); + const value = manager.serializeWithoutEscape(actual, serializers); + + if (manager.updateSnapshots) { + try { + fsHooks!.mkdirSync(fsHooks!.dirname(path as string), { recursive: true }); + fsHooks!.writeFileSync(path as string, value, "utf8"); + } catch (err) { + throwWriteError(err, path as string); + } + } else { + let expected: string; + try { + expected = fsHooks!.readFileSync(path as string, "utf8"); + } catch (err) { + throwReadError(err, path as string); + } + assert.strictEqual(value, expected); + } + }; + } +} diff --git a/packages/kernel/src/runtime/test_runner/test-failure.ts b/packages/kernel/src/runtime/test_runner/test-failure.ts new file mode 100644 index 0000000..b8b246a --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/test-failure.ts @@ -0,0 +1,22 @@ +// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and +// other Node contributors). See LICENSE-node. +// Node's ERR_TEST_FAILURE (lib/internal/errors.js): message is `error.message ?? error` +// (inspected when not already a string), `.cause` keeps the original thrown value and +// `.failureType` tags why (kTestCodeFailure, kHookFailure, kTestTimeoutFailure, ...). +import { inspect } from "../inspect"; + +export interface TestFailureError extends Error { + code: "ERR_TEST_FAILURE"; + failureType: string; + cause: unknown; +} + +export function testFailure(error: unknown, failureType: string): TestFailureError { + const raw = (error as { message?: unknown } | null)?.message ?? error; + const msg = typeof raw === "string" ? raw : inspect(raw); + const e = new Error(msg) as TestFailureError; + e.code = "ERR_TEST_FAILURE"; + e.failureType = failureType; + e.cause = error; + return e; +} diff --git a/packages/kernel/src/runtime/test_runner/test.ts b/packages/kernel/src/runtime/test_runner/test.ts new file mode 100644 index 0000000..6678384 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/test.ts @@ -0,0 +1,1297 @@ +// 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/test.js: Test, Suite, TestHook, TestContext, SuiteContext. +// Trimmed from Node's file: no coverage, no randomize/seed, no rerun-failures replay, +// no source-mapped locations, no diagnostics_channel publishing, no watch mode. +// testNamePatterns/testSkipPatterns filtering (willBeFilteredByName, +// getTestNameWithAncestors, testMatchesPattern) IS ported, reading harness.config — +// but nothing feeds that config today: run()'s own testNamePatterns/testSkipPatterns +// option never reaches it for isolation:'none' (confirmed against real Node 24.16.0 — +// see runner.ts), and no CLI flag parser exists here (DESIGN: refused) to set it the +// other way. It is wired so a later CLI parser only has to feed commandLineDefaults(). The +// `only`-filtering behaviour direct execution actually exercises (confirmed against +// real Node 24.16.0: `node file.js` always filters by `only`, unlike `--test`, because +// harness.js's `isFilteringByOnly` is hardcoded true outside `--test`/run()) is kept. +// Concurrency is accepted and validated like Node's, and subtests under `concurrency: +// true` do interleave (their async bodies race on this single-threaded worker's +// cooperative loop the same way Node's real parallel scheduling only ever interleaves +// microtask-level work, not CPU-bound synchronous code) — confirmed against real Node +// 24.16.0 with a `describe(..., { concurrency: true })` of two subtests that each log +// before/after an awaited delay: both goldens print `a1,b1,b2,a2`. +import { invalidArgType, invalidArgValue, outOfRange } from "../errors"; +import { assert as nodeAssert } from "../assert"; +import { testFailure, type TestFailureError } from "./test-failure"; +import { TestsStream, type Loc } from "./tests_stream"; +import { getAssertionMap } from "./assert"; +import { isTestFailureError, countCompletedTest, createDeferredCallback, getCallerLocation } from "./utils"; +import { createMockTracker, MockTracker } from "./mock/mock"; +import { SnapshotManager } from "./snapshot"; + +export const kCancelledByParent = "cancelledByParent"; +const kAborted = "testAborted"; +const kParentAlreadyFinished = "parentAlreadyFinished"; +export const kSubtestsFailed = "subtestsFailed"; +const kTestCodeFailure = "testCodeFailure"; +const kTestTimeoutFailure = "testTimeoutFailure"; +const kExpectedFailure = "expectedFailure"; +const kHookFailure = "hookFailed"; +const kCallbackAndPromisePresent = "callbackAndPromisePresent"; +const kDefaultTimeout: number | null = null; +export const kUnwrapErrors = new Set([kTestCodeFailure, kHookFailure, "uncaughtException", "unhandledRejection"]); +const kHookNames = ["before", "after", "beforeEach", "afterEach"] as const; +type HookName = (typeof kHookNames)[number]; +const noop = (): void => {}; +// Node's lib/internal/timers.js TIMEOUT_MAX: the largest delay setTimeout/setInterval +// accept (a signed 32-bit int), reused here as the upper bound for timeout/plan.wait/ +// waitFor's interval and timeout the way test.js's validateNumber calls do. +const TIMEOUT_MAX = 2 ** 31 - 1; +const MAX_UINT32 = 4_294_967_295; + +// No shared validateUint32/validateNumber/validateAbortSignal exists in src/runtime (each +// module that needs Node's internal/validators.js checks keeps its own small copy — see +// e.g. histogram.ts's validateInteger, readline/promises.ts's validateInteger); these +// mirror internal/validators.js's validateUint32, validateNumber and validateAbortSignal +// for the handful of test_runner options that need them. +function validateUint32(value: unknown, name: string, positive = false): void { + if (typeof value !== "number") throw invalidArgType(name, "number", value); + if (!Number.isInteger(value)) throw outOfRange(name, "an integer", value); + const min = positive ? 1 : 0; + if (value < min || value > MAX_UINT32) throw outOfRange(name, `>= ${min} && <= ${MAX_UINT32}`, value); +} + +function validateNumber(value: unknown, name: string, min?: number, max?: number): void { + if (typeof value !== "number") throw invalidArgType(name, "number", value); + if ((min != null && value < min) || (max != null && value > max) || ((min != null || max != null) && Number.isNaN(value))) { + const range = `${min != null ? `>= ${min}` : ""}${min != null && max != null ? " && " : ""}${max != null ? `<= ${max}` : ""}`; + throw outOfRange(name, range, value); + } +} + +function validateAbortSignal(signal: unknown, name: string): void { + if (signal !== undefined && (signal === null || typeof signal !== "object" || !("aborted" in signal))) { + throw invalidArgType(name, "an instance of AbortSignal", signal); + } +} + +/** Node's test.js testMatchesPattern: a pattern matches by the test's own name, by any + * ancestor's name (a matched suite drags its subtests along), or by the full + * "grandparent parent test" ancestor chain (so a non-unique leaf name can still be + * pinned down). `.exec()`, not `.test()`, to mirror Node's RegExpPrototypeExec exactly + * — including its `lastIndex` statefulness for a pattern carrying the `g`/`y` flag. */ +function testMatchesPattern(test: Test, patterns: RegExp[]): boolean { + const matchesByNameOrParent = patterns.some((re) => re.exec(test.name) !== null) + || (test.parent !== null && testMatchesPattern(test.parent, patterns)); + if (matchesByNameOrParent) return true; + const testNameWithAncestors = test.getTestNameWithAncestors().trim(); + return patterns.some((re) => re.exec(testNameWithAncestors) !== null); +} + +/** The test whose body (fn, or before/beforeEach/afterEach/after hook) is currently + * executing. The harness's uncaughtException/unhandledRejection handlers attribute an + * async error to this test (or the root test when nothing is running), the closest + * honest equivalent of Node's async_hooks-based resource-to-test attribution + * (async_hooks.createHook is refused here — see divergences.json "test." + * and DESIGN.md's refusals section). */ +let currentlyRunning: Test | null = null; +export function getCurrentlyRunningTest(): Test | null { + return currentlyRunning; +} + +/** The nearest Suite whose builder callback is on the stack right now, pushed and + * popped around the synchronous part of a Suite's body (Suite#createBuild). + * harness.ts's top-level test()/describe()/hooks read it, falling back to the root. + * Node answers this through async_hooks (testResources keyed by asyncId), which + * follows awaits. The kernel has no async context, so a test()/it() registered after + * an await inside an async describe() builder lands under the root here, not under + * the suite. */ +const buildingStack: Test[] = []; +export function pushBuildingParent(parent: Test): void { + buildingStack.push(parent); +} +export function popBuildingParent(): void { + buildingStack.pop(); +} + +export function currentBuildingParent(): Test | null { + return buildingStack[buildingStack.length - 1] ?? null; +} + +export interface Harness { + config: { + timeout: number; only: boolean; cwd: string; updateSnapshots: boolean; + testNamePatterns: RegExp[] | null; testSkipPatterns: RegExp[] | null; + }; + counters: { + tests: number; failed: number; passed: number; cancelled: number; skipped: number; todo: number; + topLevel: number; suites: number; + }; + success: boolean; + isFilteringByOnly: boolean; + /** True when either pattern list is non-null — runner.ts's commandLineDefaults() + * path (a later CLI parser would set this the same way). Read once per subtest, in + * the constructor, to decide whether to call willBeFilteredByName() at all. */ + isFilteringByName: boolean; + reporter?: TestsStream; + reported?: boolean; + snapshotManager?: SnapshotManager; + /** Node's harness.bootstrapPromise: null once resolved, otherwise every top-level + * test()/describe() call (harness.ts's startSubtestAfterBootstrap) awaits it before + * actually starting. createTestTree() seeds it with an already-resolved promise + * (Node's runBootstrap(), which only does real async work for options.globalSetupPath + * — unwired here, see runner.ts's header); run()'s isolation:'none' path (runner.ts's + * runIsolatedNone) replaces it with one that stays pending until every test file has + * loaded, which is the entire race fix: no top-level test can start — let alone + * finish and trigger finalize()'s "every top-level test is done" check below — before + * every file has had a chance to register its own. */ + bootstrapPromise?: Promise | null; + /** Node's harness.buildPromise: resolves once every *top-level* Suite's buildSuite + * (and, transitively, everything synchronously reachable from it) has settled — + * harness.ts's startSubtestAfterBootstrap awaits it, nulling it once, before any + * top-level test()/describe() actually starts(); Test#start() (below) re-checks it + * so a nested subtest started from inside a Suite.run() that happens to race ahead + * of the gate also defers. Seeded with a pending promise in createTestTree(); + * resolved by waitForBuildPhase() once harness.buildSuites settles. This is the + * fix for exactly the bug this file's history records: without it, a describe() + * whose builder callback awaits (an async describe) lets sibling top-level tests + * that build synchronously start and finish first, so the spec reporter sees them + * — and reports them — before the async describe's own subtests ever got attached. */ + buildPromise?: Promise | null; + /** Node's harness.buildSuites: every top-level Suite's `buildSuite` promise, + * collected by startSubtestAfterBootstrap as each top-level describe() registers, + * then awaited together (Promise.all) by waitForBuildPhase() before buildPromise + * resolves. */ + buildSuites?: Promise[]; + /** Node's harness.isWaitingForBuildPhase: set once a microtask has been queued to + * call waitForBuildPhase(), so the second (and later) top-level registration in the + * same synchronous turn doesn't queue a redundant one. */ + isWaitingForBuildPhase?: boolean; + /** Node's harness.waitForBuildPhase(): awaits every buildSuites entry collected so + * far, then resolves buildPromise. Queued as a microtask by the first top-level + * registration, so by the time it runs, every top-level test()/describe() call made + * synchronously in this turn has already pushed its own buildSuite into the array. */ + waitForBuildPhase?: () => Promise; +} + +export interface TestOptions { + name?: string; + fn?: TestFn; + parent?: Test | null; + concurrency?: number | boolean; + only?: boolean; + timeout?: number; + todo?: boolean | string; + skip?: boolean | string; + signal?: AbortSignal; + plan?: number; + expectFailure?: ExpectFailure; + loc?: [number, number, string]; + harness?: Harness; + entryFile?: string; + hookType?: HookName; +} + +export type ExpectFailure = boolean | string | RegExp | ((err: unknown) => boolean) + | { label?: string; match?: RegExp | ((err: unknown) => boolean) }; +type ParsedExpectFailure = false | { label: string | undefined; match: RegExp | ((err: unknown) => boolean) | undefined }; + +function parseExpectFailure(expectFailure: ExpectFailure | undefined): ParsedExpectFailure { + if (expectFailure === undefined || expectFailure === false) return false; + if (typeof expectFailure === "string") return { label: expectFailure, match: undefined }; + if (typeof expectFailure === "function" || expectFailure instanceof RegExp) return { label: undefined, match: expectFailure }; + if (typeof expectFailure !== "object") return { label: undefined, match: undefined }; + const keys = Object.keys(expectFailure); + if (keys.length === 0) throw invalidArgValue("options.expectFailure", expectFailure, "must not be an empty object"); + if (keys.every((k) => k === "match" || k === "label")) return { label: expectFailure.label, match: expectFailure.match }; + return { label: undefined, match: undefined }; +} + +export type TestFn = (ctx: TestContext | SuiteContext, cb?: (err?: unknown) => void) => unknown; + +export interface RunArgs { ctx: TestContext | SuiteContext; args: [TestContext] | [SuiteContext]; } + +export class TestPlan { + expected: number; + actual = 0; + wait: boolean | number | undefined; + #waitIndefinitely = false; + #planPromise: { promise: Promise; resolve: () => void; reject: (err: unknown) => void } | null = null; + #timeoutId: ReturnType | null = null; + + constructor(count: number, options: { wait?: boolean | number } = {}) { + validateUint32(count, "count"); + if (options === null || typeof options !== "object") throw invalidArgType("options", "Object", options); + this.expected = count; + const { wait } = options; + if (typeof wait === "boolean") { + this.wait = wait; + this.#waitIndefinitely = wait; + } else if (typeof wait === "number") { + validateNumber(wait, "options.wait", 0, TIMEOUT_MAX); + this.wait = wait; + } else if (wait !== undefined) { + throw invalidArgType("options.wait", "one of type boolean or number", wait); + } + } + + #planMet(): boolean { + return this.actual === this.expected; + } + + #shouldWait(): boolean { + return this.wait !== undefined && this.wait !== false; + } + + check(): Promise | undefined { + if (this.#planMet()) { + if (this.#timeoutId !== null) { + clearTimeout(this.#timeoutId); + this.#timeoutId = null; + } + if (this.#planPromise) { + this.#planPromise.resolve(); + this.#planPromise = null; + } + return; + } + if (!this.#shouldWait()) { + throw testFailure(`plan expected ${this.expected} assertions but received ${this.actual}`, kTestCodeFailure); + } + if (!this.#planPromise) { + let resolve!: () => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + this.#planPromise = { promise, resolve, reject }; + if (!this.#waitIndefinitely) { + this.#timeoutId = setTimeout(() => { + reject(testFailure( + `plan timed out after ${this.wait}ms with ${this.actual} assertions when expecting ${this.expected}`, + kTestTimeoutFailure, + )); + }, this.wait as number); + } + } + return this.#planPromise.promise; + } + + count(): void { + this.actual++; + if (this.#planPromise) this.check(); + } +} + +function getFullName(test: Test): string { + if (test === test.root) return test.name; + let fullName = test.name; + for (let t = test.parent!; t !== t.root; t = t.parent!) fullName = `${t.name} > ${fullName}`; + return fullName; +} + +function stopTest(timeout: number | null, signal: AbortSignal): { promise: Promise; dispose(): void } { + let resolve!: () => void; + const base = new Promise((res) => (resolve = res)); + const onAbort = (): void => resolve(); + signal.addEventListener("abort", onAbort); + let timer: ReturnType | undefined; + let promise = base; + let dispose: () => void; + if (timeout === kDefaultTimeout) { + dispose = () => signal.removeEventListener("abort", onAbort); + } else { + timer = setTimeout(resolve, timeout!); + (timer as unknown as { unref?: () => void }).unref?.(); + promise = base.then(() => { + throw testFailure(`test timed out after ${timeout}ms`, kTestTimeoutFailure); + }); + dispose = () => { + signal.removeEventListener("abort", onAbort); + clearTimeout(timer); + }; + } + return { promise, dispose }; +} + +export class TestContext { + #assert: Record unknown> | undefined; + #test: Test; + + constructor(test: Test) { + this.#test = test; + } + + get signal(): AbortSignal { + return this.#test.signal; + } + + get name(): string { + return this.#test.name; + } + + get filePath(): string | undefined { + return this.#test.entryFile; + } + + get fullName(): string { + return getFullName(this.#test); + } + + get error(): unknown { + return this.#test.error; + } + + get passed(): boolean { + return this.#test.passed; + } + + get attempt(): number { + return 0; + } + + get workerId(): number | undefined { + return undefined; + } + + diagnostic(message: string): void { + this.#test.diagnostic(message); + } + + plan(count: number, options: { wait?: boolean | number } = {}): void { + if (this.#test.plan !== null) throw testFailure("cannot set plan more than once", kTestCodeFailure); + this.#test.plan = new TestPlan(count, options); + } + + get assert(): Record unknown> { + if (this.#assert === undefined) { + const test = this.#test; + const map = getAssertionMap(); + // Node's lazyAssertObject(harness): the shared assertion map gains 'snapshot'/ + // 'fileSnapshot' once, ever, for the process — the SnapshotManager instance is + // created from whichever harness happens to reach here first (there is only + // ever one harness per worker here, so this always matches Node). + if (!map.has("snapshot")) { + // A subtest's own `.harness` field is null (only the root Test carries it — + // see Test's constructor/`harness` field above); every test reaches the same + // harness through `.root.harness`, matching Node's `this.#test.root.harness`. + const harness = test.root.harness!; + const manager = new SnapshotManager(harness.config.updateSnapshots); + harness.snapshotManager = manager; + map.set("snapshot", manager.createAssert() as (...args: unknown[]) => unknown); + map.set("fileSnapshot", manager.createFileAssert() as (...args: unknown[]) => unknown); + } + const assertObj: Record unknown> = {}; + this.#assert = assertObj; + for (const [name, method] of map) { + assertObj[name] = (...args: unknown[]) => { + if (test.plan !== null) test.plan.count(); + return method.apply(this, args); + }; + } + if (!map.has("ok")) { + assertObj.ok = (...args: unknown[]) => { + if (test.plan !== null) test.plan.count(); + nodeAssert.ok(...args); + }; + } + } + return this.#assert; + } + + get mock(): unknown { + return this.#test.mock(); + } + + runOnly(value: unknown): void { + this.#test.runOnlySubtests = Boolean(value); + } + + skip(message?: string): void { + this.#test.skip(message); + } + + todo(message?: string): void { + this.#test.todo(message); + } + + test(name: unknown, options?: unknown, fn?: unknown): Promise { + if (this.#test.plan !== null) this.#test.plan.count(); + const subtest = this.#test.createSubtest(Test, name, options, fn, { loc: getCallerLocation() }); + return subtest.start(); + } + + before(fn: unknown, options?: unknown): void { + this.#test.createHook("before", fn, { ...(options as object), parent: this.#test, hookType: "before", loc: getCallerLocation() }); + } + + after(fn: unknown, options?: unknown): void { + this.#test.createHook("after", fn, { ...(options as object), parent: this.#test, hookType: "after", loc: getCallerLocation() }); + } + + beforeEach(fn: unknown, options?: unknown): void { + this.#test.createHook("beforeEach", fn, { ...(options as object), parent: this.#test, hookType: "beforeEach", loc: getCallerLocation() }); + } + + afterEach(fn: unknown, options?: unknown): void { + this.#test.createHook("afterEach", fn, { ...(options as object), parent: this.#test, hookType: "afterEach", loc: getCallerLocation() }); + } + + /** Node's TestContext#waitFor: poll `condition` every `interval`ms (default 50) until + * it returns/resolves truthy-or-whatever, or `timeout`ms (default 1000) elapses. */ + waitFor(condition: unknown, options: { interval?: number; timeout?: number } = {}): Promise { + if (typeof condition !== "function") throw invalidArgType("condition", "of type function", condition); + if (options === null || typeof options !== "object") throw invalidArgType("options", "Object", options); + const { interval = 50, timeout = 1000 } = options; + validateNumber(interval, "options.interval", 0, TIMEOUT_MAX); + validateNumber(timeout, "options.timeout", 0, TIMEOUT_MAX); + let resolve!: (v: unknown) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + let cause: unknown; + const noError = Symbol(); + let causeSet = false; + let pollerId: ReturnType | undefined; + const timeoutId = setTimeout(() => { + clearTimeout(pollerId); + const err = new Error("waitFor() timed out"); + if (causeSet) (err as Error & { cause?: unknown }).cause = cause; + reject(err); + }, timeout); + const poller = async (): Promise => { + try { + const result = await (condition as () => unknown)(); + clearTimeout(timeoutId); + resolve(result); + } catch (err) { + cause = err; + causeSet = true; + pollerId = setTimeout(poller, interval); + } + }; + void poller(); + void noError; + return promise; + } +} + +export class SuiteContext { + #suite: Suite; + + constructor(suite: Suite) { + this.#suite = suite; + } + + get signal(): AbortSignal { + return this.#suite.signal; + } + + get name(): string { + return this.#suite.name; + } + + get filePath(): string | undefined { + return this.#suite.entryFile; + } + + get fullName(): string { + return getFullName(this.#suite); + } + + get passed(): boolean { + return this.#suite.passed; + } + + get attempt(): number { + return 0; + } + + diagnostic(message: string): void { + this.#suite.diagnostic(message); + } +} + +interface PendingSubtest { + test: Test; + resolve: () => void; +} + +export class Test { + reportedType: "test" | "suite" = "test"; + abortController = new AbortController(); + outerSignal: AbortSignal | undefined; + signal: AbortSignal; + name: string; + parent: Test | null; + root: Test; + harness: Harness | null = null; + config: Harness["config"]; + concurrency = 1; + nesting: number; + only: boolean | undefined; + reporter: TestsStream; + runOnlySubtests: boolean; + childNumber: number; + timeout: number | null; + entryFile: string | undefined; + testId: number; + loc: Loc | undefined; + fn: TestFn; + mockTracker: MockTracker | null = null; + plan: TestPlan | null = null; + expectedAssertions: number | undefined; + cancelled = false; + expectFailure: ParsedExpectFailure; + skipped: boolean; + isTodo: boolean; + startTime: number | null = null; + endTime: number | null = null; + passed = false; + error: unknown = null; + message: string | null; + activeSubtests = 0; + pendingSubtests: PendingSubtest[] = []; + pendingSubtestsScheduled = false; + readySubtests = new Map(); + unfinishedSubtests = new Set(); + subtestsPromise: { promise: Promise; resolve: () => void } | null = null; + subtests: Test[] = []; + nextReportOrder = 1; + reportOrder = 0; + waitingOn = 0; + finished = false; + filtered = false; + filteredByName = false; + hasOnlyTests = false; + testNumber = 0; + outputSubtestCount = 0; + diagnostics: string[] = []; + nextTestId = 1; + hookType: HookName | undefined; + reported = false; + #reportedSubtest = false; + hooks: { before: TestHook[]; after: TestHook[]; beforeEach: TestHook[]; afterEach: TestHook[]; ownAfterEachCount: number } = { + before: [], after: [], beforeEach: [], afterEach: [], ownAfterEachCount: 0, + }; + /** Always false on a plain Test; Suite's own field (below) flips to true once its + * createBuild() settles. Declared here (not just on Suite) so createSubtest's guard + * can read it on any parent without an instanceof check, matching Node's base + * Test#buildPhaseFinished, which is likewise undefined (falsy) off a Suite. */ + buildPhaseFinished = false; + + constructor(options: TestOptions) { + let { fn, name, parent = null } = options; + const { concurrency, entryFile, expectFailure, loc, only, timeout, todo, skip, signal, plan } = options; + if (typeof fn !== "function") fn = noop; + if (typeof name !== "string" || name === "") name = fn.name || ""; + if (!(parent instanceof Test)) parent = null; + + this.name = name; + this.parent = parent; + + if (parent === null) { + this.root = this; + this.harness = options.harness!; + this.config = this.harness.config; + this.concurrency = 1; + this.nesting = 0; + this.only = this.config.only; + this.reporter = new TestsStream(); + this.runOnlySubtests = this.only; + this.childNumber = 0; + this.timeout = kDefaultTimeout; + this.entryFile = entryFile; + this.testId = 0; + } else { + const nesting = parent.parent === null ? parent.nesting : parent.nesting + 1; + const { config, isFilteringByOnly, isFilteringByName } = parent.root.harness!; + this.root = parent.root; + this.config = config; + this.concurrency = parent.concurrency; + this.nesting = nesting; + this.only = only; + this.reporter = parent.reporter; + this.runOnlySubtests = false; + this.childNumber = parent.subtests.length + 1; + this.timeout = parent.timeout; + this.entryFile = parent.entryFile; + this.testId = this.root.nextTestId++; + + if (isFilteringByName) { + this.filteredByName = this.willBeFilteredByName(); + if (!this.filteredByName) { + for (let t: Test | null = this.parent; t !== null && t.filteredByName; t = t.parent) t.filteredByName = false; + } + } + + if (isFilteringByOnly) { + if (this.only) { + this.parent!.runOnlySubtests = true; + if (this.parent === this.root || this.parent!.startTime === null) { + for (let t: Test | null = this.parent; t !== null && !t.hasOnlyTests; t = t.parent) t.hasOnlyTests = true; + } + } else if (this.only === false) { + fn = noop; + } + } + } + + switch (typeof concurrency) { + case "number": + validateUint32(concurrency, "options.concurrency", true); + this.concurrency = concurrency; + break; + case "boolean": + this.concurrency = concurrency ? (parent === null ? 1 : Infinity) : 1; + break; + default: + if (concurrency != null) throw invalidArgType("options.concurrency", "one of type boolean or number", concurrency); + } + + if (timeout != null && timeout !== Infinity) { + validateNumber(timeout, "options.timeout", 0, TIMEOUT_MAX); + this.timeout = timeout; + } else if (timeout == null) { + const cliTimeout = this.config.timeout; + if (cliTimeout != null && cliTimeout !== Infinity) { + validateNumber(cliTimeout, "this.config.timeout", 0, TIMEOUT_MAX); + this.timeout = cliTimeout; + } + } + + if (skip) fn = noop; + + this.outerSignal = signal; + this.signal = this.abortController.signal; + validateAbortSignal(signal, "options.signal"); + this.outerSignal?.addEventListener("abort", this.#abortHandler); + + this.fn = fn; + this.expectedAssertions = plan; + this.expectFailure = parseExpectFailure(expectFailure) || (this.parent?.expectFailure ?? false); + this.skipped = skip !== undefined && skip !== false; + this.isTodo = (todo !== undefined && todo !== false) || Boolean(this.parent?.isTodo); + this.message = typeof skip === "string" ? skip : typeof todo === "string" ? todo : null; + this.loc = loc === undefined ? undefined : { line: loc[0], column: loc[1], file: loc[2] }; + } + + applyFilters(): void { + if (this.error) return; + if (this.filteredByName) { + this.filtered = true; + return; + } + if (this.root.harness!.isFilteringByOnly && !this.only && !this.hasOnlyTests) { + if (this.parent!.runOnlySubtests || this.parent!.hasOnlyTests || this.only === false) this.filtered = true; + } + } + + /** Node's test.js willBeFilteredByName(): read once, from the constructor, against + * harness.config.testNamePatterns/testSkipPatterns — null for every path here today + * (direct execution, and run(), whose own testNamePatterns/testSkipPatterns option + * Node itself never wires into this config for isolation:'none' — see runner.ts's + * header comment), so this is dead code until a later CLI flag parser sets either + * field through commandLineDefaults(). Kept so that wiring has somewhere to land. */ + willBeFilteredByName(): boolean { + const { testNamePatterns, testSkipPatterns } = this.config; + if (testNamePatterns && !testMatchesPattern(this, testNamePatterns)) return true; + if (testSkipPatterns && testMatchesPattern(this, testSkipPatterns)) return true; + return false; + } + + /** Node's test.js getTestNameWithAncestors(): "grandparent parent test", used by + * testMatchesPattern so a pattern can match a test by its full nested name. */ + getTestNameWithAncestors(): string { + if (!this.parent) return ""; + return `${this.parent.getTestNameWithAncestors()} ${this.name}`; + } + + hasConcurrency(): boolean { + return this.concurrency > this.activeSubtests; + } + + addPendingSubtest(p: PendingSubtest): void { + this.pendingSubtests.push(p); + } + + schedulePendingSubtests(): void { + if (this.pendingSubtestsScheduled) return; + this.pendingSubtestsScheduled = true; + queueMicrotask(() => { + this.pendingSubtestsScheduled = false; + void this.processPendingSubtests(); + }); + } + + assignReportOrder(subtest: Test): void { + if (subtest.reportOrder === 0) subtest.reportOrder = this.nextReportOrder++; + } + + async processPendingSubtests(): Promise { + while (this.pendingSubtests.length > 0 && this.hasConcurrency()) { + const deferred = this.pendingSubtests.shift()!; + const test = deferred.test; + this.assignReportOrder(test); + test.reporter.dequeue(test.nesting, test.loc, test.name, this.reportedType, test.testId); + await test.run(); + deferred.resolve(); + } + } + + addReadySubtest(subtest: Test): void { + this.assignReportOrder(subtest); + this.readySubtests.set(subtest.reportOrder, subtest); + if (this.unfinishedSubtests.delete(subtest) && this.unfinishedSubtests.size === 0) this.subtestsPromise!.resolve(); + } + + isClearToSend(): boolean { + return this.parent === null || (this.parent.waitingOn === this.reportOrder && this.parent.isClearToSend()); + } + + processReadySubtestRange(canSend: boolean): void { + const start = this.waitingOn; + const end = start + this.readySubtests.size; + for (let i = start; i < end; i++) { + const subtest = this.readySubtests.get(i); + if (subtest === undefined) return; + canSend ||= this.isClearToSend(); + if (!canSend) return; + subtest.finalize(); + this.readySubtests.delete(i); + } + } + + createSubtest(Factory: typeof Test, nameArg: unknown, optionsArg: unknown, fnArg: unknown, overrides: Partial): Test { + let name = nameArg; + let options = optionsArg; + let fn = fnArg; + if (typeof name === "function") { + fn = name; + name = undefined; + } else if (name !== null && typeof name === "object") { + fn = options; + options = name; + name = undefined; + } else if (typeof options === "function") { + fn = options; + options = undefined; + } + if (options === null || typeof options !== "object") options = {}; + + let parent: Test = this; + // Node's test.js: `this.finished || this.buildPhaseFinished` — a describe() whose + // own builder callback already settled (e.g. it threw, or just returned) can still + // have a stray test()/describe() call land on it afterward (a callback that fires + // late); attach that one to the root instead, same as a call on an already-finished + // test. + const preventAddingSubtests = this.finished || this.buildPhaseFinished; + if (preventAddingSubtests) { + while (parent.parent !== null) parent = parent.parent; + } + + const test = new Factory({ fn: fn as TestFn, name: name as string | undefined, parent, ...(options as object), ...overrides }); + + if (parent.waitingOn === 0) { + parent.waitingOn = test.childNumber; + let resolve!: () => void; + const promise = new Promise((res) => (resolve = res)); + parent.subtestsPromise = { promise, resolve }; + } + + if (preventAddingSubtests) { + test.fail(testFailure("test could not be started because its parent finished", kParentAlreadyFinished)); + } + + parent.subtests.push(test); + return test; + } + + #abortHandler = (): void => { + const error = Object.assign(new Error("The test was aborted"), { name: "AbortError", failureType: kAborted }); + this.#cancel(error); + }; + + #cancel(error?: unknown): void { + if (this.endTime !== null || this.error !== null) return; + this.fail(error ?? testFailure("test did not finish before its parent and was cancelled", kCancelledByParent)); + this.cancelled = true; + this.abortController.abort(); + } + + computeInheritedHooks(): void { + if (this.parent!.hooks.beforeEach.length > 0) this.hooks.beforeEach.unshift(...this.parent!.hooks.beforeEach); + if (this.parent!.hooks.afterEach.length > 0) this.hooks.afterEach.push(...this.parent!.hooks.afterEach); + } + + createHook(name: HookName, fn: unknown, options: { loc?: [number, number, string] } & Record): void { + if (!kHookNames.includes(name)) throw invalidArgValue("hook name", name, `must be one of: ${kHookNames.map((n) => `'${n}'`).join(", ")}`); + const hook = new TestHook(fn as TestFn, options as unknown as TestOptions & { parent: Test; hookType: HookName }); + if (name === "before" || name === "after") { + const run = hook.run.bind(hook); + let ran = false; + let result: Promise | undefined; + hook.run = (args?: RunArgs) => { + if (!ran) { + ran = true; + result = run(args); + } + return result!; + }; + } + if (name === "before" && this.startTime !== null) { + void hook.run(this.getRunArgs()).then(() => { + if (hook.error != null) this.fail(hook.error); + }); + } + if (name === "afterEach") { + this.hooks.afterEach.splice(this.hooks.ownAfterEachCount, 0, hook); + this.hooks.ownAfterEachCount++; + } else { + this.hooks[name].push(hook); + } + } + + fail(err: unknown): void { + if (this.error !== null) return; + if (this.expectFailure) { + if (this.expectFailure.match !== undefined) { + const match = this.expectFailure.match; + const e = err as { code?: string; failureType?: string; cause?: unknown }; + const errorToCheck = e?.code === "ERR_TEST_FAILURE" && e?.failureType === kTestCodeFailure && e.cause !== undefined + ? e.cause : err; + const ok = match instanceof RegExp ? match.test(String((errorToCheck as Error)?.message ?? errorToCheck)) + : typeof match === "function" ? Boolean((match as (e: unknown) => boolean)(errorToCheck)) : false; + if (!ok) { + this.passed = false; + this.error = testFailure("The test failed, but the error did not match the expected validation", kTestCodeFailure); + (this.error as { cause?: unknown }).cause = errorToCheck; + return; + } + } + this.passed = true; + } else { + this.passed = false; + } + this.error = err; + } + + pass(): void { + if (this.error == null && this.expectFailure && !this.skipped) { + this.passed = false; + this.error = testFailure("test was expected to fail but passed", kExpectedFailure); + return; + } + if (this.error !== null) return; + if (this.skipped || this.isTodo) { + this.passed = true; + return; + } + if (this.expectFailure) { + this.passed = false; + this.error = testFailure("Test passed but was expected to fail", kTestCodeFailure); + return; + } + this.passed = true; + } + + skip(message?: string): void { + this.skipped = true; + this.message = message ?? null; + } + + todo(message?: string): void { + this.isTodo = true; + this.message = message ?? null; + } + + diagnostic(message: string): void { + this.diagnostics.push(message); + } + + mock(): unknown { + this.mockTracker ??= createMockTracker(); + return this.mockTracker; + } + + start(): Promise { + this.applyFilters(); + if (!this.filtered) this.testNumber = ++this.parent!.outputSubtestCount; + + this.parent!.unfinishedSubtests.add(this); + this.reporter.enqueue(this.nesting, this.loc, this.name, this.reportedType, this.testId); + // Node's test.js start(): `this.root.harness.buildPromise || !this.parent.hasConcurrency()`. + // harness.ts's startSubtestAfterBootstrap already awaits (and nulls) buildPromise + // before calling a top-level subtest's start(), so this branch of the check is + // normally moot for a top-level call; it matters for a subtest started directly by + // a Suite's own run() (its `subtests.map(s => s.start())`), which re-checks the same + // shared root harness rather than assuming the gate already cleared. + if (this.root.harness!.buildPromise || !this.parent!.hasConcurrency()) { + let resolve!: () => void; + const promise = new Promise((res) => (resolve = res)); + this.parent!.addPendingSubtest({ test: this, resolve }); + return promise; + } + + this.parent!.assignReportOrder(this); + this.reporter.dequeue(this.nesting, this.loc, this.name, this.reportedType, this.testId); + return this.run(); + } + + shouldAbort(): boolean { + if (this.signal.aborted || this.outerSignal?.aborted) { + this.#abortHandler(); + return true; + } + return false; + } + + getRunArgs(): RunArgs { + const ctx = new TestContext(this); + return { ctx, args: [ctx] }; + } + + async runHook(hookName: HookName, args: RunArgs): Promise { + try { + const hooks = this.hooks[hookName]; + for (const hook of hooks) { + await hook.run(args); + if (hook.error) throw hook.error; + } + } catch (err) { + const error = testFailure(`failed running ${hookName} hook`, kHookFailure); + error.cause = isTestFailureError(err) ? err.cause : err; + throw error; + } + } + + async run(): Promise { + if (this.parent !== null) { + this.parent.activeSubtests++; + this.computeInheritedHooks(); + } + this.startTime ??= performance.now(); + + if (this.shouldAbort()) { + this.postRun(); + return; + } + + const hookArgs = this.getRunArgs(); + const { args } = hookArgs; + const ctx = hookArgs.ctx as TestContext; + if (this.plan === null && this.expectedAssertions) ctx.plan(this.expectedAssertions); + + const wasSkippedBeforeRun = this.skipped; + const after = async (): Promise => { + if (this.hooks.after.length > 0) await this.runHook("after", hookArgs); + }; + let afterEachRan = false; + let afterEachPromise: Promise | undefined; + const afterEach = (): Promise => { + if (!afterEachRan) { + afterEachRan = true; + afterEachPromise = (async () => { + if ((this.parent?.hooks.afterEach.length ?? 0) > 0 && !wasSkippedBeforeRun) { + await this.parent!.runHook("afterEach", hookArgs); + } + })(); + } + return afterEachPromise!; + }; + + let stop: { promise: Promise; dispose(): void } | undefined; + const previouslyRunning = currentlyRunning; + currentlyRunning = this; + try { + if ((this.parent?.hooks.before.length ?? 0) > 0) await this.parent!.runHook("before", this.parent!.getRunArgs()); + if ((this.parent?.hooks.beforeEach.length ?? 0) > 0 && !this.skipped) await this.parent!.runHook("beforeEach", hookArgs); + + stop = stopTest(this.timeout, this.signal); + const runArgs: unknown[] = [...args]; + + const promises: Promise[] = []; + if (this.fn.length === runArgs.length + 1) { + // Legacy Node-style error-first callback. + const { promise, cb } = createDeferredCallback(); + const ret = this.fn(ctx, cb); + if (ret && typeof (ret as Promise).then === "function") { + this.fail(testFailure("passed a callback but also returned a Promise", kCallbackAndPromisePresent)); + promises.push(Promise.resolve(ret)); + } else { + promises.push(promise); + } + } else { + promises.push(Promise.resolve(this.fn(ctx))); + } + promises.push(stop.promise); + await Promise.race(promises); + + this.shouldAbort(); + + if (this.subtestsPromise !== null) await Promise.race([this.subtestsPromise.promise, stop.promise]); + + if (this.plan !== null) { + const planPromise = this.plan.check(); + if (planPromise) await Promise.race([planPromise, stop.promise]); + } + + this.pass(); + await afterEach(); + await after(); + } catch (err) { + if (isTestFailureError(err)) { + if (err.failureType === kTestTimeoutFailure) this.#cancel(err); + else this.fail(err); + } else { + this.fail(testFailure(err, kTestCodeFailure)); + } + try { + await afterEach(); + } catch { /* test is already failing */ } + try { + await after(); + } catch { /* ignore */ } + } finally { + stop?.dispose(); + currentlyRunning = previouslyRunning; + if (this.parent !== null) this.abortController.abort(); + } + + this.postRun(); + } + + postRun(pendingSubtestsError?: unknown): void { + this.endTime ??= performance.now(); + this.startTime ??= this.endTime; + + this.pendingSubtests = []; + let failed = 0; + for (const subtest of this.subtests) { + if (!subtest.finished) { + subtest.#cancel(pendingSubtestsError); + subtest.postRun(pendingSubtestsError); + } + if (!subtest.passed && !subtest.isTodo) failed++; + } + + if ((this.passed || this.parent === null) && failed > 0) { + const subtestString = `subtest${failed > 1 ? "s" : ""}`; + this.fail(testFailure(`${failed} ${subtestString} failed`, kSubtestsFailed)); + } + + this.outerSignal?.removeEventListener("abort", this.#abortHandler); + // Node's postRun(): `this.mock?.reset()`, restoring every mock the test made and + // disabling its mock timers, whether the test passed, failed or never mocked + // anything (reset() is a no-op in that last case). + this.mockTracker?.reset(); + + if (this.parent !== null) { + if (!this.filtered) { + const report = this.getReportDetails(); + (report.details as { passed?: boolean }).passed = this.passed; + this.testNumber ||= ++this.parent.outputSubtestCount; + this.reporter.complete(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive, this.testId); + this.parent.activeSubtests--; + } + this.parent.addReadySubtest(this); + this.parent.processReadySubtestRange(false); + void this.parent.processPendingSubtests(); + } else if (!this.reported) { + const { diagnostics, harness, loc, nesting, reporter } = this; + this.reported = true; + reporter.plan(nesting, loc, harness!.counters.topLevel); + // Node's postRun(): write recorded snapshots before diagnostics, since a failed + // write is itself reported as a diagnostic. + harness!.snapshotManager?.writeSnapshotFiles(); + for (const d of diagnostics) reporter.diagnostic(nesting, loc, d); + const duration = this.duration(); + reporter.diagnostic(nesting, loc, `tests ${harness!.counters.tests}`); + reporter.diagnostic(nesting, loc, `suites ${harness!.counters.suites}`); + reporter.diagnostic(nesting, loc, `pass ${harness!.counters.passed}`); + reporter.diagnostic(nesting, loc, `fail ${harness!.counters.failed}`); + reporter.diagnostic(nesting, loc, `cancelled ${harness!.counters.cancelled}`); + reporter.diagnostic(nesting, loc, `skipped ${harness!.counters.skipped}`); + reporter.diagnostic(nesting, loc, `todo ${harness!.counters.todo}`); + reporter.diagnostic(nesting, loc, `duration_ms ${duration}`); + reporter.summary(nesting, loc?.file, harness!.success, harness!.counters, duration); + reporter.end(); + } + } + + finalize(): void { + this.processReadySubtestRange(true); + this.report(); + this.parent!.waitingOn++; + this.finished = true; + + // `waitingOn > subtests.length` means every top-level test has finished — safe to + // rely on because no top-level test can even start until harness.ts's + // startSubtestAfterBootstrap has resolved its `bootstrapPromise` wait, and (for + // run()'s isolation:'none' path) that promise only resolves once every test file + // has loaded and registered its own top-level tests. Give the root test's own + // run() a chance to execute (it reports the final summary once it returns), same + // as Node's harness invoking this from the last subtest's finalize() rather than + // waiting for a separate 'beforeExit' turn. + if (this.parent === this.root && this.root.waitingOn > this.root.subtests.length) { + void this.root.run(); + } + } + + duration(): number { + return (this.endTime! - this.startTime!); + } + + getReportDetails(): { details: Record; directive: Record | undefined } { + let directive: Record | undefined; + const details: Record = { duration_ms: this.duration() }; + if (this.skipped) directive = this.reporter.getSkip(this.message ?? undefined); + else if (this.isTodo) directive = this.reporter.getTodo(this.message ?? undefined); + else if (this.expectFailure) directive = this.reporter.getXFail(this.expectFailure.label); + + if (this.reportedType) details.type = this.reportedType; + if (!this.passed) details.error = this.error; + return { details, directive }; + } + + report(): void { + countCompletedTest(this); + if (this.outputSubtestCount > 0) this.reporter.plan(this.subtests[0]!.nesting, this.loc, this.outputSubtestCount); + else this.reportStarted(); + const report = this.getReportDetails(); + if (this.passed) this.reporter.ok(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive, this.testId); + else this.reporter.fail(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive, this.testId); + for (const d of this.diagnostics) this.reporter.diagnostic(this.nesting, this.loc, d); + } + + reportStarted(): void { + if (this.#reportedSubtest || this.parent === null) return; + this.#reportedSubtest = true; + this.parent.reportStarted(); + this.reporter.start(this.nesting, this.loc, this.name, this.testId); + } +} + +export class TestHook extends Test { + override reportedType: "test" | "suite" = "test"; + parentTest: Test; + #args: RunArgs | undefined; + + constructor(fn: TestFn, options: TestOptions & { parent: Test; hookType: HookName }) { + const { hookType, loc, parent, timeout, signal } = options; + super({ fn, loc, timeout, signal, harness: parent.root.harness! }); + this.parentTest = parent; + this.hookType = hookType; + } + + override run(args?: RunArgs): Promise { + if (args) this.#args = args; + if (this.error && !this.outerSignal?.aborted) { + this.passed = false; + this.error = null; + this.abortController.abort(); + this.abortController = new AbortController(); + this.signal = this.abortController.signal; + } + return super.run(); + } + + override getRunArgs(): RunArgs { + return this.#args!; + } + + override postRun(): void { + const { error, loc, parentTest: parent } = this; + if (error && parent === parent.root && this.hookType === "after") { + if (isTestFailureError(error)) error.failureType = kHookFailure; + this.endTime ??= performance.now(); + parent.reporter.fail(0, loc, parent.subtests.length + 1, loc!.file, { duration_ms: this.duration(), error }, undefined, 0); + } + } +} + +export class Suite extends Test { + override reportedType: "test" | "suite" = "suite"; + buildSuite: Promise; + + constructor(options: TestOptions) { + super(options); + if (options.timeout == null) this.timeout = null; + // Node's test.js also re-enables a `skip`ped suite's own fn here when BOTH + // testNamePatterns and testSkipPatterns are non-null, so a name-filtered run can + // still see inside it. Omitted: both fields are always null on every path this + // kernel reaches today (see the file header), so the condition can never be true. + this.buildSuite = this.createBuild(options.fn ?? noop); + this.fn = noop; + } + + async createBuild(fn: TestFn): Promise { + try { + const { ctx } = this.getRunArgs(); + let result: unknown; + pushBuildingParent(this); + try { + result = fn(ctx as never); + } finally { + popBuildingParent(); + } + await result; + } catch (err) { + this.fail(testFailure(err, kTestCodeFailure)); + } finally { + this.buildPhaseFinished = true; + } + } + + override getRunArgs(): RunArgs { + const ctx = new SuiteContext(this); + return { ctx, args: [ctx] }; + } + + override async run(): Promise { + this.computeInheritedHooks(); + const hookArgs = this.getRunArgs(); + + let stop: { promise: Promise; dispose(): void } | undefined; + let afterRan = false; + const after = async (): Promise => { + if (!afterRan) { + afterRan = true; + await this.runHook("after", hookArgs); + } + }; + try { + this.parent!.activeSubtests++; + await this.buildSuite; + this.startTime = performance.now(); + + if (this.shouldAbort()) { + this.subtests = []; + this.postRun(); + return; + } + + if (this.parent!.hooks.before.length > 0) await this.parent!.runHook("before", this.parent!.getRunArgs()); + await this.runHook("before", hookArgs); + + stop = stopTest(this.timeout, this.signal); + const subtests = this.skipped || this.error ? [] : this.subtests; + const promise = Promise.all(subtests.map((s) => s.start())); + await Promise.race([promise, stop.promise]); + await after(); + this.pass(); + } catch (err) { + try { + await after(); + } catch { /* suite is already failing */ } + if (isTestFailureError(err)) this.fail(err); + else this.fail(testFailure(err, kTestCodeFailure)); + } finally { + stop?.dispose(); + } + this.postRun(); + } +} + +export { getFullName }; diff --git a/packages/kernel/src/runtime/test_runner/tests_stream.ts b/packages/kernel/src/runtime/test_runner/tests_stream.ts new file mode 100644 index 0000000..ab58914 --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/tests_stream.ts @@ -0,0 +1,115 @@ +// 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/tests_stream.js: an object-mode Readable that is also the +// event bus every Test/Suite reports through. Each method both emits a named `test:*` +// event (for `process.on`-style or custom reporter listeners) and pushes the same +// `{ type, data }` object for anyone consuming the stream itself (node:test/reporters, +// a custom ESM reporter's `source` argument). Field shapes here are the public wire +// format: verified against real Node 24.16.0 by feeding a hand-built test file through +// a custom `--test-reporter` and diffing the JSON it printed per event (see the unit +// brief). Kept deliberately free of coverage/rerun/watch fields Node's harness also +// stuffs through this stream — those are out of scope (coverage needs the inspector; +// watch mode isn't implemented). +import { Readable } from "../stream"; + +export interface Loc { + line: number; + column: number; + file: string; +} + +export type TestEvent = { type: string; data: Record }; + +export class TestsStream extends Readable { + // Node's own #buffer/#canPush (lib/internal/test_runner/tests_stream.js): _read() + // re-arms #canPush and drains anything queued while backpressured, rather than the + // no-op _read a plain pass-through Readable would use. + #buffer: TestEvent[] = []; + #canPush = true; + + constructor() { + super({ objectMode: true, highWaterMark: Number.MAX_SAFE_INTEGER }); + } + + override _read(): void { + this.#canPush = true; + while (this.#buffer.length > 0) { + const obj = this.#buffer.shift()!; + if (!this.#tryPush(obj)) return; + } + } + + #tryPush(message: TestEvent | null): boolean { + if (this.#canPush) this.#canPush = this.push(message); + else this.#buffer.push(message as TestEvent); + return this.#canPush; + } + + #emitMessage(type: string, data: Record): void { + this.emit(type, data); + this.#tryPush({ type, data }); + } + + fail(nesting: number, loc: Loc | undefined, testNumber: number, name: string, + details: Record, directive: Record | undefined, testId: number): void { + this.#emitMessage("test:fail", { name, nesting, testNumber, testId, details, ...loc, ...directive }); + } + + ok(nesting: number, loc: Loc | undefined, testNumber: number, name: string, + details: Record, directive: Record | undefined, testId: number): void { + this.#emitMessage("test:pass", { name, nesting, testNumber, testId, details, ...loc, ...directive }); + } + + complete(nesting: number, loc: Loc | undefined, testNumber: number, name: string, + details: Record, directive: Record | undefined, testId: number): void { + this.#emitMessage("test:complete", { name, nesting, testNumber, testId, details, ...loc, ...directive }); + } + + plan(nesting: number, loc: Loc | undefined, count: number): void { + this.#emitMessage("test:plan", { nesting, count, ...loc }); + } + + getSkip(reason: string | boolean | undefined = undefined): { skip: string | boolean } { + return { skip: reason ?? true }; + } + + getTodo(reason: string | boolean | undefined = undefined): { todo: string | boolean } { + return { todo: reason ?? true }; + } + + getXFail(expectation: string | boolean | undefined = undefined): { expectFailure: string | boolean } { + return { expectFailure: expectation ?? true }; + } + + enqueue(nesting: number, loc: Loc | undefined, name: string, type: string, testId: number): void { + this.#emitMessage("test:enqueue", { nesting, name, type, testId, ...loc }); + } + + dequeue(nesting: number, loc: Loc | undefined, name: string, type: string, testId: number): void { + this.#emitMessage("test:dequeue", { nesting, name, type, testId, ...loc }); + } + + start(nesting: number, loc: Loc | undefined, name: string, testId: number): void { + this.#emitMessage("test:start", { nesting, name, testId, ...loc }); + } + + diagnostic(nesting: number, loc: Loc | undefined, message: string, level: string = "info"): void { + this.#emitMessage("test:diagnostic", { nesting, message, level, ...loc }); + } + + coverage(nesting: number, loc: Loc | undefined, summary: unknown): void { + this.#emitMessage("test:coverage", { nesting, summary, ...loc }); + } + + summary(nesting: number, file: string | undefined, success: boolean, counts: Record, duration_ms: number): void { + this.#emitMessage("test:summary", { success, counts, duration_ms, file }); + } + + interrupted(tests: unknown[]): void { + this.#emitMessage("test:interrupted", { tests }); + } + + end(): void { + this.#tryPush(null); + } +} diff --git a/packages/kernel/src/runtime/test_runner/utils.ts b/packages/kernel/src/runtime/test_runner/utils.ts new file mode 100644 index 0000000..2d947ee --- /dev/null +++ b/packages/kernel/src/runtime/test_runner/utils.ts @@ -0,0 +1,138 @@ +// 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/utils.js, trimmed to what harness.ts/test.ts actually use: +// the callback-invoked-twice guard, the ERR_TEST_FAILURE sniff, the counters bump and +// the CLI-flag defaults. Coverage, randomize/seed, rerun-failures, --test-shard and +// --test-name-pattern all read real CLI flags through getOptionValue(); none of that +// parsing exists here (DESIGN: CLI flags are refused), so commandLineDefaults() is the +// one place a later flag parser would feed real values in. +import { testFailure, type TestFailureError } from "./test-failure"; +import { invalidArgValue } from "../errors"; +import type { Test } from "./test"; + +export const kMultipleCallbackInvocations = "multipleCallbackInvocations"; + +export interface DeferredCallback { + promise: Promise; + cb: (err?: unknown) => void; +} + +/** Node's createDeferredCallback: resolves on the first call, rejects if it carried an + * error, and fails the test (via the thrown ERR_TEST_FAILURE on the second call) if + * the callback is invoked more than once. */ +export function createDeferredCallback(): DeferredCallback { + let calledCount = 0; + let resolve!: () => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const cb = (err?: unknown): void => { + calledCount++; + if (calledCount > 1) { + if (calledCount === 2) throw testFailure("callback invoked multiple times", kMultipleCallbackInvocations); + return; + } + if (err) reject(err); + else resolve(); + }; + return { promise, cb }; +} + +export function isTestFailureError(err: unknown): err is TestFailureError { + return Boolean(err) && typeof err === "object" && (err as { code?: string }).code === "ERR_TEST_FAILURE"; +} + +/** Node's countCompletedTest: bumps the root's summary counters for one finished test + * or suite. Skip/todo win over a failure; a suite only ever affects `suites`. */ +export function countCompletedTest(test: Test): void { + const harness = test.root.harness!; + if (test.nesting === 0) harness.counters.topLevel++; + if (test.reportedType === "suite") { + harness.counters.suites++; + if (!test.passed && !test.isTodo) harness.success = false; + return; + } + if (test.skipped) harness.counters.skipped++; + else if (test.isTodo) harness.counters.todo++; + else if (test.cancelled) { + harness.counters.cancelled++; + harness.success = false; + } else if (!test.passed) { + harness.counters.failed++; + harness.success = false; + } else { + harness.counters.passed++; + } + harness.counters.tests++; +} + +export interface GlobalTestOptions { + isTestRunner: boolean; + concurrency: number | boolean; + only: boolean; + timeout: number; + cwd: string; + testUpdateSnapshots: boolean; + /** Converted from run()'s testNamePatterns/testSkipPatterns (runner.ts), or null. A + * later CLI parser (--test-name-pattern/--test-skip-pattern) would feed these the + * same way: null here means "not filtering by name", read by test.ts's + * willBeFilteredByName(). Not fed by run()'s own option today — see runner.ts's + * header comment on the confirmed Node quirk this mirrors. */ + testNamePatterns: RegExp[] | null; + testSkipPatterns: RegExp[] | null; +} + +/** Node's internalBinding('util').getCallerLocation(): where the function that calls + * this one was itself called from. Parses the `.stack` string rather than using + * Error.prepareStackTrace/CallSite#getFileName() (src/runtime/util.ts's + * getCallSites() technique): CallSite#getFileName() comes back `undefined` for a + * frame inside the guest's indirect-eval'd CJS wrapper in this engine (confirmed by + * probing it directly), even though the wrapper's `//# sourceURL=` comment (see + * compileCjs in modules.ts) does make the guest's real path show up correctly in the + * plain `.stack` string's own frame lines, which is what's parsed here instead. + * Every caller invokes this directly from its own body — test()/describe()/before()/ + * ... in harness.ts, and TestContext#test/before/after/beforeEach/afterEach in + * test.ts — so after the message line and this function's own frame, the next frame + * (index 2, 0-based) is the caller's own wrapper body, and the one after that (index + * 3) is the user's actual call site. */ +export function getCallerLocation(): [number, number, string] | undefined { + const lines = (new Error().stack ?? "").split("\n"); + const line = lines[3]; + if (!line) return undefined; + const m = /\(([^()]+):(\d+):(\d+)\)\s*$/.exec(line) ?? /^\s*at\s+(\S+):(\d+):(\d+)\s*$/.exec(line); + if (!m) return undefined; + return [Number(m[2]), Number(m[3]), m[1]!]; +} + +/** CLI flags Node reads through getOptionValue (DESIGN: no CLI flag parser here, so + * every run behaves as `node file.test.js` with no --test* flags at all). A later + * flag parser feeds real values in by replacing this one function. */ +export function commandLineDefaults(cwd: string): GlobalTestOptions { + return { + isTestRunner: false, concurrency: 1, only: false, timeout: Infinity, cwd, testUpdateSnapshots: false, + testNamePatterns: null, testSkipPatterns: null, + }; +} + +/** Node's internal/test_runner/utils.js kPatterns/kFileExtensions/kDefaultPattern: the + * glob run() falls back to when `files` and `globPatterns` are both omitted. No + * --strip-types flag exists here (DESIGN: no CLI flag parser), so .ts/.mts/.cts never + * join the extension list the way Node's does under that flag. */ +export const kDefaultPattern = "**/{test,test/**/*,test-*,*[._-]test}.{js,mjs,cjs}"; + +/** Node's internal/test_runner/utils.js convertStringToRegExp: a bare `/pat/flags` + * string is read as a literal regex (its own flags, not case-insensitive by default); + * anything else is the pattern verbatim with no flags. */ +export function convertStringToRegExp(str: string, name: string): RegExp { + const m = /^\/(.*)\/([a-z]*)$/.exec(str); + const pattern = m?.[1] ?? str; + const flags = m?.[2] || ""; + try { + return new RegExp(pattern, flags); + } catch (err) { + const msg = err instanceof Error ? err.message : undefined; + throw invalidArgValue(name, str, `is an invalid regular expression.${msg ? ` ${msg}` : ""}`); + } +} diff --git a/packages/kernel/src/shared/builtin-ids.ts b/packages/kernel/src/shared/builtin-ids.ts index 30c4925..426d2fd 100644 --- a/packages/kernel/src/shared/builtin-ids.ts +++ b/packages/kernel/src/shared/builtin-ids.ts @@ -7,7 +7,7 @@ export const BUILTIN_IDS = [ "dns/promises", "dgram", "diagnostics_channel", "events", "fs", "fs/promises", "http", "http2", "inspector", "inspector/promises", "module", "net", "os", "path", "path/win32", "path/posix", "perf_hooks", "https", "process", "readline", "readline/promises", "querystring", "sea", "stream", "stream/consumers", "stream/promises", - "stream/web", "string_decoder", "timers", "timers/promises", "trace_events", "tls", "tty", "url", "v8", "vm", + "stream/web", "string_decoder", "test", "test/reporters", "timers", "timers/promises", "trace_events", "tls", "tty", "url", "v8", "vm", "worker_threads", "util", "util/types", "zlib", ] as const; @@ -21,4 +21,4 @@ export function isBuiltinId(id: string): id is BuiltinId { /** Ids Node resolves only as `node:`: the bare name is a regular (missing) * package, and `module.isBuiltin`/`builtinModules` leave them out. */ -export const SCHEME_ONLY_BUILTINS: ReadonlySet = new Set(["sea"]); +export const SCHEME_ONLY_BUILTINS: ReadonlySet = new Set(["sea", "test", "test/reporters"]); diff --git a/packages/kernel/src/shared/builtin-names.ts b/packages/kernel/src/shared/builtin-names.ts index 1de4cdc..ec1f43a 100644 --- a/packages/kernel/src/shared/builtin-names.ts +++ b/packages/kernel/src/shared/builtin-names.ts @@ -169,6 +169,8 @@ export const BUILTIN_EXPORT_NAMES = { "WritableStreamDefaultWriter", ], string_decoder: ["StringDecoder"], + test: ["test", "describe", "it", "suite", "before", "after", "beforeEach", "afterEach", "run", "mock", "snapshot", "assert"], + "test/reporters": ["dot", "junit", "spec", "tap", "lcov"], timers: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "setImmediate", "clearImmediate", "promises"], "timers/promises": ["setTimeout", "setImmediate", "setInterval", "scheduler"], trace_events: ["createTracing", "getEnabledCategories"], diff --git a/packages/kernel/tests/builtins.spec.ts b/packages/kernel/tests/builtins.spec.ts index 5c42481..66e1b12 100644 --- a/packages/kernel/tests/builtins.spec.ts +++ b/packages/kernel/tests/builtins.spec.ts @@ -502,3 +502,37 @@ test("events.getEventListeners refuses on a native EventTarget (no public listen }, "/app/main.cjs"); expect(lines).toEqual(["ERR_NIB_UNSUPPORTED true", "max 5"]); }); + +// node:test's run() with process isolation (the default), coverage or watch: none of +// these can be golden'd, since real Node actually performs them (spawns children, +// enables coverage, starts a watcher) instead of throwing — see runner.ts's header +// comment and compat/divergences.json's "test.run". Asserted directly here instead, +// per the brief. +test("node:test run() refuses process isolation (the default), coverage and watch, after validating every option", async ({ page }) => { + const lines = await guestLines(page, { + "/app/main.cjs": [ + 'const { run } = require("node:test");', + // Validation still wins over the refusal: a bad option throws its own + // ERR_INVALID_ARG_TYPE/ERR_INVALID_ARG_VALUE, never the refusal, even though + // every one of these calls would otherwise hit it. + 'try { run({ isolation: "process", files: "nope" }); } catch (e) { console.log("bad option first", e.code); }', + // isolation: 'process' is the default — omitting it refuses the same way. + 'try { run({}); } catch (e) { console.log("default isolation", e.code, e.message.includes("node:test"), e.message.includes("v8.Serializer")); }', + 'try { run({ isolation: "process" }); } catch (e) { console.log("explicit process", e.code); }', + 'try { run({ isolation: "none", coverage: true }); } catch (e) { console.log("coverage", e.code, e.message.includes("inspector")); }', + 'try { run({ isolation: "none", watch: true }); } catch (e) { console.log("watch", e.code, e.message.includes("FilesWatcher")); }', + // isolation: 'none' with nothing unsupported set does not refuse — it is a + // plain function call returning a TestsStream synchronously. + 'const s = run({ isolation: "none", files: [] });', + 'console.log("none ok", s.constructor.name, typeof s.on);', + ].join("\n"), + }, "/app/main.cjs"); + expect(lines).toEqual([ + "bad option first ERR_INVALID_ARG_TYPE", + "default isolation ERR_NIB_UNSUPPORTED true true", + "explicit process ERR_NIB_UNSUPPORTED", + "coverage ERR_NIB_UNSUPPORTED true", + "watch ERR_NIB_UNSUPPORTED true", + "none ok TestsStream function", + ]); +}); diff --git a/packages/kernel/tests/fixtures/guests/builtin-stream-compose-order.cjs b/packages/kernel/tests/fixtures/guests/builtin-stream-compose-order.cjs new file mode 100644 index 0000000..aefc550 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-stream-compose-order.cjs @@ -0,0 +1,30 @@ +// Ordering of piped and composed output against synchronous console.log. +const { Readable, Transform, compose, PassThrough } = require("node:stream"); + +const upper = () => new Transform({ transform(chunk, _enc, cb) { cb(null, String(chunk).toUpperCase()); } }); + +const piped = new Readable({ read() {} }); +piped.pipe(upper()).pipe(process.stdout); +piped.push("piped 1\n"); +console.log("sync after piped 1"); + +const src = new Readable({ read() {} }); +compose(src, upper()).pipe(process.stdout); +src.push("composed 1\n"); +console.log("sync after composed 1"); + +setTimeout(() => { + piped.push("piped 2\n"); + console.log("sync after piped 2"); + src.push("composed 2\n"); + console.log("sync after composed 2"); + const pt = new PassThrough(); + const c = compose(pt, upper()); + c.on("data", (d) => console.log("data", String(d).trim())); + c.on("end", () => console.log("end")); + c.on("finish", () => console.log("finish")); + pt.write("x"); + console.log("sync after write"); + pt.end("y"); + setTimeout(() => { piped.push(null); src.push(null); }, 20); +}, 20); diff --git a/packages/kernel/tests/fixtures/guests/builtin-stream-ticks.cjs b/packages/kernel/tests/fixtures/guests/builtin-stream-ticks.cjs new file mode 100644 index 0000000..4c9ae03 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-stream-ticks.cjs @@ -0,0 +1,137 @@ +// Tick-by-tick ordering of the stream primitives against sync code and each other: +// 'readable'/'data'/'end'/'finish'/'close' interleaved with console.log, nextTick and +// queueMicrotask markers. A deliberate companion to builtin-stream-compose-order.cjs — +// that one caught compose() taking an extra hop; this one pins down the individual +// pieces (emitReadable, flow, onwrite/afterWrite, Transform's held callback, +// endReadable, finish) so a future change that shifts any of them fails loudly here +// instead of showing up only as a reordered line in some unrelated guest's output. +const { Readable, Writable, Transform, PassThrough, compose } = require("node:stream"); + +function mark(label) { + console.log("sync", label); + process.nextTick(() => console.log("nextTick", label)); + queueMicrotask(() => console.log("microtask", label)); +} + +// --- Readable: push while paused (a 'readable' listener) vs flowing ('data'). --- +{ + const r = new Readable({ read() {} }); + r.on("readable", () => console.log("readable: readable, length", r.readableLength)); + r.on("end", () => console.log("readable: end")); + mark("readable-push1"); + r.push("a"); + mark("readable-push2"); + r.push("b"); + mark("readable-after-pushes"); + setTimeout(() => { + console.log("readable: read ->", String(r.read())); + r.push(null); + }, 5); +} + +// --- Readable: flowing mode ('data' listener attached first). --- +{ + const r = new Readable({ read() {} }); + r.on("data", (c) => console.log("flowing: data", String(c))); + r.on("end", () => console.log("flowing: end")); + mark("flowing-push1"); + r.push("x"); + mark("flowing-push2"); + r.push("y"); + setTimeout(() => r.push(null), 5); +} + +// --- Writable: sync vs async _write, and the drain/finish/close chain. --- +{ + const chunks = []; + const w = new Writable({ + write(chunk, _enc, cb) { + chunks.push(String(chunk)); + if (String(chunk) === "async") setTimeout(cb, 0); + else cb(); + }, + }); + w.on("drain", () => console.log("writable: drain")); + w.on("finish", () => console.log("writable: finish")); + w.on("close", () => console.log("writable: close")); + mark("writable-write-sync"); + console.log("writable: write sync ->", w.write("sync")); + mark("writable-write-async"); + console.log("writable: write async ->", w.write("async")); + mark("writable-after-writes"); + w.end(() => console.log("writable: end callback, chunks", chunks.join(","))); +} + +// --- Transform: a sync transform vs one whose callback resolves later, and the +// 'finish' vs 'end' race once ended. --- +{ + const events = []; + const t = new Transform({ + transform(chunk, _enc, cb) { + const s = String(chunk); + if (s === "slow") setTimeout(() => cb(null, s.toUpperCase()), 5); + else cb(null, s.toUpperCase()); + }, + }); + t.on("data", (c) => events.push(`data:${c}`)); + t.on("finish", () => events.push("finish")); + t.on("end", () => { events.push("end"); console.log("transform: order", events.join(",")); }); + mark("transform-write-fast"); + t.write("fast"); + mark("transform-write-slow"); + t.write("slow"); + t.end(); +} + +// --- PassThrough piped into a slow sink: backpressure pause/resume ordering. --- +{ + const pt = new PassThrough(); + const seen = []; + let allow = false; + const sink = new Writable({ + write(chunk, _enc, cb) { + seen.push(String(chunk)); + if (!allow) setTimeout(cb, 5); + else cb(); + }, + }); + pt.pipe(sink); + pt.on("pause", () => console.log("passthrough: paused, seen", seen.join(","))); + pt.on("resume", () => console.log("passthrough: resumed, seen", seen.join(","))); + mark("passthrough-write1"); + pt.write("one"); + mark("passthrough-write2"); + pt.write("two"); + setTimeout(() => { allow = true; pt.end("three"); }, 15); +} + +// --- pipe(): a 'data' listener's sync emission against a destination write's own +// sync callback, and the 'end'-to-'finish' handoff. --- +{ + const src = new Readable({ read() {} }); + const dest = new Writable({ write(_c, _e, cb) { cb(); } }); + src.pipe(dest); + dest.on("finish", () => console.log("pipe: dest finish")); + mark("pipe-push"); + src.push("p1"); + console.log("pipe: sync after push"); + src.push(null); +} + +// --- compose(): confirm an already-flowing composed Readable still delivers in the +// same tick shape as a plain pipe (the thing builtin-stream-compose-order.cjs covers +// for one specific interleaving; this is the minimal standalone case). --- +{ + const upper = () => new Transform({ transform(c, _e, cb) { cb(null, String(c).toUpperCase()); } }); + const src = new Readable({ read() {} }); + const composed = compose(src, upper()); + composed.on("data", (c) => console.log("compose: data", String(c))); + composed.on("end", () => console.log("compose: end")); + mark("compose-push1"); + src.push("c1"); + mark("compose-push2"); + src.push("c2"); + setTimeout(() => src.push(null), 5); +} + +setTimeout(() => console.log("done"), 30); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-async-describe.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-async-describe.cjs new file mode 100644 index 0000000..8360e80 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-async-describe.cjs @@ -0,0 +1,28 @@ +// Async describe() builders: every top-level test waits for all builds to settle. +// Registrations happen before each builder's await; the kernel has no async context +// to attribute a registration made after one (see currentBuildingParent in test.ts). +const { describe, it, test, before } = require("node:test"); +const tick = () => new Promise((r) => setImmediate(r)); +console.log("top"); +describe("slow build", async () => { + console.log("build slow start"); + it("slow 1", () => console.log("run slow 1")); + await tick(); await tick(); + console.log("build slow end"); +}); +describe("fast build", () => { + console.log("build fast"); + before(() => console.log("before fast")); + it("fast 1", () => console.log("run fast 1")); + describe("nested async", async () => { + it("nested 1", () => console.log("run nested 1")); + await tick(); + console.log("build nested"); + }); +}); +test("plain", () => console.log("run plain")); +describe("throws while building", async () => { + await tick(); + throw new Error("build failed"); +}); +console.log("bottom"); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-core.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-core.cjs new file mode 100644 index 0000000..9aa346f --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-core.cjs @@ -0,0 +1,79 @@ +// node:test's real default path: `node file.test.js`, default (spec) reporter, real +// exit code. Exercises test/describe/it, before/after/beforeEach/afterEach, only/skip/ +// todo, timeout, signal, plan, t.assert, t.diagnostic, t.waitFor, t.fullName, +// expectFailure, and the summary counts. Output has real durations and a real stack +// trace frame, so its golden carries "mask": "test-durations" (scripts/golden.mjs and +// tests/guest.ts apply the same masking to both sides before comparing — see +// scripts/golden-files.ts's maskTestDurations). +const { test, describe, before, after, beforeEach, afterEach } = require("node:test"); + +const order = []; + +before(() => order.push("before")); +after(() => order.push("after")); +beforeEach(() => order.push("beforeEach")); +afterEach(() => order.push("afterEach")); + +test("sync pass", (t) => { + t.assert.strictEqual(1, 1); + t.diagnostic("a diagnostic from a passing test"); +}); + +test("fullName at top level", (t) => { + t.assert.strictEqual(t.fullName, "fullName at top level"); +}); + +test("plan counts assertions", (t) => { + t.plan(2); + t.assert.ok(true); + t.assert.ok(true); +}); + +test("waitFor retries only while the condition throws", async (t) => { + // Surprising, confirmed against real Node 24.16.0: t.waitFor does NOT poll until its + // condition returns truthy. It resolves with whatever the condition returns the + // first time the condition does not throw — even a falsy value — and only retries + // (every `interval`ms) while the condition keeps throwing. + let attempts = 0; + const result = await t.waitFor(() => { + attempts++; + if (attempts < 3) throw new Error("not yet"); + return attempts; + }, { interval: 1, timeout: 1000 }); + t.assert.strictEqual(result, 3); +}); + +test("expected failure", { expectFailure: true }, () => { + throw new Error("this failure is expected"); +}); + +test("skipped via option", { skip: "not relevant here" }, () => { + throw new Error("must not run"); +}); + +test("todo via option", { todo: true }, () => { + throw new Error("todo failures do not fail the run"); +}); + +test("aborts on its own signal", { timeout: 50 }, async (t) => { + t.assert.strictEqual(t.signal.aborted, false); + await new Promise(() => {}); // never resolves: the timeout must cancel it. +}); + +describe("a suite with hooks", () => { + test("nested pass", () => {}); + test("nested fail", () => { + throw new Error("nested boom"); + }); +}); + +describe("hook order", () => { + test("first", () => {}); + test("second", () => {}); +}); + +test("hooks ran in Node's order", () => { + // before/after run once for the whole root; beforeEach/afterEach run around every + // top-level test. + console.log("hook order", order.slice(0, 3).join(",")); +}); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-esm.mjs b/packages/kernel/tests/fixtures/guests/builtin-test-esm.mjs new file mode 100644 index 0000000..28d25e6 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-esm.mjs @@ -0,0 +1,17 @@ +// node:test under ESM, with a top-level await ahead of the first test() call — the +// lazy bootstrap (harness.ts's lazyBootstrapRoot) must still work after real +// asynchronous work has already happened at module scope, not just synchronously at +// the top of the file. Durations vary, so this golden carries "mask": "test-durations". +import { test } from "node:test"; +import assert from "node:assert"; + +await new Promise((resolve) => setTimeout(resolve, 1)); + +test("esm sync pass", () => { + assert.strictEqual(1, 1); +}); + +test("esm async pass", async (t) => { + await Promise.resolve(); + t.assert.ok(true); +}); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-globals-intact.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-globals-intact.cjs new file mode 100644 index 0000000..df6496a --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-globals-intact.cjs @@ -0,0 +1,17 @@ +const util = require("node:util"); +const timers = require("node:timers"); +const before = { setTimeout: globalThis.setTimeout, setImmediate: globalThis.setImmediate }; +const { describe, it } = require("node:test"); +for (const name of ["setTimeout", "setImmediate"]) { + const fn = globalThis[name]; + console.log(name, "same as before", fn === before[name], "same as timers", fn === timers[name], + "name", fn.name, "promisify.custom", typeof fn[util.promisify.custom]); +} +describe("inside a suite build", async () => { + console.log("during build same", globalThis.setTimeout === before.setTimeout); + it("ran", () => {}); + await util.promisify(setTimeout)(1); +}); +const tp = require("node:timers/promises"); +console.log("promisify is timers/promises", util.promisify(setTimeout) === tp.setTimeout, util.promisify(setImmediate) === tp.setImmediate, + Object.getOwnPropertyDescriptor(setTimeout, util.promisify.custom).enumerable); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-mock.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-mock.cjs new file mode 100644 index 0000000..41d9dbe --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-mock.cjs @@ -0,0 +1,185 @@ +// node:internal/test_runner/mock/mock.js: MockTracker (fn/method/getter/setter/ +// property/reset/restoreAll) and MockFunctionContext (calls/callCount/ +// mockImplementation(Once)/resetCalls/restore), plus the per-test t.mock that restores +// itself after the test (node's postRun() calls `this.mock?.reset()`). `calls[i].stack` +// is a real Error whose frames can't match Node's own, so only `instanceof Error` is +// printed for it, never its text. +const { test, mock } = require("node:test"); +const assert = require("node:assert"); + +console.log("mock.module is absent by default", typeof mock.module); + +function printCall(label, call) { + console.log( + label, + "args=" + JSON.stringify(call.arguments), + "result=" + JSON.stringify(call.result), + "error=" + (call.error === undefined ? "undefined" : String(call.error)), + "this=" + (call.this === undefined ? "undefined" : JSON.stringify(call.this)), + "target=" + (call.target === undefined ? "undefined" : typeof call.target), + "stack-is-Error=" + (call.stack instanceof Error), + "keys=" + JSON.stringify(Object.keys(call).sort()), + ); +} + +test("mock.fn basic", () => { + const f = mock.fn((a, b) => a + b); + assert.strictEqual(f(1, 2), 3); + assert.strictEqual(f.mock.callCount(), 1); + printCall("basic call0", f.mock.calls[0]); + f.mock.restore(); + assert.strictEqual(typeof f, "function"); +}); + +test("mock.fn original-with-implementation", () => { + function original(a) { return `orig:${a}`; } + const f = mock.fn(original, (a) => `impl:${a}`); + console.log("fn result", f("x")); + console.log("fn callCount", f.mock.callCount()); + f.mock.restore(); + console.log("after restore", f("y")); +}); + +test("mock.fn options-only overload", () => { + const f = mock.fn({ times: 2 }); + console.log("options-only typeof", typeof f()); +}); + +test("mock.fn times switches back to original after N calls", () => { + // mock.fn has no object to restore onto, so the proxy keeps tracking past `times`; + // only the implementation switches back to `original`. + function original() { return "orig"; } + function impl() { return "impl"; } + const f = mock.fn(original, impl, { times: 2 }); + console.log("calls", f(), f(), f(), f()); + console.log("times callCount", f.mock.callCount()); +}); + +test("mock.method times restores the original onto the object after N calls", () => { + const obj = { f() { return "orig"; } }; + const m = mock.method(obj, "f", () => "impl", { times: 2 }); + console.log("method calls", obj.f(), obj.f(), obj.f(), obj.f()); + console.log("method callCount", m.mock.callCount(), "restored", obj.f === m ? "no" : "yes"); +}); + +test("mock.fn mockImplementation and mockImplementationOnce", () => { + const f = mock.fn(() => "default"); + f.mock.mockImplementationOnce(() => "once-0", 0); + console.log("call0", f()); + console.log("call1", f()); + f.mock.mockImplementation(() => "new-default"); + console.log("call2", f()); + f.mock.resetCalls(); + console.log("callCount after resetCalls", f.mock.callCount()); +}); + +test("mock.fn construct trap", () => { + class Base { + constructor(x) { this.x = x; } + } + const MockedBase = mock.fn(Base); + const inst = new MockedBase(5); + console.log("construct x", inst.x, "instanceof", inst instanceof Base); + console.log("construct call target typeof", typeof MockedBase.mock.calls[0].target); +}); + +test("mock.fn errors from the implementation are tracked and rethrown", () => { + const f = mock.fn(() => { throw new Error("boom"); }); + try { + f(); + } catch (err) { + console.log("caught", err.message); + } + console.log("tracked error is Error", f.mock.calls[0].error instanceof Error, f.mock.calls[0].error.message); +}); + +test("mock.method on an object", () => { + const obj = { greet(name) { return `hi ${name}`; } }; + const m = mock.method(obj, "greet", (name) => `mocked ${name}`); + console.log("method result", obj.greet("a")); + console.log("same ref", obj.greet === m); + m.mock.restore(); + console.log("restored result", obj.greet("b")); +}); + +test("mock.method with default implementation (passthrough)", () => { + const obj = { add(a, b) { return a + b; } }; + mock.method(obj, "add"); + console.log("passthrough", obj.add(2, 3), "callCount", obj.add.mock.callCount()); +}); + +test("mock.getter and mock.setter", () => { + const obj = { + _v: 1, + get v() { return this._v; }, + set v(x) { this._v = x; }, + }; + const getterMock = mock.getter(obj, "v", () => 99); + console.log("getter mocked", obj.v); + getterMock.mock.restore(); + console.log("getter restored", obj.v); + + const setterMock = mock.setter(obj, "v", (x) => { obj._v = x * 2; }); + obj.v = 10; + console.log("setter mocked, _v", obj._v); + setterMock.mock.restore(); + obj.v = 10; + console.log("setter restored, _v", obj._v); +}); + +test("mock.property", () => { + const obj = { y: 5 }; + const proxy = mock.property(obj, "y", 99); + console.log("property mocked", obj.y, proxy.y); + console.log("accessCount", proxy.mock.accessCount()); + proxy.mock.restore(); + console.log("property restored", obj.y); +}); + +test("mock.reset restores everything and clears timers", () => { + const obj = { z: 1 }; + const proxy = mock.property(obj, "z", 2); + const f = mock.fn(() => "x"); + f(); + mock.reset(); + console.log("after reset, property", obj.z, "callCount still", f.mock.callCount()); +}); + +test("t.mock auto-restores after the test", (t) => { + const obj = { w: 1 }; + t.mock.property(obj, "w", 2); + console.log("inside test, w", obj.w); + globalThis.__lastWObj = obj; // read after the test ends, see below +}); + +test("t.mock restored by the time the previous test's teardown ran", () => { + console.log("after previous test, w", globalThis.__lastWObj.w); + delete globalThis.__lastWObj; +}); + +test("mock.fn/method argument validation", () => { + const cases = [ + ["fn non-function original", () => mock.fn(1)], + ["fn non-function implementation", () => mock.fn(() => {}, 1)], + ["fn non-object options", () => mock.fn(() => {}, () => {}, 1)], + ["fn bad times", () => mock.fn(() => {}, () => {}, { times: 0 })], + ["fn non-integer times", () => mock.fn(() => {}, () => {}, { times: 1.5 })], + ["method bad methodName", () => mock.method({}, 123)], + ["method non-object target", () => mock.method(1, "x")], + ["method missing method", () => mock.method({}, "missing")], + ["method non-function implementation", () => mock.method({ a() {} }, "a", 1)], + ["method getter+setter both true", () => mock.method({ a() {} }, "a", () => {}, { getter: true, setter: true })], + ["getter option false", () => mock.getter({ get a() { return 1; } }, "a", () => {}, { getter: false })], + ["setter option false", () => mock.setter({ set a(v) {} }, "a", () => {}, { setter: false })], + ["property missing on object", () => mock.property({}, "missing")], + ["property non-string name", () => mock.property({ a: 1 }, 1)], + ]; + for (const [label, fn] of cases) { + try { + fn(); + console.log(label, "did not throw (unexpected)"); + } catch (err) { + console.log(label, err.code, err.name, err.message); + } + } +}); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-order.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-order.cjs new file mode 100644 index 0000000..06cfe16 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-order.cjs @@ -0,0 +1,16 @@ +const { test, describe, it } = require("node:test"); +const order = []; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +for (const c of [0, 1.5, -1, "x"]) { + try { test("bad", { concurrency: c }, () => {}); } catch (e) { console.log("concurrency", String(c), e.code, e.message); } +} +describe("par", { concurrency: true }, () => { + it("a", async () => { order.push("a1"); await sleep(30); order.push("a2"); }); + it("b", async () => { order.push("b1"); await sleep(5); order.push("b2"); }); +}); +test("log order", async (t) => { + console.log("inside log order"); + await sleep(10); + console.log("order", order.join(",")); +}); +test("later", () => { console.log("inside later"); }); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-reporters-dot.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-dot.cjs new file mode 100644 index 0000000..d1ef8ff --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-dot.cjs @@ -0,0 +1,20 @@ +// node:test/reporters' dot, fed a hand-built event stream. `process.stdout.columns` is +// undefined for a piped/non-TTY stream (both under real Node's spawnSync oracle and the +// kernel's guest stdout), so the dot grid always falls back to width 20 — deterministic +// either way. +const { dot } = require("node:test/reporters"); + +const err = new Error("three boom"); +err.stack = "Error: three boom\n at TestContext. (/app/three.cjs:1:1)"; + +const events = [ + { type: "test:pass", data: { name: "one", nesting: 0, testNumber: 1, testId: 1, details: { duration_ms: 1, type: "test" } } }, + { type: "test:pass", data: { name: "two", nesting: 0, testNumber: 2, testId: 2, details: { duration_ms: 1, type: "test" } } }, + { type: "test:fail", data: { name: "three", nesting: 0, testNumber: 3, testId: 3, details: { duration_ms: 1, type: "test", error: { code: "ERR_TEST_FAILURE", failureType: "testCodeFailure", cause: err } } } }, + { type: "test:pass", data: { name: "four", nesting: 0, testNumber: 4, testId: 4, details: { duration_ms: 1, type: "test" } } }, +]; + +async function main() { + for await (const chunk of dot(events)) process.stdout.write(chunk); +} +main(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-reporters-junit.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-junit.cjs new file mode 100644 index 0000000..f11fa3e --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-junit.cjs @@ -0,0 +1,33 @@ +// node:test/reporters' junit, fed a hand-built event stream. Kept flat (no nested +// describe/suite) so the output never needs a node: that tag carries +// `hostname=""`, and this kernel's os.hostname() is always "localhost" +// (no network identity in a browser — src/runtime/os.ts), while the real machine that +// records this golden reports its own hostname. Exercising only nodes keeps +// the golden deterministic across machines without needing a mask. +const { junit } = require("node:test/reporters"); + +// Real Node's `details.error` is an ERR_TEST_FAILURE instance (constructed by +// test.ts's testFailure()), which has its OWN `.message` (copied from `cause.message`) +// — junit.js reads `error.message` directly, unlike the other reporters (which only +// ever look at `.cause`), so a plain `{ code, failureType, cause }` object without its +// own `.message` throws here. `.stack` is pinned so the body (which inspects +// the whole error, stack included) doesn't vary machine to machine either. +const cause = new Error("beta boom"); +cause.stack = "Error: beta boom\n at TestContext. (/app/beta.cjs:4:9)"; +const betaFailsError = Object.assign(new Error(cause.message), { code: "ERR_TEST_FAILURE", failureType: "testCodeFailure", cause }); +betaFailsError.stack = "Error: beta boom\n at async Test.run (node:internal/test_runner/test:1300:1)"; + +const events = [ + { type: "test:start", data: { nesting: 0, name: "alpha passes", testId: 1 } }, + { type: "test:pass", data: { name: "alpha passes", nesting: 0, testNumber: 1, testId: 1, details: { duration_ms: 1.5, type: "test" }, file: "/app/beta.cjs" } }, + { type: "test:start", data: { nesting: 0, name: "beta fails", testId: 2 } }, + { type: "test:fail", data: { name: "beta fails", nesting: 0, testNumber: 2, testId: 2, details: { duration_ms: 0.7, type: "test", error: betaFailsError }, file: "/app/beta.cjs" } }, + { type: "test:start", data: { nesting: 0, name: "gamma skipped", testId: 3 } }, + { type: "test:pass", data: { name: "gamma skipped", nesting: 0, testNumber: 3, testId: 3, details: { duration_ms: 0.1, type: "test" }, file: "/app/beta.cjs", skip: "not relevant" } }, + { type: "test:diagnostic", data: { nesting: 0, message: "tests 3" } }, +]; + +async function main() { + for await (const chunk of junit(events)) process.stdout.write(chunk); +} +main(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-reporters-lcov.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-lcov.cjs new file mode 100644 index 0000000..2a46d21 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-lcov.cjs @@ -0,0 +1,29 @@ +// node:test/reporters' lcov, fed a hand-built `test:coverage` event. lcov is pure +// formatting over a coverage summary object; this runtime never produces that summary +// itself (coverage needs the inspector — refused, compat/divergences.json +// "test.coverage"), but the format doesn't care where the summary came from. +const { lcov } = require("node:test/reporters"); + +const summary = { + workingDirectory: "/app", + files: [ + { + path: "/app/beta.cjs", + functions: [{ name: "add", line: 1, count: 3 }, { name: "", line: 5, count: 0 }], + totalFunctionCount: 2, + coveredFunctionCount: 1, + branches: [{ line: 2, count: 3 }, { line: 2, count: 0 }], + totalBranchCount: 2, + coveredBranchCount: 1, + lines: [{ line: 3, count: 3 }, { line: 1, count: 3 }, { line: 5, count: 0 }], + coveredLineCount: 2, + totalLineCount: 3, + }, + ], +}; + +const reporter = lcov(); +reporter.on("data", (chunk) => process.stdout.write(chunk)); +reporter.write({ type: "test:coverage", data: { nesting: 0, summary } }); +reporter.write({ type: "test:pass", data: { name: "ignored", nesting: 0 } }); // lcov ignores non-coverage events +reporter.end(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-reporters-spec.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-spec.cjs new file mode 100644 index 0000000..81dcd06 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-spec.cjs @@ -0,0 +1,29 @@ +// node:test/reporters' spec (the default reporter), fed a hand-built event stream — +// same shapes as builtin-test-reporters-tap.cjs — piped through directly so the output +// is byte-for-byte deterministic. SpecReporter is a Transform, not a plain async +// generator, so it's driven by write()/end() like any other stream. +const { spec } = require("node:test/reporters"); + +const err = new Error("beta boom"); +err.stack = "Error: beta boom\n at TestContext. (/app/beta.cjs:4:9)\n at async Test.run (node:internal/test_runner/test:1300:1)"; + +const events = [ + { type: "test:start", data: { nesting: 0, name: "alpha passes", testId: 1, line: 2, column: 1, file: __filename } }, + { type: "test:pass", data: { name: "alpha passes", nesting: 0, testNumber: 1, testId: 1, details: { duration_ms: 1.5, type: "test" }, line: 2, column: 1, file: __filename } }, + { type: "test:diagnostic", data: { nesting: 0, message: "from alpha", level: "info", line: 2, column: 1, file: __filename } }, + { type: "test:start", data: { nesting: 0, name: "nested suite", testId: 2, line: 3, column: 1, file: __filename } }, + { type: "test:start", data: { nesting: 1, name: "beta fails", testId: 3, line: 4, column: 3, file: __filename } }, + { type: "test:fail", data: { name: "beta fails", nesting: 1, testNumber: 1, testId: 3, details: { duration_ms: 0.7, type: "test", error: { code: "ERR_TEST_FAILURE", failureType: "testCodeFailure", cause: err } }, line: 4, column: 3, file: __filename } }, + { type: "test:fail", data: { name: "nested suite", nesting: 0, testNumber: 2, testId: 2, details: { duration_ms: 2.1, type: "suite", error: { code: "ERR_TEST_FAILURE", failureType: "subtestsFailed", cause: "1 subtest failed" } }, line: 3, column: 1, file: __filename } }, + { type: "test:start", data: { nesting: 0, name: "gamma skipped", testId: 4, line: 6, column: 1, file: __filename } }, + { type: "test:pass", data: { name: "gamma skipped", nesting: 0, testNumber: 3, testId: 4, details: { duration_ms: 0.1, type: "test" }, line: 6, column: 1, file: __filename, skip: true } }, + { type: "test:diagnostic", data: { nesting: 0, message: "tests 3", level: "info" } }, + { type: "test:diagnostic", data: { nesting: 0, message: "pass 1", level: "info" } }, + { type: "test:diagnostic", data: { nesting: 0, message: "fail 1", level: "info" } }, + { type: "test:summary", data: { success: false, counts: { tests: 3, failed: 1, passed: 1, cancelled: 0, skipped: 1, todo: 0, topLevel: 3, suites: 1 }, duration_ms: 9.6 } }, +]; + +const reporter = spec(); +reporter.on("data", (chunk) => process.stdout.write(chunk)); +for (const ev of events) reporter.write(ev); +reporter.end(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-reporters-tap.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-tap.cjs new file mode 100644 index 0000000..f2a36ec --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-reporters-tap.cjs @@ -0,0 +1,30 @@ +// node:test/reporters' tap, fed a hand-built event stream so the output is byte-for-byte +// deterministic (no real test run, no real durations). Event shapes are the ones Node +// 24.16.0 actually emits (confirmed with a custom --test-reporter against a real test +// file, then hand-fixed to carry a real Error so the YAML block exercises a real stack). +const { tap } = require("node:test/reporters"); + +const err = new Error("beta boom"); +err.stack = "Error: beta boom\n at TestContext. (/app/beta.cjs:4:9)\n at async Test.run (node:internal/test_runner/test:1300:1)"; + +const events = [ + { type: "test:start", data: { nesting: 0, name: "alpha passes", testId: 1, line: 2, column: 1, file: "/app/beta.cjs" } }, + { type: "test:pass", data: { name: "alpha passes", nesting: 0, testNumber: 1, testId: 1, details: { duration_ms: 1.5, type: "test" }, line: 2, column: 1, file: "/app/beta.cjs" } }, + { type: "test:diagnostic", data: { nesting: 0, message: "from alpha", level: "info", line: 2, column: 1, file: "/app/beta.cjs" } }, + { type: "test:start", data: { nesting: 0, name: "nested suite", testId: 2, line: 3, column: 1, file: "/app/beta.cjs" } }, + { type: "test:start", data: { nesting: 1, name: "beta fails", testId: 3, line: 4, column: 3, file: "/app/beta.cjs" } }, + { type: "test:fail", data: { name: "beta fails", nesting: 1, testNumber: 1, testId: 3, details: { duration_ms: 0.7, type: "test", error: { code: "ERR_TEST_FAILURE", failureType: "testCodeFailure", cause: err } }, line: 4, column: 3, file: "/app/beta.cjs" } }, + { type: "test:plan", data: { nesting: 1, count: 1, line: 3, column: 1, file: "/app/beta.cjs" } }, + { type: "test:fail", data: { name: "nested suite", nesting: 0, testNumber: 2, testId: 2, details: { duration_ms: 2.1, type: "suite", error: { code: "ERR_TEST_FAILURE", failureType: "subtestsFailed", cause: "1 subtest failed" } }, line: 3, column: 1, file: "/app/beta.cjs" } }, + { type: "test:start", data: { nesting: 0, name: "gamma skipped", testId: 4, line: 6, column: 1, file: "/app/beta.cjs" } }, + { type: "test:pass", data: { name: "gamma skipped", nesting: 0, testNumber: 3, testId: 4, details: { duration_ms: 0.1, type: "test" }, line: 6, column: 1, file: "/app/beta.cjs", skip: true } }, + { type: "test:plan", data: { nesting: 0, count: 3 } }, + { type: "test:diagnostic", data: { nesting: 0, message: "tests 3", level: "info" } }, + { type: "test:diagnostic", data: { nesting: 0, message: "pass 1", level: "info" } }, + { type: "test:diagnostic", data: { nesting: 0, message: "fail 1", level: "info" } }, +]; + +async function main() { + for await (const chunk of tap(events)) process.stdout.write(chunk); +} +main(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-run-errors.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-run-errors.cjs new file mode 100644 index 0000000..85a9e69 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-run-errors.cjs @@ -0,0 +1,61 @@ +// node:test's run(): every validated option, in isolation. None of these cases ever +// reaches a real test run (each throws before isolation is even looked at), so there +// are no durations and nothing non-deterministic — no masking needed. +const { run } = require("node:test"); + +const cases = [ + ["options not an object", () => run(123)], + ["options null", () => run(null)], + ["files not an array", () => run({ files: "x" })], + ["watch not boolean", () => run({ watch: "x" })], + ["forceExit not boolean", () => run({ forceExit: "x" })], + ["forceExit with watch", () => run({ forceExit: true, watch: true })], + ["only not boolean", () => run({ only: "x" })], + ["globPatterns not an array", () => run({ globPatterns: "x" })], + ["randomize not boolean", () => run({ randomize: "x" })], + ["randomSeed not uint32", () => run({ randomSeed: -1 })], + ["randomSeed not integer", () => run({ randomSeed: 1.5 })], + ["randomSeed with watch", () => run({ randomSeed: 1, watch: true })], + ["randomize with watch", () => run({ randomize: true, watch: true })], + ["rerunFailuresFilePath not a string", () => run({ rerunFailuresFilePath: 5 })], + ["randomSeed with rerunFailuresFilePath", () => run({ rerunFailuresFilePath: "x", randomSeed: 1 })], + ["randomize with rerunFailuresFilePath", () => run({ rerunFailuresFilePath: "x", randomize: true })], + ["cwd not a string", () => run({ cwd: 5 })], + ["globPatterns with files", () => run({ globPatterns: ["a"], files: ["b"] })], + ["shard not an object", () => run({ shard: 5 })], + ["shard.total not an integer", () => run({ shard: { index: 1, total: "x" } })], + ["shard.total out of range", () => run({ shard: { index: 1, total: 0 } })], + ["shard.index out of range", () => run({ shard: { index: 3, total: 2 } })], + ["shard with watch", () => run({ shard: { index: 1, total: 2 }, watch: true })], + ["setup not a function", () => run({ setup: 123 })], + ["testNamePatterns[i] wrong type", () => run({ testNamePatterns: [123] })], + ["testSkipPatterns[i] wrong type", () => run({ testSkipPatterns: [123] })], + ["testNamePatterns[i] invalid regex", () => run({ testNamePatterns: ["/[/"] })], + ["isolation invalid", () => run({ isolation: "bogus" })], + ["coverage not boolean", () => run({ coverage: "yes" })], + ["coverageExcludeGlobs[i] wrong type", () => run({ coverageExcludeGlobs: [5] })], + ["coverageIncludeGlobs[i] wrong type", () => run({ coverageIncludeGlobs: [5] })], + ["lineCoverage out of range", () => run({ lineCoverage: 150 })], + ["branchCoverage out of range", () => run({ branchCoverage: -1 })], + ["functionCoverage not a number", () => run({ functionCoverage: "x" })], + ["argv[i] wrong type", () => run({ argv: [1] })], + ["execArgv[i] wrong type", () => run({ execArgv: [1] })], + ["globalSetupPath wrong type", () => run({ globalSetupPath: 5 })], + ["globalSetupPath null byte", () => run({ globalSetupPath: "x\u0000y" })], + ["env with isolation none", () => run({ isolation: "none", env: {} })], + // Deliberately NOT included here: run({}) (isolation: 'process', the default), + // run({isolation:'none', coverage:true}) and run({isolation:'none', watch:true}). + // Real Node does not throw for any of those three — it spawns children, enables + // coverage, or starts a watcher — so there is nothing for a golden recorded under + // real Node to agree on. Those are kernel-only refusals, asserted directly against + // the kernel in tests/builtins.spec.ts instead (see the brief). +]; + +for (const [label, fn] of cases) { + try { + fn(); + console.log(label, "NO THROW"); + } catch (e) { + console.log(label, "|", e.code, "|", e.message); + } +} diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-run-esm.mjs b/packages/kernel/tests/fixtures/guests/builtin-test-run-esm.mjs new file mode 100644 index 0000000..950708c --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-run-esm.mjs @@ -0,0 +1,30 @@ +// run() called from an ESM entry (top-level await), consumed through +// .on("test:pass"/"test:fail"/"test:summary") instead of for-await — the other style +// the brief calls out ("end" is a plain Readable event: it needs the stream actually +// consumed/flowing, which pure event listeners never do — confirmed against real Node +// 24.16.0, where the same script hangs on "end" and never settles — so this waits on +// "test:summary" instead, emitted directly regardless of whether anything reads the +// stream, the same event the direct-execution path's own exit-code listener uses). +// Exercises concurrency, timeout and signal: all three flow straight into the root +// Test's own constructor (rootTestOptions), which already validates them, so run() +// itself adds nothing beyond passing them through. +import { run } from "node:test"; + +const controller = new AbortController(); + +const stream = run({ + isolation: "none", + files: ["run-dep-b.mjs"], + concurrency: 2, + timeout: 5000, + signal: controller.signal, +}); + +const done = new Promise((resolve) => { + stream.on("test:pass", (d) => console.log("test:pass", JSON.stringify(d.name), d.nesting)); + stream.on("test:fail", (d) => console.log("test:fail", JSON.stringify(d.name), d.nesting)); + stream.on("test:summary", (d) => resolve(d.success)); +}); + +const success = await done; +console.log("summary success?", success, "signal aborted?", controller.signal.aborted); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-run-files.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-run-files.cjs new file mode 100644 index 0000000..8f3de71 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-run-files.cjs @@ -0,0 +1,51 @@ +// run({ isolation: "none", files: [...] }): loads four committed test files (CJS and +// ESM, one failing test, one that throws at load, one with no tests at all) in this +// process and consumes the returned TestsStream directly with `for await` — one of the +// ways the brief calls out (".compose(reporter)", ".pipe(stdout)", "for await", or +// ".on('test:pass')"). The reporter here is deliberately minimal (type, name, nesting, +// details minus duration_ms, and an error's code/name/message/failureType, never a +// stack) so the golden needs no duration/stack masking at all. +// +// only/testNamePatterns/testSkipPatterns are set below and should, by Node's own +// documented behavior, filter to just the "passes" tests — they do not. Confirmed +// against real Node 24.16.0: run()'s own testNamePatterns/testSkipPatterns/only never +// reach the harness.config a loaded file's test() calls are filtered against for +// isolation:'none' (runner.js's run() copies them only into getRunArgs(), used to +// build an isolation:'process' child's CLI argv). Every test below runs regardless — +// that is the Node-faithful result this golden records. +const { run } = require("node:test"); + +function detailsOf(d) { + if (!d) return undefined; + const out = { type: d.type }; + if (d.error) { + const err = d.error; + out.error = { code: err.code, name: err.name, message: err.message, failureType: err.failureType }; + } + return out; +} + +async function main() { + const stream = run({ + isolation: "none", + files: ["run-dep-a.cjs", "run-dep-b.mjs", "run-dep-throws.cjs", "run-dep-empty.cjs"], + only: true, + testNamePatterns: ["passes"], + testSkipPatterns: ["fails"], + setup: (reporter) => { + console.log("setup called", typeof reporter.on); + }, + }); + for await (const evt of stream) { + if (evt.type === "test:pass" || evt.type === "test:fail") { + console.log(evt.type, JSON.stringify(evt.data.name), evt.data.nesting, JSON.stringify(detailsOf(evt.data.details))); + } else if (evt.type === "test:diagnostic" && !evt.data.message.startsWith("duration_ms")) { + // The root summary's own duration_ms diagnostic is real elapsed time — the one + // non-deterministic thing this reporter would otherwise print — so it is the + // one line left out, rather than reaching for the duration-masking golden infra + // over a single line. + console.log("diag", evt.data.nesting, evt.data.message); + } + } +} +main(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-run-glob.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-run-glob.cjs new file mode 100644 index 0000000..c1f0b84 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-run-glob.cjs @@ -0,0 +1,18 @@ +// run({ isolation: "none" }) with neither `files` nor `globPatterns`: Node's own +// default glob (kDefaultPattern — "**/{test,test/**/*,test-*,*[._-]test}.{js,mjs,cjs}") +// finds every test file under cwd. Scoped to a dedicated subdirectory (cwd) holding +// just one matching file, rather than cwd-ing into the whole fixtures directory, so +// this golden can't start differing because an unrelated fixture happens to match the +// pattern later. +const path = require("node:path"); +const { run } = require("node:test"); + +async function main() { + const stream = run({ isolation: "none", cwd: path.join(__dirname, "run-glob") }); + for await (const evt of stream) { + if (evt.type === "test:pass" || evt.type === "test:fail") { + console.log(evt.type, JSON.stringify(evt.data.name), evt.data.nesting); + } + } +} +main(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-run-nomatch.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-run-nomatch.cjs new file mode 100644 index 0000000..d9f8823 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-run-nomatch.cjs @@ -0,0 +1,11 @@ +// run({ globPatterns: [...] }) with a literal (no glob metachar) pattern that matches +// nothing: Node's createTestFileList treats that as a misspelled filename, not an +// empty result set — it prints to stderr and exits the process with code 1, never +// emitting any test event (confirmed against real Node 24.16.0). Scoped to an empty +// subdirectory so it can't accidentally match something else in the shared fixtures +// tree. +const path = require("node:path"); +const { run } = require("node:test"); + +run({ isolation: "none", cwd: path.join(__dirname, "run-glob"), globPatterns: ["nope-literal.js"] }); +console.log("unreachable"); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-run-order.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-run-order.cjs new file mode 100644 index 0000000..e23c6a9 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-run-order.cjs @@ -0,0 +1,34 @@ +// run({ isolation: "none" }) over three dep files (run-order-dep-{a,b,c}): the first +// has several synchronous, trivial top-level tests, the second is ESM, the third is +// CJS. Each file logs once from its own top level and once from inside each test +// body, so this golden pins the load-versus-run interleaving exactly: which files' +// top-level logs land before any test runs, and in what order the queued top-level +// tests actually execute once harness.ts's bootstrapPromise gate (ported from Node's +// harness.js/runner.js) lets them start. See test.ts's finalize() and runner.ts's +// runIsolatedNone for what this is pinning. +const { run } = require("node:test"); + +function detailsOf(d) { + if (!d) return undefined; + const out = { type: d.type }; + if (d.error) { + const err = d.error; + out.error = { code: err.code, name: err.name, message: err.message }; + } + return out; +} + +async function main() { + const stream = run({ + isolation: "none", + files: ["run-order-dep-a.cjs", "run-order-dep-b.mjs", "run-order-dep-c.cjs"], + }); + for await (const evt of stream) { + if (evt.type === "test:pass" || evt.type === "test:fail") { + console.log(evt.type, JSON.stringify(evt.data.name), evt.data.nesting, JSON.stringify(detailsOf(evt.data.details))); + } else if (evt.type === "test:diagnostic" && !evt.data.message.startsWith("duration_ms")) { + console.log("diag", evt.data.nesting, evt.data.message); + } + } +} +main(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-run-shard.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-run-shard.cjs new file mode 100644 index 0000000..78cd87f --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-run-shard.cjs @@ -0,0 +1,24 @@ +// run({ shard }) filters the already-resolved file list by `index % total === index-1` +// before any file loads — confirmed against real Node 24.16.0 by running the same +// four-file `files` list under each shard of a 2-shard split and checking the union is +// exactly the four files with no overlap. Two run() calls, sequenced (the second +// starts only once the first's stream has fully ended), since createTestTree replaces +// this worker's one `globalRoot` — see harness.ts. +const { run } = require("node:test"); + +const files = ["run-dep-a.cjs", "run-dep-b.mjs", "run-dep-c.cjs", "run-dep-d.cjs"]; + +async function runShard(index, total) { + const stream = run({ isolation: "none", files, shard: { index, total } }); + for await (const evt of stream) { + if (evt.type === "test:pass" || evt.type === "test:fail") { + console.log(`shard ${index}/${total}`, evt.type, JSON.stringify(evt.data.name)); + } + } +} + +async function main() { + await runShard(1, 2); + await runShard(2, 2); +} +main(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-scheme-only.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-scheme-only.cjs new file mode 100644 index 0000000..de59e9d --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-scheme-only.cjs @@ -0,0 +1,12 @@ +// node:test is scheme-only: `require("node:test")` works, bare `require("test")` is an +// ordinary (missing) package, matching Node's SCHEME_ONLY_BUILTINS treatment (there is +// no npm package literally named "test" shadowing it here, so this always surfaces the +// loader's "module not found" behavior either way). +try { + require("test"); + console.log("require('test') unexpectedly succeeded"); +} catch (e) { + console.log("require('test') threw", e.code); +} +const nodeTest = require("node:test"); +console.log("require('node:test') works", typeof nodeTest, typeof nodeTest.describe); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-snapshot.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-snapshot.cjs new file mode 100644 index 0000000..1fa7744 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-snapshot.cjs @@ -0,0 +1,108 @@ +// node:internal/test_runner/snapshot.js: t.assert.snapshot, t.assert.fileSnapshot, +// snapshot.setDefaultSnapshotSerializers and snapshot.setResolveSnapshotPath. Without +// --test-update-snapshots (always off here: no CLI flag parser), a missing snapshot +// fails exactly as it does in Node. The snapshot files this guest needs are written by +// hand (duplicating the documented `exports[\`id\`] = \`value\`;` format and its +// backslash/backtick/`${`-escaping) *before* any assertion runs, so a match only +// happens if this runtime's own serialize/escape math agrees with that hand-written +// expectation — and both are cleaned up on exit (golden.mjs forbids a guest leaving +// files behind in this shared fixtures directory). +const { test, snapshot } = require("node:test"); +const fs = require("node:fs"); + +const customSnapshotPath = `${__filename}.custom.snapshot`; +const fileSnapshotPath = `${__filename}.manual.file`; + +// setResolveSnapshotPath/setDefaultSnapshotSerializers apply from here on, to every +// subsequent t.assert.snapshot call in this process (confirmed against real Node +// 24.16.0: both are plain module-level setters, not per-test). +snapshot.setResolveSnapshotPath((entryFile) => `${entryFile}.custom.snapshot`); +const customSerializer = (value) => `CUSTOM:${JSON.stringify(value)}`; +snapshot.setDefaultSnapshotSerializers([customSerializer]); + +function templateEscape(str) { + return String(str).replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${"); +} +function escapeSnapshotKey(str) { + return JSON.stringify(str).slice(1, -1).replaceAll("`", "\\`").replaceAll("${", "\\${"); +} +function serialize(value) { + return `\n${templateEscape(customSerializer(value))}\n`; +} + +const matchValue = { a: 1, list: [1, 2, 3] }; +const wrongValue = { a: "not-the-matching-value" }; + +fs.writeFileSync( + customSnapshotPath, + `exports[\`${escapeSnapshotKey("snapshot match 1")}\`] = \`${serialize(matchValue)}\`;\n\n` + + `exports[\`${escapeSnapshotKey("snapshot mismatch 1")}\`] = \`${serialize(wrongValue)}\`;\n`, +); +fs.writeFileSync(fileSnapshotPath, customSerializer(matchValue)); + +process.on("exit", () => { + for (const p of [customSnapshotPath, fileSnapshotPath]) { + try { + fs.unlinkSync(p); + } catch { + /* already gone */ + } + } +}); + +test("snapshot match", (t) => { + t.assert.snapshot(matchValue); + console.log("match: no throw"); +}); + +test("snapshot mismatch", (t) => { + try { + t.assert.snapshot(matchValue); // the file holds wrongValue for this id + console.log("mismatch: did not throw (unexpected)"); + } catch (err) { + console.log("mismatch", err.code, err.name, err.message); + } +}); + +test("snapshot missing", (t) => { + try { + t.assert.snapshot(matchValue); // no entry recorded for this id at all + console.log("missing: did not throw (unexpected)"); + } catch (err) { + console.log("missing", err.code, err.name, err.message, typeof err.snapshot, typeof err.filename); + } +}); + +test("fileSnapshot match", (t) => { + t.assert.fileSnapshot(matchValue, fileSnapshotPath); + console.log("fileSnapshot match: no throw"); +}); + +test("fileSnapshot missing file", (t) => { + try { + t.assert.fileSnapshot(matchValue, `${fileSnapshotPath}.nope`); + console.log("fileSnapshot missing: did not throw (unexpected)"); + } catch (err) { + console.log("fileSnapshot missing", err.code, err.name, (err.cause && err.cause.code) ?? "no-cause"); + } +}); + +test("snapshot argument validation", (t) => { + for (const [label, fn] of [ + ["snapshot bad options", () => t.assert.snapshot(1, null)], + ["snapshot bad serializers", () => t.assert.snapshot(1, { serializers: "nope" })], + ["snapshot bad serializer entry", () => t.assert.snapshot(1, { serializers: [1] })], + ["fileSnapshot bad path", () => t.assert.fileSnapshot(1, 123)], + ["fileSnapshot bad options", () => t.assert.fileSnapshot(1, "x", null)], + ["setResolveSnapshotPath bad fn", () => snapshot.setResolveSnapshotPath("nope")], + ["setDefaultSnapshotSerializers bad array", () => snapshot.setDefaultSnapshotSerializers("nope")], + ["setDefaultSnapshotSerializers bad entry", () => snapshot.setDefaultSnapshotSerializers([1])], + ]) { + try { + fn(); + console.log(label, "did not throw (unexpected)"); + } catch (err) { + console.log(label, err.code, err.name, err.message); + } + } +}); diff --git a/packages/kernel/tests/fixtures/guests/builtin-test-timers.cjs b/packages/kernel/tests/fixtures/guests/builtin-test-timers.cjs new file mode 100644 index 0000000..0470b61 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/builtin-test-timers.cjs @@ -0,0 +1,155 @@ +// node:internal/test_runner/mock/mock_timers.js: mock.timers.enable({apis, now}), the +// globalThis/node:timers/node:timers/promises forms of setTimeout/setInterval/ +// setImmediate, mocked Date, scheduler.wait, tick/runAll/setTime/reset and +// Symbol.dispose. Only the virtual clock ever advances here (mock.timers.tick/runAll), +// never real time, so the output is deterministic. +const { test, mock } = require("node:test"); +const assert = require("node:assert"); +const nodeTimers = require("node:timers"); +const nodeTimersPromises = require("node:timers/promises"); + +// Saved before any mock.timers.enable() call, and compared by identity (never by +// `.toString()`: Node's real timers are themselves plain JS functions with visible +// source text, not "[native code]", and this runtime's own real timers have different +// source text again — identity is the only check that is the same under both). +const realSetTimeout = globalThis.setTimeout; +const realSetInterval = globalThis.setInterval; + +test("setTimeout/setInterval/setImmediate via globalThis, node:timers and promises, tick-driven", async (t) => { + mock.timers.enable({ apis: ["setTimeout", "setInterval", "setImmediate"], now: 0 }); + const order = []; + setTimeout(() => order.push("global-timeout"), 100); + nodeTimers.setTimeout(() => order.push("timers-timeout"), 100); + const intervalId = setInterval(() => order.push("interval"), 50); + setImmediate(() => order.push("immediate")); + + mock.timers.tick(0); + console.log("after tick(0)", order.join(",")); + mock.timers.tick(50); + console.log("after tick(50)", order.join(",")); + mock.timers.tick(50); + console.log("after tick(100)", order.join(",")); + clearInterval(intervalId); + mock.timers.tick(1000); + console.log("after clearInterval + tick(1000)", order.join(",")); + + const p1 = nodeTimersPromises.setTimeout(10, "resolved-value"); + mock.timers.tick(10); + console.log("promisified setTimeout", await p1); + + const p2 = nodeTimersPromises.setImmediate("imm-value"); + mock.timers.tick(0); + console.log("promisified setImmediate", await p2); + + mock.timers.reset(); +}); + +test("mock.timers.runAll fires every pending timer up to the furthest one", () => { + mock.timers.enable({ apis: ["setTimeout"], now: 1000 }); + const order = []; + setTimeout(() => order.push("a"), 300); + setTimeout(() => order.push("b"), 100); + mock.timers.runAll(); + console.log("runAll order", order.join(",")); + mock.timers.reset(); +}); + +test("mock.timers.setTime moves the clock without running timers", () => { + mock.timers.enable({ apis: ["setTimeout"], now: 0 }); + let fired = false; + setTimeout(() => (fired = true), 100); + mock.timers.setTime(50); + console.log("fired before due", fired); + mock.timers.tick(50); + console.log("fired after due", fired); + mock.timers.reset(); +}); + +test("mock.timers Date", () => { + mock.timers.enable({ apis: ["Date"], now: 1000 }); + console.log("Date.now()", Date.now()); + console.log("new Date().getTime()", new Date().getTime()); + console.log("new Date(2000).getTime()", new Date(2000).getTime()); + mock.timers.setTime(5000); + console.log("after setTime", Date.now()); + console.log("Date.isMock", Date.isMock); + mock.timers.reset(); + console.log("Date restored, is function", typeof Date.now()); +}); + +test("mock.timers scheduler.wait", async () => { + mock.timers.enable({ apis: ["scheduler.wait"], now: 0 }); + const p = nodeTimersPromises.scheduler.wait(100).then(() => "waited"); + mock.timers.tick(100); + console.log(await p); + mock.timers.reset(); +}); + +test("mock.timers enabling only setTimeout leaves setInterval untouched", () => { + mock.timers.enable({ apis: ["setTimeout"], now: 0 }); + console.log("setInterval unchanged", globalThis.setInterval === realSetInterval); + console.log("setTimeout changed", globalThis.setTimeout !== realSetTimeout); + console.log("node:timers.setTimeout is the same fn as globalThis's", nodeTimers.setTimeout === globalThis.setTimeout); + mock.timers.reset(); + console.log("setTimeout restored", globalThis.setTimeout === realSetTimeout); +}); + +test("mock.timers reset() via t.mock.timers is scoped per test (t.mock auto-restores)", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"], now: 0 }); + let fired = false; + setTimeout(() => (fired = true), 10); + t.mock.timers.tick(10); + console.log("fired inside test", fired); +}); + +test("after the previous test ended, real setTimeout is back", () => { + console.log("setTimeout is the saved original", globalThis.setTimeout === realSetTimeout); +}); + +test("Symbol.dispose resets the timers", () => { + mock.timers.enable({ apis: ["setTimeout"], now: 0 }); + let fired = false; + setTimeout(() => (fired = true), 10); + mock.timers[Symbol.dispose](); + console.log("fired after dispose-before-tick", fired); + try { + mock.timers.tick(10); + console.log("tick after dispose did not throw (unexpected)"); + } catch (err) { + console.log("tick after dispose", err.code, err.name); + } +}); + +test("mock.timers argument validation", () => { + const cases = [ + ["enable twice", () => { mock.timers.enable(); mock.timers.enable(); }], + ["enable bad api", () => mock.timers.enable({ apis: ["bogus"] })], + ["enable non-array apis", () => mock.timers.enable({ apis: "setTimeout" })], + ["enable NaN now", () => mock.timers.enable({ now: NaN })], + ["enable negative now", () => mock.timers.enable({ now: -5 })], + ["enable non-number now", () => mock.timers.enable({ now: "x" })], + ["tick before enable", () => mock.timers.tick(10)], + ["setTime before enable", () => mock.timers.setTime(10)], + ["runAll before enable", () => mock.timers.runAll()], + ]; + for (const [label, fn] of cases) { + mock.timers.reset(); + try { + fn(); + console.log(label, "did not throw (unexpected)"); + } catch (err) { + console.log(label, err.code, err.name, err.message); + } + } + mock.timers.reset(); +}); + +test("mock.timers tick negative time", () => { + mock.timers.enable({ apis: ["setTimeout"], now: 0 }); + try { + mock.timers.tick(-1); + } catch (err) { + console.log("tick negative", err.code, err.name, err.message); + } + mock.timers.reset(); +}); diff --git a/packages/kernel/tests/fixtures/guests/run-dep-a.cjs b/packages/kernel/tests/fixtures/guests/run-dep-a.cjs new file mode 100644 index 0000000..6249ea6 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-dep-a.cjs @@ -0,0 +1,7 @@ +// A CJS test file run() loads: one passing and one failing top-level test. +const { test } = require("node:test"); + +test("a passes", () => {}); +test("a fails", () => { + throw new Error("a boom"); +}); diff --git a/packages/kernel/tests/fixtures/guests/run-dep-b.mjs b/packages/kernel/tests/fixtures/guests/run-dep-b.mjs new file mode 100644 index 0000000..68d7ad6 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-dep-b.mjs @@ -0,0 +1,5 @@ +// An ESM test file run() loads: top-level await, then one passing test. +import { test } from "node:test"; + +await Promise.resolve(); +test("b passes", () => {}); diff --git a/packages/kernel/tests/fixtures/guests/run-dep-c.cjs b/packages/kernel/tests/fixtures/guests/run-dep-c.cjs new file mode 100644 index 0000000..3bb2e4c --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-dep-c.cjs @@ -0,0 +1,2 @@ +const { test } = require("node:test"); +test("c passes", () => {}); diff --git a/packages/kernel/tests/fixtures/guests/run-dep-d.cjs b/packages/kernel/tests/fixtures/guests/run-dep-d.cjs new file mode 100644 index 0000000..db876d8 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-dep-d.cjs @@ -0,0 +1,2 @@ +const { test } = require("node:test"); +test("d passes", () => {}); diff --git a/packages/kernel/tests/fixtures/guests/run-dep-empty.cjs b/packages/kernel/tests/fixtures/guests/run-dep-empty.cjs new file mode 100644 index 0000000..a50bf55 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-dep-empty.cjs @@ -0,0 +1,10 @@ +// A test file with no tests at all, loaded cleanly. run() reports this as one passing +// placeholder test named after the file (confirmed against real Node 24.16.0). +// Deliberately no side effect here (no console.log): real Node's for-loop over the +// file list outraces a trivial test's own completion (several awaits deep) and this +// kernel's module loader does not (each file import crosses a worker/syscall +// boundary) — confirmed by reproducing the kernel-only reordering this caused when a +// console.log did live here. A plain top-level statement would print at a different +// point in the two reporters' output for a reason that has nothing to do with run() +// itself, so it is left out rather than golden'd into a race. +void 0; diff --git a/packages/kernel/tests/fixtures/guests/run-dep-throws.cjs b/packages/kernel/tests/fixtures/guests/run-dep-throws.cjs new file mode 100644 index 0000000..9959414 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-dep-throws.cjs @@ -0,0 +1,3 @@ +// A test file that throws while loading, registering zero top-level tests. run() +// reports this as one failing placeholder test named after the file. +throw new Error("boom at load"); diff --git a/packages/kernel/tests/fixtures/guests/run-glob/widget.test.cjs b/packages/kernel/tests/fixtures/guests/run-glob/widget.test.cjs new file mode 100644 index 0000000..50de25d --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-glob/widget.test.cjs @@ -0,0 +1,4 @@ +// Matched by Node's default test-file glob (**/{test,test/**/*,test-*,*[._-]test}.{js,mjs,cjs} +// — a "*[._-]test" basename, here "widget.test"). +const { test } = require("node:test"); +test("widget passes", () => {}); diff --git a/packages/kernel/tests/fixtures/guests/run-order-dep-a.cjs b/packages/kernel/tests/fixtures/guests/run-order-dep-a.cjs new file mode 100644 index 0000000..8e36eaa --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-order-dep-a.cjs @@ -0,0 +1,16 @@ +// First file run-order loads: several synchronous, trivial top-level tests, plus a +// top-level log, so the golden pins exactly when this file's own module-load log and +// its tests' run-time logs land relative to the other files' loads and the stream's +// own test:pass events. +console.log("load a"); +const { test } = require("node:test"); + +test("a1", () => { + console.log("run a1"); +}); +test("a2", () => { + console.log("run a2"); +}); +test("a3", () => { + console.log("run a3"); +}); diff --git a/packages/kernel/tests/fixtures/guests/run-order-dep-b.mjs b/packages/kernel/tests/fixtures/guests/run-order-dep-b.mjs new file mode 100644 index 0000000..8ba743b --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-order-dep-b.mjs @@ -0,0 +1,7 @@ +// Second file run-order loads: ESM, one top-level test. +import { test } from "node:test"; +console.log("load b"); + +test("b1", () => { + console.log("run b1"); +}); diff --git a/packages/kernel/tests/fixtures/guests/run-order-dep-c.cjs b/packages/kernel/tests/fixtures/guests/run-order-dep-c.cjs new file mode 100644 index 0000000..acdac10 --- /dev/null +++ b/packages/kernel/tests/fixtures/guests/run-order-dep-c.cjs @@ -0,0 +1,10 @@ +// Third file run-order loads: CJS, two top-level tests. +console.log("load c"); +const { test } = require("node:test"); + +test("c1", () => { + console.log("run c1"); +}); +test("c2", () => { + console.log("run c2"); +}); diff --git a/packages/kernel/tests/goldens/guests/builtin-stream-compose-order.cjs.json b/packages/kernel/tests/goldens/guests/builtin-stream-compose-order.cjs.json new file mode 100644 index 0000000..0a8eb4d --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-stream-compose-order.cjs.json @@ -0,0 +1,22 @@ +{ + "guest": "builtin-stream-compose-order.cjs", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "sync after piped 1", + "sync after composed 1", + "PIPED 1", + "COMPOSED 1", + "PIPED 2", + "sync after piped 2", + "sync after composed 2", + "sync after write", + "COMPOSED 2", + "data XY", + "end", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-stream-ticks.cjs.json b/packages/kernel/tests/goldens/guests/builtin-stream-ticks.cjs.json new file mode 100644 index 0000000..02f6bd6 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-stream-ticks.cjs.json @@ -0,0 +1,74 @@ +{ + "guest": "builtin-stream-ticks.cjs", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "sync readable-push1", + "sync readable-push2", + "sync readable-after-pushes", + "sync flowing-push1", + "sync flowing-push2", + "sync writable-write-sync", + "writable: write sync -> true", + "sync writable-write-async", + "writable: write async -> true", + "sync writable-after-writes", + "sync transform-write-fast", + "sync transform-write-slow", + "sync passthrough-write1", + "sync passthrough-write2", + "sync pipe-push", + "pipe: sync after push", + "sync compose-push1", + "sync compose-push2", + "nextTick readable-push1", + "readable: readable, length 2", + "nextTick readable-push2", + "nextTick readable-after-pushes", + "flowing: data x", + "flowing: data y", + "nextTick flowing-push1", + "nextTick flowing-push2", + "nextTick writable-write-sync", + "nextTick writable-write-async", + "nextTick writable-after-writes", + "nextTick transform-write-fast", + "nextTick transform-write-slow", + "passthrough: resumed, seen one", + "nextTick passthrough-write1", + "nextTick passthrough-write2", + "nextTick pipe-push", + "compose: data C1C2", + "nextTick compose-push1", + "nextTick compose-push2", + "pipe: dest finish", + "microtask readable-push1", + "microtask readable-push2", + "microtask readable-after-pushes", + "microtask flowing-push1", + "microtask flowing-push2", + "microtask writable-write-sync", + "microtask writable-write-async", + "microtask writable-after-writes", + "microtask transform-write-fast", + "microtask transform-write-slow", + "microtask passthrough-write1", + "microtask passthrough-write2", + "microtask pipe-push", + "microtask compose-push1", + "microtask compose-push2", + "writable: end callback, chunks sync,async", + "writable: finish", + "writable: close", + "readable: read -> ab", + "readable: readable, length 0", + "flowing: end", + "transform: order data:FAST,data:SLOW,end", + "compose: end", + "done", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-async-describe.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-async-describe.cjs.json new file mode 100644 index 0000000..f46bdcc --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-async-describe.cjs.json @@ -0,0 +1,49 @@ +{ + "guest": "builtin-test-async-describe.cjs", + "mask": "test-durations", + "node": "v24.16.0", + "code": 1, + "stdout": [ + "top", + "build slow start", + "build fast", + "bottom", + "build nested", + "build slow end", + "run slow 1", + "before fast", + "run fast 1", + "run nested 1", + "run plain", + "▶ slow build", + " ✔ slow 1 (Nms)", + "✔ slow build (Nms)", + "▶ fast build", + " ✔ fast 1 (Nms)", + " ▶ nested async", + " ✔ nested 1 (Nms)", + " ✔ nested async (Nms)", + "✔ fast build (Nms)", + "✔ plain (Nms)", + "✖ throws while building (Nms)", + "ℹ tests 4", + "ℹ suites 4", + "ℹ pass 4", + "ℹ fail 0", + "ℹ cancelled 0", + "ℹ skipped 0", + "ℹ todo 0", + "ℹ duration_ms N", + "", + "✖ failing tests:", + "", + "test at builtin-test-async-describe.cjs:24:1", + "✖ throws while building (Nms)", + " Error: build failed", + " at (/app/builtin-test-async-describe.cjs:26:9)", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-core.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-core.cjs.json new file mode 100644 index 0000000..f6307c4 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-core.cjs.json @@ -0,0 +1,55 @@ +{ + "guest": "builtin-test-core.cjs", + "mask": "test-durations", + "node": "v24.16.0", + "code": 1, + "stdout": [ + "✔ sync pass (Nms)", + "ℹ a diagnostic from a passing test", + "✔ fullName at top level (Nms)", + "✔ plan counts assertions (Nms)", + "✔ waitFor retries only while the condition throws (Nms)", + "✔ expected failure (Nms) # EXPECTED FAILURE", + "﹣ skipped via option (Nms) # not relevant here", + "⚠ todo via option (Nms) # TODO", + "hook order before,beforeEach,afterEach", + "✖ aborts on its own signal (Nms)", + "▶ a suite with hooks", + " ✔ nested pass (Nms)", + " ✖ nested fail (Nms)", + "✖ a suite with hooks (Nms)", + "▶ hook order", + " ✔ first (Nms)", + " ✔ second (Nms)", + "✔ hook order (Nms)", + "✔ hooks ran in Node's order (Nms)", + "ℹ tests 13", + "ℹ suites 2", + "ℹ pass 9", + "ℹ fail 1", + "ℹ cancelled 1", + "ℹ skipped 1", + "ℹ todo 1", + "ℹ duration_ms N", + "", + "✖ failing tests:", + "", + "test at builtin-test-core.cjs:54:1", + "⚠ todo via option (Nms) # TODO", + " Error: todo failures do not fail the run", + " at (/app/builtin-test-core.cjs:55:9)", + "", + "test at builtin-test-core.cjs:58:1", + "✖ aborts on its own signal (Nms)", + " 'test timed out after 50ms'", + "", + "test at builtin-test-core.cjs:65:3", + "✖ nested fail (Nms)", + " Error: nested boom", + " at (/app/builtin-test-core.cjs:66:11)", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-esm.mjs.json b/packages/kernel/tests/goldens/guests/builtin-test-esm.mjs.json new file mode 100644 index 0000000..830de3b --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-esm.mjs.json @@ -0,0 +1,22 @@ +{ + "guest": "builtin-test-esm.mjs", + "mask": "test-durations", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "✔ esm sync pass (Nms)", + "✔ esm async pass (Nms)", + "ℹ tests 2", + "ℹ suites 0", + "ℹ pass 2", + "ℹ fail 0", + "ℹ cancelled 0", + "ℹ skipped 0", + "ℹ todo 0", + "ℹ duration_ms N", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-globals-intact.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-globals-intact.cjs.json new file mode 100644 index 0000000..eb26657 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-globals-intact.cjs.json @@ -0,0 +1,27 @@ +{ + "guest": "builtin-test-globals-intact.cjs", + "mask": "test-durations", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "setTimeout same as before true same as timers true name setTimeout promisify.custom function", + "setImmediate same as before true same as timers true name setImmediate promisify.custom function", + "during build same true", + "promisify is timers/promises true true true", + "▶ inside a suite build", + " ✔ ran (Nms)", + "✔ inside a suite build (Nms)", + "ℹ tests 1", + "ℹ suites 1", + "ℹ pass 1", + "ℹ fail 0", + "ℹ cancelled 0", + "ℹ skipped 0", + "ℹ todo 0", + "ℹ duration_ms N", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-mock.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-mock.cjs.json new file mode 100644 index 0000000..3244ef2 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-mock.cjs.json @@ -0,0 +1,82 @@ +{ + "guest": "builtin-test-mock.cjs", + "mask": "test-durations", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "mock.module is absent by default undefined", + "basic call0 args=[1,2] result=3 error=undefined this=undefined target=undefined stack-is-Error=true keys=[\"arguments\",\"error\",\"result\",\"stack\",\"target\",\"this\"]", + "fn result impl:x", + "fn callCount 1", + "after restore orig:y", + "options-only typeof undefined", + "calls impl impl orig orig", + "times callCount 4", + "method calls impl impl orig orig", + "method callCount 2 restored yes", + "call0 once-0", + "call1 default", + "call2 new-default", + "callCount after resetCalls 0", + "construct x 5 instanceof true", + "construct call target typeof function", + "caught boom", + "tracked error is Error true boom", + "method result mocked a", + "same ref true", + "restored result hi b", + "passthrough 5 callCount 1", + "getter mocked 99", + "getter restored 1", + "setter mocked, _v 20", + "setter restored, _v 10", + "property mocked 99 99", + "accessCount 2", + "property restored 5", + "after reset, property 1 callCount still 1", + "inside test, w 2", + "after previous test, w 1", + "fn non-function original ERR_INVALID_ARG_TYPE TypeError The \"original\" argument must be of type function. Received type number (1)", + "fn non-function implementation ERR_INVALID_ARG_TYPE TypeError The \"implementation\" argument must be of type function. Received type number (1)", + "fn non-object options ERR_INVALID_ARG_TYPE TypeError The \"options\" argument must be of type object. Received type number (1)", + "fn bad times ERR_OUT_OF_RANGE RangeError The value of \"options.times\" is out of range. It must be >= 1 && <= 9007199254740991. Received 0", + "fn non-integer times ERR_OUT_OF_RANGE RangeError The value of \"options.times\" is out of range. It must be an integer. Received 1.5", + "method bad methodName ERR_INVALID_ARG_TYPE TypeError The \"methodName\" argument must be one of type string or symbol. Received type number (123)", + "method non-object target ERR_INVALID_ARG_TYPE TypeError The \"object\" argument must be of type object. Received type number (1)", + "method missing method ERR_INVALID_ARG_VALUE TypeError The argument 'methodName' must be a method. Received undefined", + "method non-function implementation ERR_INVALID_ARG_TYPE TypeError The \"implementation\" argument must be of type function. Received type number (1)", + "method getter+setter both true ERR_INVALID_ARG_VALUE TypeError The property 'options.setter' cannot be used with 'options.getter'. Received true", + "getter option false ERR_INVALID_ARG_VALUE TypeError The property 'options.getter' cannot be false. Received false", + "setter option false ERR_INVALID_ARG_VALUE TypeError The property 'options.setter' cannot be false. Received false", + "property missing on object ERR_INVALID_ARG_VALUE TypeError The argument 'propertyName' is not a property of the object. Received 'missing'", + "property non-string name ERR_INVALID_ARG_TYPE TypeError The \"propertyName\" argument must be one of type string or symbol. Received type number (1)", + "✔ mock.fn basic (Nms)", + "✔ mock.fn original-with-implementation (Nms)", + "✔ mock.fn options-only overload (Nms)", + "✔ mock.fn times switches back to original after N calls (Nms)", + "✔ mock.method times restores the original onto the object after N calls (Nms)", + "✔ mock.fn mockImplementation and mockImplementationOnce (Nms)", + "✔ mock.fn construct trap (Nms)", + "✔ mock.fn errors from the implementation are tracked and rethrown (Nms)", + "✔ mock.method on an object (Nms)", + "✔ mock.method with default implementation (passthrough) (Nms)", + "✔ mock.getter and mock.setter (Nms)", + "✔ mock.property (Nms)", + "✔ mock.reset restores everything and clears timers (Nms)", + "✔ t.mock auto-restores after the test (Nms)", + "✔ t.mock restored by the time the previous test's teardown ran (Nms)", + "✔ mock.fn/method argument validation (Nms)", + "ℹ tests 16", + "ℹ suites 0", + "ℹ pass 16", + "ℹ fail 0", + "ℹ cancelled 0", + "ℹ skipped 0", + "ℹ todo 0", + "ℹ duration_ms N", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-order.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-order.cjs.json new file mode 100644 index 0000000..2ca511d --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-order.cjs.json @@ -0,0 +1,33 @@ +{ + "guest": "builtin-test-order.cjs", + "mask": "test-durations", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "concurrency 0 ERR_OUT_OF_RANGE The value of \"options.concurrency\" is out of range. It must be >= 1 && <= 4294967295. Received 0", + "concurrency 1.5 ERR_OUT_OF_RANGE The value of \"options.concurrency\" is out of range. It must be an integer. Received 1.5", + "concurrency -1 ERR_OUT_OF_RANGE The value of \"options.concurrency\" is out of range. It must be >= 1 && <= 4294967295. Received -1", + "concurrency x ERR_INVALID_ARG_TYPE The \"options.concurrency\" property must be one of type boolean or number. Received type string ('x')", + "inside log order", + "▶ par", + " ✔ a (Nms)", + " ✔ b (Nms)", + "✔ par (Nms)", + "order a1,b1,b2,a2", + "inside later", + "✔ log order (Nms)", + "✔ later (Nms)", + "ℹ tests 4", + "ℹ suites 1", + "ℹ pass 4", + "ℹ fail 0", + "ℹ cancelled 0", + "ℹ skipped 0", + "ℹ todo 0", + "ℹ duration_ms N", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-reporters-dot.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-reporters-dot.cjs.json new file mode 100644 index 0000000..9ac6887 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-reporters-dot.cjs.json @@ -0,0 +1,18 @@ +{ + "guest": "builtin-test-reporters-dot.cjs", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "..X.", + "", + "Failed tests:", + "", + "✖ three (1ms)", + " Error: three boom", + " at TestContext. (/app/three.cjs:1:1)", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-reporters-junit.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-reporters-junit.cjs.json new file mode 100644 index 0000000..c5af7ec --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-reporters-junit.cjs.json @@ -0,0 +1,30 @@ +{ + "guest": "builtin-test-reporters-junit.cjs", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "", + "", + "\t", + "\t", + "\t\t", + "Error: beta boom", + " at async Test.run (node:internal/test_runner/test:1300:1) {", + " code: 'ERR_TEST_FAILURE',", + " failureType: 'testCodeFailure',", + " cause: Error: beta boom", + " at TestContext.<anonymous> (/app/beta.cjs:4:9)", + "}", + "\t\t", + "\t", + "\t", + "\t\t", + "\t", + "\t", + "", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-reporters-lcov.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-reporters-lcov.cjs.json new file mode 100644 index 0000000..be96cbb --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-reporters-lcov.cjs.json @@ -0,0 +1,29 @@ +{ + "guest": "builtin-test-reporters-lcov.cjs", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "TN:", + "SF:beta.cjs", + "FN:1,add", + "FN:5,anonymous_1", + "FNDA:3,add", + "FNDA:0,anonymous_1", + "FNF:2", + "FNH:1", + "BRDA:2,0,0,3", + "BRDA:2,1,0,0", + "BRF:2", + "BRH:1", + "DA:1,3", + "DA:3,3", + "DA:5,0", + "LH:2", + "LF:3", + "end_of_record", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-reporters-spec.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-reporters-spec.cjs.json new file mode 100644 index 0000000..4540054 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-reporters-spec.cjs.json @@ -0,0 +1,28 @@ +{ + "guest": "builtin-test-reporters-spec.cjs", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "✔ alpha passes (1.5ms)", + "ℹ from alpha", + "▶ nested suite", + " ✖ beta fails (0.7ms)", + "✖ nested suite (2.1ms)", + "﹣ gamma skipped (0.1ms) # SKIP", + "ℹ tests 3", + "ℹ pass 1", + "ℹ fail 1", + "", + "✖ failing tests:", + "", + "test at builtin-test-reporters-spec.cjs:4:3", + "✖ beta fails (0.7ms)", + " Error: beta boom", + " at TestContext. (/app/beta.cjs:4:9)", + " at async Test.run (node:internal/test_runner/test:1300:1)", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-reporters-tap.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-reporters-tap.cjs.json new file mode 100644 index 0000000..4c61071 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-reporters-tap.cjs.json @@ -0,0 +1,54 @@ +{ + "guest": "builtin-test-reporters-tap.cjs", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "TAP version 13", + "# Subtest: alpha passes", + "ok 1 - alpha passes", + " ---", + " duration_ms: 1.5", + " type: 'test'", + " ...", + "# from alpha", + "# Subtest: nested suite", + " # Subtest: beta fails", + " not ok 1 - beta fails", + " ---", + " duration_ms: 0.7", + " type: 'test'", + " location: '/app/beta.cjs:4:3'", + " code: 'ERR_TEST_FAILURE'", + " failureType: 'testCodeFailure'", + " cause:", + " error: 'beta boom'", + " stack: |-", + " TestContext. (/app/beta.cjs:4:9)", + " async Test.run (node:internal/test_runner/test:1300:1)", + " ...", + " 1..1", + "not ok 2 - nested suite", + " ---", + " duration_ms: 2.1", + " type: 'suite'", + " location: '/app/beta.cjs:3:1'", + " code: 'ERR_TEST_FAILURE'", + " failureType: 'subtestsFailed'", + " cause: '1 subtest failed'", + " ...", + "# Subtest: gamma skipped", + "ok 3 - gamma skipped # SKIP", + " ---", + " duration_ms: 0.1", + " type: 'test'", + " ...", + "1..3", + "# tests 3", + "# pass 1", + "# fail 1", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-run-errors.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-run-errors.cjs.json new file mode 100644 index 0000000..7ad5c49 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-run-errors.cjs.json @@ -0,0 +1,50 @@ +{ + "guest": "builtin-test-run-errors.cjs", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "options not an object | ERR_INVALID_ARG_TYPE | The \"options\" argument must be of type object. Received type number (123)", + "options null | ERR_INVALID_ARG_TYPE | The \"options\" argument must be of type object. Received null", + "files not an array | ERR_INVALID_ARG_TYPE | The \"options.files\" property must be an instance of Array. Received type string ('x')", + "watch not boolean | ERR_INVALID_ARG_TYPE | The \"options.watch\" property must be of type boolean. Received type string ('x')", + "forceExit not boolean | ERR_INVALID_ARG_TYPE | The \"options.forceExit\" property must be of type boolean. Received type string ('x')", + "forceExit with watch | ERR_INVALID_ARG_VALUE | The property 'options.forceExit' is not supported with watch mode. Received true", + "only not boolean | ERR_INVALID_ARG_TYPE | The \"options.only\" property must be of type boolean. Received type string ('x')", + "globPatterns not an array | ERR_INVALID_ARG_TYPE | The \"options.globPatterns\" property must be an instance of Array. Received type string ('x')", + "randomize not boolean | ERR_INVALID_ARG_TYPE | The \"options.randomize\" property must be of type boolean. Received type string ('x')", + "randomSeed not uint32 | ERR_OUT_OF_RANGE | The value of \"options.randomSeed\" is out of range. It must be >= 0 && <= 4294967295. Received -1", + "randomSeed not integer | ERR_OUT_OF_RANGE | The value of \"options.randomSeed\" is out of range. It must be an integer. Received 1.5", + "randomSeed with watch | ERR_INVALID_ARG_VALUE | The property 'options.randomSeed' is not supported with watch mode. Received 1", + "randomize with watch | ERR_INVALID_ARG_VALUE | The property 'options.randomize' is not supported with watch mode. Received true", + "rerunFailuresFilePath not a string | ERR_INVALID_ARG_TYPE | The \"options.rerunFailuresFilePath\" property must be of type string or an instance of Buffer or URL. Received type number (5)", + "randomSeed with rerunFailuresFilePath | ERR_INVALID_ARG_VALUE | The property 'options.randomSeed' is not supported with rerun failures mode. Received 1", + "randomize with rerunFailuresFilePath | ERR_INVALID_ARG_VALUE | The property 'options.randomize' is not supported with rerun failures mode. Received true", + "cwd not a string | ERR_INVALID_ARG_TYPE | The \"options.cwd\" property must be of type string. Received type number (5)", + "globPatterns with files | ERR_INVALID_ARG_VALUE | The property 'options.globPatterns' is not supported when specifying 'options.files'. Received [ 'a' ]", + "shard not an object | ERR_INVALID_ARG_TYPE | The \"options.shard\" property must be of type object. Received type number (5)", + "shard.total not an integer | ERR_INVALID_ARG_TYPE | The \"options.shard.total\" property must be of type number. Received type string ('x')", + "shard.total out of range | ERR_OUT_OF_RANGE | The value of \"options.shard.total\" is out of range. It must be >= 1 && <= 9007199254740991. Received 0", + "shard.index out of range | ERR_OUT_OF_RANGE | The value of \"options.shard.index\" is out of range. It must be >= 1 && <= 2. Received 3", + "shard with watch | ERR_INVALID_ARG_VALUE | The property 'options.shard' shards not supported with watch mode. Received true", + "setup not a function | ERR_INVALID_ARG_TYPE | The \"options.setup\" property must be of type function. Received type number (123)", + "testNamePatterns[i] wrong type | ERR_INVALID_ARG_TYPE | The \"options.testNamePatterns[0]\" property must be of type string or an instance of RegExp. Received type number (123)", + "testSkipPatterns[i] wrong type | ERR_INVALID_ARG_TYPE | The \"options.testSkipPatterns[0]\" property must be of type string or an instance of RegExp. Received type number (123)", + "testNamePatterns[i] invalid regex | ERR_INVALID_ARG_VALUE | The property 'options.testNamePatterns[0]' is an invalid regular expression. Invalid regular expression: /[/: Unterminated character class. Received '/[/'", + "isolation invalid | ERR_INVALID_ARG_VALUE | The property 'options.isolation' must be one of: 'process', 'none'. Received 'bogus'", + "coverage not boolean | ERR_INVALID_ARG_TYPE | The \"options.coverage\" property must be of type boolean. Received type string ('yes')", + "coverageExcludeGlobs[i] wrong type | ERR_INVALID_ARG_TYPE | The \"options.coverageExcludeGlobs[0]\" property must be of type string. Received type number (5)", + "coverageIncludeGlobs[i] wrong type | ERR_INVALID_ARG_TYPE | The \"options.coverageIncludeGlobs[0]\" property must be of type string. Received type number (5)", + "lineCoverage out of range | ERR_OUT_OF_RANGE | The value of \"options.lineCoverage\" is out of range. It must be >= 0 && <= 100. Received 150", + "branchCoverage out of range | ERR_OUT_OF_RANGE | The value of \"options.branchCoverage\" is out of range. It must be >= 0 && <= 100. Received -1", + "functionCoverage not a number | ERR_INVALID_ARG_TYPE | The \"options.functionCoverage\" property must be of type number. Received type string ('x')", + "argv[i] wrong type | ERR_INVALID_ARG_TYPE | The \"options.argv[0]\" property must be of type string. Received type number (1)", + "execArgv[i] wrong type | ERR_INVALID_ARG_TYPE | The \"options.execArgv[0]\" property must be of type string. Received type number (1)", + "globalSetupPath wrong type | ERR_INVALID_ARG_TYPE | The \"options.globalSetupPath\" property must be of type string or an instance of Buffer or URL. Received type number (5)", + "globalSetupPath null byte | ERR_INVALID_ARG_VALUE | The property 'options.globalSetupPath' must be a string, Uint8Array, or URL without null bytes. Received 'x\\x00y'", + "env with isolation none | ERR_INVALID_ARG_VALUE | The property 'options.env' is not supported with isolation='none'. Received {}", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-run-esm.mjs.json b/packages/kernel/tests/goldens/guests/builtin-test-run-esm.mjs.json new file mode 100644 index 0000000..ea67a9a --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-run-esm.mjs.json @@ -0,0 +1,16 @@ +{ + "guest": "builtin-test-run-esm.mjs", + "deps": [ + "run-dep-b.mjs" + ], + "node": "v24.16.0", + "code": 0, + "stdout": [ + "test:pass \"b passes\" 0", + "summary success? true signal aborted? false", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-run-files.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-run-files.cjs.json new file mode 100644 index 0000000..cdb9abb --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-run-files.cjs.json @@ -0,0 +1,30 @@ +{ + "guest": "builtin-test-run-files.cjs", + "deps": [ + "run-dep-a.cjs", + "run-dep-b.mjs", + "run-dep-throws.cjs", + "run-dep-empty.cjs" + ], + "node": "v24.16.0", + "code": 0, + "stdout": [ + "setup called function", + "test:pass \"a passes\" 0 {\"type\":\"test\"}", + "test:fail \"a fails\" 0 {\"type\":\"test\",\"error\":{\"code\":\"ERR_TEST_FAILURE\",\"name\":\"Error\",\"message\":\"a boom\",\"failureType\":\"testCodeFailure\"}}", + "test:pass \"b passes\" 0 {\"type\":\"test\"}", + "test:fail \"run-dep-throws.cjs\" 0 {\"type\":\"test\",\"error\":{\"name\":\"Error\",\"message\":\"boom at load\"}}", + "test:pass \"run-dep-empty.cjs\" 0 {\"type\":\"test\"}", + "diag 0 tests 5", + "diag 0 suites 0", + "diag 0 pass 3", + "diag 0 fail 2", + "diag 0 cancelled 0", + "diag 0 skipped 0", + "diag 0 todo 0", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-run-glob.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-run-glob.cjs.json new file mode 100644 index 0000000..2510f7b --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-run-glob.cjs.json @@ -0,0 +1,15 @@ +{ + "guest": "builtin-test-run-glob.cjs", + "deps": [ + "run-glob/widget.test.cjs" + ], + "node": "v24.16.0", + "code": 0, + "stdout": [ + "test:pass \"widget passes\" 0", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-run-nomatch.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-run-nomatch.cjs.json new file mode 100644 index 0000000..f2969f4 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-run-nomatch.cjs.json @@ -0,0 +1,15 @@ +{ + "guest": "builtin-test-run-nomatch.cjs", + "deps": [ + "run-glob/widget.test.cjs" + ], + "node": "v24.16.0", + "code": 1, + "stdout": [ + "" + ], + "stderr": [ + "Could not find 'nope-literal.js'", + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-run-order.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-run-order.cjs.json new file mode 100644 index 0000000..0994bae --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-run-order.cjs.json @@ -0,0 +1,38 @@ +{ + "guest": "builtin-test-run-order.cjs", + "deps": [ + "run-order-dep-a.cjs", + "run-order-dep-b.mjs", + "run-order-dep-c.cjs" + ], + "node": "v24.16.0", + "code": 0, + "stdout": [ + "load a", + "load b", + "load c", + "run a1", + "run a2", + "run a3", + "run b1", + "run c1", + "run c2", + "test:pass \"a1\" 0 {\"type\":\"test\"}", + "test:pass \"a2\" 0 {\"type\":\"test\"}", + "test:pass \"a3\" 0 {\"type\":\"test\"}", + "test:pass \"b1\" 0 {\"type\":\"test\"}", + "test:pass \"c1\" 0 {\"type\":\"test\"}", + "test:pass \"c2\" 0 {\"type\":\"test\"}", + "diag 0 tests 6", + "diag 0 suites 0", + "diag 0 pass 6", + "diag 0 fail 0", + "diag 0 cancelled 0", + "diag 0 skipped 0", + "diag 0 todo 0", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-run-shard.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-run-shard.cjs.json new file mode 100644 index 0000000..629e1be --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-run-shard.cjs.json @@ -0,0 +1,22 @@ +{ + "guest": "builtin-test-run-shard.cjs", + "deps": [ + "run-dep-a.cjs", + "run-dep-b.mjs", + "run-dep-c.cjs", + "run-dep-d.cjs" + ], + "node": "v24.16.0", + "code": 0, + "stdout": [ + "shard 1/2 test:pass \"a passes\"", + "shard 1/2 test:fail \"a fails\"", + "shard 1/2 test:pass \"c passes\"", + "shard 2/2 test:pass \"b passes\"", + "shard 2/2 test:pass \"d passes\"", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-scheme-only.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-scheme-only.cjs.json new file mode 100644 index 0000000..cf8e8b5 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-scheme-only.cjs.json @@ -0,0 +1,13 @@ +{ + "guest": "builtin-test-scheme-only.cjs", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "require('test') threw MODULE_NOT_FOUND", + "require('node:test') works function function", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-snapshot.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-snapshot.cjs.json new file mode 100644 index 0000000..237a3bb --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-snapshot.cjs.json @@ -0,0 +1,45 @@ +{ + "guest": "builtin-test-snapshot.cjs", + "mask": "test-durations", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "match: no throw", + "mismatch ERR_ASSERTION AssertionError Expected values to be strictly equal:", + "+ actual - expected", + "", + "+ '\\nCUSTOM:{\"a\":1,\"list\":[1,2,3]}\\n'", + "- '\\nCUSTOM:{\"a\":\"not-the-matching-value\"}\\n'", + " ^", + "", + "missing ERR_INVALID_STATE Error Invalid state: Snapshot 'snapshot missing 1' not found in '/app/builtin-test-snapshot.cjs.custom.snapshot.' Missing snapshots can be generated by rerunning the command with the --test-update-snapshots flag. string string", + "fileSnapshot match: no throw", + "fileSnapshot missing ERR_INVALID_STATE Error ENOENT", + "snapshot bad options ERR_INVALID_ARG_TYPE TypeError The \"options\" argument must be of type object. Received null", + "snapshot bad serializers ERR_INVALID_ARG_TYPE TypeError The \"options.serializers\" property must be an instance of Array. Received type string ('nope')", + "snapshot bad serializer entry ERR_INVALID_ARG_TYPE TypeError The \"options.serializers[0]\" property must be of type function. Received type number (1)", + "fileSnapshot bad path ERR_INVALID_ARG_TYPE TypeError The \"path\" argument must be of type string. Received type number (123)", + "fileSnapshot bad options ERR_INVALID_ARG_TYPE TypeError The \"options\" argument must be of type object. Received null", + "setResolveSnapshotPath bad fn ERR_INVALID_ARG_TYPE TypeError The \"fn\" argument must be of type function. Received type string ('nope')", + "setDefaultSnapshotSerializers bad array ERR_INVALID_ARG_TYPE TypeError The \"serializers\" argument must be an instance of Array. Received type string ('nope')", + "setDefaultSnapshotSerializers bad entry ERR_INVALID_ARG_TYPE TypeError The \"serializers[0]\" argument must be of type function. Received type number (1)", + "✔ snapshot match (Nms)", + "✔ snapshot mismatch (Nms)", + "✔ snapshot missing (Nms)", + "✔ fileSnapshot match (Nms)", + "✔ fileSnapshot missing file (Nms)", + "✔ snapshot argument validation (Nms)", + "ℹ tests 6", + "ℹ suites 0", + "ℹ pass 6", + "ℹ fail 0", + "ℹ cancelled 0", + "ℹ skipped 0", + "ℹ todo 0", + "ℹ duration_ms N", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/goldens/guests/builtin-test-timers.cjs.json b/packages/kernel/tests/goldens/guests/builtin-test-timers.cjs.json new file mode 100644 index 0000000..99e25d7 --- /dev/null +++ b/packages/kernel/tests/goldens/guests/builtin-test-timers.cjs.json @@ -0,0 +1,65 @@ +{ + "guest": "builtin-test-timers.cjs", + "mask": "test-durations", + "node": "v24.16.0", + "code": 0, + "stdout": [ + "after tick(0) immediate", + "after tick(50) immediate,interval", + "after tick(100) immediate,interval,global-timeout,timers-timeout,interval", + "after clearInterval + tick(1000) immediate,interval,global-timeout,timers-timeout,interval", + "promisified setTimeout resolved-value", + "promisified setImmediate imm-value", + "runAll order b,a", + "fired before due false", + "fired after due true", + "Date.now() 1000", + "new Date().getTime() 1000", + "new Date(2000).getTime() 2000", + "after setTime 5000", + "Date.isMock true", + "Date restored, is function number", + "waited", + "setInterval unchanged true", + "setTimeout changed true", + "node:timers.setTimeout is the same fn as globalThis's true", + "setTimeout restored true", + "fired inside test true", + "setTimeout is the saved original true", + "fired after dispose-before-tick false", + "tick after dispose ERR_INVALID_STATE Error", + "enable twice ERR_INVALID_STATE Error Invalid state: MockTimers is already enabled!", + "enable bad api ERR_INVALID_ARG_VALUE TypeError The property 'options.apis' option bogus is not supported. Received 'bogus'", + "enable non-array apis ERR_INVALID_ARG_TYPE TypeError The \"options.apis\" property must be an instance of Array. Received type string ('setTimeout')", + "enable NaN now ERR_INVALID_ARG_VALUE TypeError The argument 'now' epoch must be a positive integer received NaN. Received NaN", + "enable negative now ERR_INVALID_ARG_VALUE TypeError The argument 'time' -5. Received 'positive integer'", + "enable non-number now ERR_INVALID_ARG_TYPE TypeError The \"initialTime\" argument must be of type number. Received type string ('x')", + "tick before enable ERR_INVALID_STATE Error Invalid state: You should enable MockTimers first by calling the .enable function", + "setTime before enable ERR_INVALID_STATE Error Invalid state: You should enable MockTimers first by calling the .enable function", + "runAll before enable ERR_INVALID_STATE Error Invalid state: You should enable MockTimers first by calling the .enable function", + "tick negative ERR_INVALID_ARG_VALUE TypeError The argument 'time' -1. Received 'positive integer'", + "✔ setTimeout/setInterval/setImmediate via globalThis, node:timers and promises, tick-driven (Nms)", + "✔ mock.timers.runAll fires every pending timer up to the furthest one (Nms)", + "✔ mock.timers.setTime moves the clock without running timers (Nms)", + "✔ mock.timers Date (Nms)", + "✔ mock.timers scheduler.wait (Nms)", + "✔ mock.timers enabling only setTimeout leaves setInterval untouched (Nms)", + "✔ mock.timers reset() via t.mock.timers is scoped per test (t.mock auto-restores) (Nms)", + "✔ after the previous test ended, real setTimeout is back (Nms)", + "✔ Symbol.dispose resets the timers (Nms)", + "✔ mock.timers argument validation (Nms)", + "✔ mock.timers tick negative time (Nms)", + "ℹ tests 11", + "ℹ suites 0", + "ℹ pass 11", + "ℹ fail 0", + "ℹ cancelled 0", + "ℹ skipped 0", + "ℹ todo 0", + "ℹ duration_ms N", + "" + ], + "stderr": [ + "" + ] +} diff --git a/packages/kernel/tests/guest.ts b/packages/kernel/tests/guest.ts index ab56a63..d695022 100644 --- a/packages/kernel/tests/guest.ts +++ b/packages/kernel/tests/guest.ts @@ -1,7 +1,7 @@ // Run a guest program to exit on the fs fixture page and collect its stdio. import { test, expect, type Page } from "@playwright/test"; import type { ExecOptions, Kernel } from "../src/host/index"; -import { listGoldens, readGolden, type GuestGolden } from "../scripts/golden-files.ts"; +import { listGoldens, maskTestDurations, readGolden, type GuestGolden } from "../scripts/golden-files.ts"; declare global { interface Window { __runtime?: Kernel } @@ -75,12 +75,17 @@ function goldenTest(g: GuestGolden): void { files[`/app/${name}`] = await res.text(); } const out = await runGuest(page, files, `/app/${g.guest}`, g.stdin); + // Narrow, opt-in masking (see GuestGolden#mask): only guests that declare it get + // their durations/internal stack frames normalized, and both sides (the golden + // recorded under real Node and the kernel's live output) go through the exact + // same function so neither side is favored. + const mask = (text: string): string => (g.mask ? maskTestDurations(text) : text); expect({ code: out.signal === null ? out.code : null, signal: out.signal, - stdout: out.stdout, - stderr: out.stderr.replace(/\(node:\d+\)/g, "(node:PID)"), - }).toEqual({ code: g.code, signal: g.signal ?? null, stdout: g.stdout, stderr: g.stderr }); + stdout: mask(out.stdout), + stderr: mask(out.stderr.replace(/\(node:\d+\)/g, "(node:PID)")), + }).toEqual({ code: g.code, signal: g.signal ?? null, stdout: mask(g.stdout), stderr: mask(g.stderr) }); }); } -- knotmirror 0.0.1