diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 751bfc580..43d525869 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -47,6 +47,7 @@ - [`node:sqlite`](./interop/nodejs-builtins/supported-modules/sqlite.md) - [`node:stream`](./interop/nodejs-builtins/supported-modules/stream.md) - [`node:string_decoder`](./interop/nodejs-builtins/supported-modules/string-decoder.md) + - [`node:test`](./interop/nodejs-builtins/supported-modules/test.md) - [`node:timers`](./interop/nodejs-builtins/supported-modules/timers.md) - [`node:tty`](./interop/nodejs-builtins/supported-modules/tty.md) - [Troubleshooting]() diff --git a/docs/src/interop/jco-std.md b/docs/src/interop/jco-std.md index 2b6b853e5..cf3a991ca 100644 --- a/docs/src/interop/jco-std.md +++ b/docs/src/interop/jco-std.md @@ -161,6 +161,15 @@ example, the current Buffer and querystring cores come from `unenv` and are wrap by Jco during bundling -- this allows Jco to use mature upstream work and sprinkle in WASI support where necessary. +## Component tests + +The versioned `wasi/0.2.x/node/24.x.x/test` and `test/reporters` entry points reuse +the existing jco-std assertion, error, path and stream implementations. Prefer +ordinary `node:test` and `node:test/reporters` imports through `jco componentize`; +direct jco-std imports can coexist with those builtins in the same component. +See [test runner compatibility](nodejs-builtins/supported-modules/test.md) for serial +execution, engine requirements, output, and unsupported Node process facilities. + ## Hono and WASI HTTP The Hono adapter connects a normal [Hono][hono] application to a diff --git a/docs/src/interop/nodejs-builtins/supported-modules/index.md b/docs/src/interop/nodejs-builtins/supported-modules/index.md index 44219ff82..0d4c55673 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/index.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/index.md @@ -63,6 +63,7 @@ compatibility limits. Related submodules share their parent API page. See the | [`node:stream/consumers`](./stream.md) | Portable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability. | | [`node:stream/iter`](./stream.md) | Experimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported. | | [`node:string_decoder`](./string-decoder.md) | Guest-local streaming decoder for Node 24. Requires no WIT capability. | +| [`node:test`](./test.md), [`node:test/reporters`](./test.md) | Serial component tests, hooks, assertions, mocks, and reporters. No additional WIT imports. Runner requires engine `AbortController`; see the API page for engine limits. | | [`node:timers`](./timers.md), [`node:timers/promises`](./timers.md) | Node 24 timer handles and promise timers over engine task scheduling; see the API page for runtime limits. | | [`node:tty`](./tty.md) | Node 24.20 `isatty`, `ReadStream` and `WriteStream` over the host process's descriptors through an explicit host capability; denied by default. | diff --git a/docs/src/interop/nodejs-builtins/supported-modules/test.md b/docs/src/interop/nodejs-builtins/supported-modules/test.md new file mode 100644 index 000000000..292db3841 --- /dev/null +++ b/docs/src/interop/nodejs-builtins/supported-modules/test.md @@ -0,0 +1,88 @@ +# `node:test` + +| Imports | Implementation | +| --- | --- | +| `node:test`, `node:test/reporters` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/test` and `/test/reporters` | + +`node:test` and `node:test/reporters` target +[Node.js 24.20.0](https://nodejs.org/download/release/v24.20.0/docs/api/test.html). +Application code keeps its ordinary imports: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; + +await test("addition", async (t) => { + t.plan(2); + t.assert.strictEqual(2 + 3, 5); + await t.test("nested", () => { + assert.deepStrictEqual([1, 2], [1, 2]); + }); +}); + +export function run() { return "tests completed"; } +``` + +Bundle against a world exporting `run: func() -> string` with +`jco componentize app.js --bundle --wit wit -o app.wasm`. Top-level tests run when +the engine evaluates the module, which may happen during component initialization +at build time. Tests return promises that resolve even on failure, as in Node. +Failures appear in TAP output; `t.passed` and `t.error` are available in cleanup +hooks for applications that need to expose a result through WIT. The runner does +not set the host process's exit code. A returned "tests completed" string alone +is not evidence that assertions passed. + +Tests execute serially. Synchronous, promise and callback test bodies, nested +tests, synchronous suite declarations, `describe`/`it` aliases, hooks, +skip/TODO/expected-failure directives, assertion plans, tags, and `waitFor` are +supported. `only`/`runOnly` emit Node's diagnostic outside test-only mode; Jco has +no Node test-runner CLI mode. Global teardown runs when the registered queue drains, +so suites are the preferred scope for setup and teardown across related tests. + +## Reuse and engine requirements + +The test adapter reuses jco-std's assertion implementation, error codes, inspection, +path implementation, stream transforms, promise detection, Abort compatibility, +and signal validation. Assertion behavior and error identities therefore agree +with `node:assert` in the same bundle. Function, method, getter, setter and property +mocks retain the portable proxy and restoration algorithms from Node. There are +no new dependencies. +The port records provenance against Node commit +`71b8b174857e25106d39b61a9e6f30d927da8b01` and retains its MIT notice. + +The runner requires engine `AbortController`; StarlingMonkey provides it. +The pinned QuickJS backend does not, so starting a test or suite throws +`ERR_JCO_UNSUPPORTED_NODE_API`. Importing the module, standalone mocking and +reporting still work there. Timeouts, delayed plans and `waitFor` additionally use +the engine's timer functions. + +`getTestContext()` tracks synchronous callbacks. Component engines cannot propagate +implicit test context through `await`; use the explicit `t.test()` and `t` hook +methods after asynchronous boundaries. A global test/hook registration while an +async body is pending throws with that guidance. Async suite declarations and +`concurrency: true` or numbers greater than one are unsupported. File paths and +worker IDs are undefined; attempts are zero. The adapter does not intercept +unhandled rejections, uncaught exceptions, process signals, or test tracing events. + +## Mocks, snapshots and reporters + +Each test owns a mock tracker that resets after cleanup hooks. Standalone `mock` +has explicit `reset` and `restoreAll` methods. Mock calls retain arguments, +receivers, results, errors and constructor targets. Property mocks retain access +history and one-use replacements. Symbol methods restore correctly, fixing the +pinned upstream implementation's string-only restoration check. + +`run()` (file discovery, watch mode, isolation and coverage), module loader mocks, +native timer mocking, and snapshot APIs throw `ERR_JCO_UNSUPPORTED_NODE_API` +without invoking supplied callbacks or reading their options. The deprecated +array form of `mock.timers.enable()` throws +`ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. These APIs cannot be implemented by +passing guest closures through a host capability. + +`dot`, `tap` and `junit` consume event iterables. `spec` and `lcov` are callable +and constructible jco-std stream transforms. Reports use no ANSI colors or host +terminal discovery; dot wraps at 20 columns and JUnit leaves hostname empty. +TAP error details and human-readable coverage tables use portable formatting and +omit engine stack frames. LCOV can format supplied coverage events even though +the component runner cannot collect V8 coverage. Importing reporters requires no +filesystem or process provider. diff --git a/packages/jco-std/LICENSE b/packages/jco-std/LICENSE index 29e671cf5..cbeaf8bcb 100644 --- a/packages/jco-std/LICENSE +++ b/packages/jco-std/LICENSE @@ -218,11 +218,11 @@ prospectively choose to deem waived or otherwise exclude such Section(s) of the License, but only in their entirety and only with respect to the Combined Software. ---- Node.js stream adaptations (MIT License) --- +--- Node.js stream and test runner adaptations (MIT License) --- -The Node.js stream adaptations identified by upstream provenance comments -in src/wasi/0.2.x/node/24.x.x/stream/ and their compiled forms are covered -by the following notice: +The Node.js adaptations identified by upstream provenance comments in +src/wasi/0.2.x/node/24.x.x/stream/ and src/wasi/0.2.x/node/24.x.x/test/, +and their compiled forms, are covered by the following notice: Copyright Node.js contributors. All rights reserved. diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index cb6fd1290..df7c7a12a 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -468,6 +468,16 @@ "types": "./dist/wasi/0.2.x/node/24.x.x/timers-promises.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/timers-promises.js", "default": "./dist/wasi/0.2.x/node/24.x.x/timers-promises.js" + }, + "./wasi/0.2.x/node/24.x.x/test": { + "types": "./dist/wasi/0.2.x/node/24.x.x/test/index.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/test/index.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/test/index.js" + }, + "./wasi/0.2.x/node/24.x.x/test/reporters": { + "types": "./dist/wasi/0.2.x/node/24.x.x/test/reporters.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/test/reporters.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/test/reporters.js" } }, "scripts": { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/assert.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/assert.ts new file mode 100644 index 000000000..8eb21b961 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/assert.ts @@ -0,0 +1,102 @@ +/** Adapted from nodejs/node lib/internal/test_runner/{assert,snapshot,test}.js, + * v24.20.0, 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT (see LICENSE). + * Uses jco-std assertions; snapshot files/VM execution are explicitly unsupported. */ +import nodeAssert, { type Assert } from "../assert/index.js"; +import type { TestContext } from "./context.js"; +import { invalidArgType, unsupported, validateFunction } from "./errors.js"; + +const methods = [ + "deepEqual", + "deepStrictEqual", + "doesNotMatch", + "doesNotReject", + "doesNotThrow", + "equal", + "fail", + "ifError", + "match", + "notDeepEqual", + "notDeepStrictEqual", + "notEqual", + "notStrictEqual", + "partialDeepStrictEqual", + "rejects", + "strictEqual", + "throws", + "ok", +] as const; +export interface TestAssertions extends Pick { + // Explicit properties retain assertion narrowing through a TestContext getter. + ok: Assert["ok"]; + strictEqual: Assert["strictEqual"]; + deepStrictEqual: Assert["deepStrictEqual"]; + snapshot(value: unknown, options?: { serializers?: readonly SnapshotSerializer[] }): void; + fileSnapshot( + value: unknown, + path: string, + options?: { serializers?: readonly SnapshotSerializer[] }, + ): void; +} +export type AssertionFunction = (this: TestContext, ...args: never[]) => unknown; +export interface AssertionRegistry { + register(name: string, fn: AssertionFunction): void; +} +export type SnapshotSerializer = (value: unknown) => string; +export interface SnapshotConfiguration { + setDefaultSnapshotSerializers(serializers: readonly SnapshotSerializer[]): void; + setResolveSnapshotPath(fn: (path: string | undefined) => string): void; +} +export const assertionMap: Map = new Map( + methods.map((name) => [name, nodeAssert[name]]), +); +export const assert: AssertionRegistry = Object.assign(Object.create(null), { + register(name: string, fn: AssertionFunction): void { + if (typeof name !== "string") { + throw invalidArgType("name", "string", name); + } + validateFunction(fn, "fn"); + assertionMap.set(name, fn); + }, +}); +export const snapshot: SnapshotConfiguration = Object.assign(Object.create(null), { + setDefaultSnapshotSerializers(_serializers: readonly SnapshotSerializer[]): void { + unsupported( + "snapshot.setDefaultSnapshotSerializers()", + "snapshot files and VM loading are unavailable", + ); + }, + setResolveSnapshotPath(_fn: (path: string | undefined) => string): void { + unsupported( + "snapshot.setResolveSnapshotPath()", + "snapshot files and VM loading are unavailable", + ); + }, +}); +export function createAssertions(context: TestContext, count: () => void): TestAssertions { + // Every public method is installed below before this object escapes. + const assertions: TestAssertions = Object.create(null); + const map = new Map(assertionMap); + if (!map.has("snapshot")) { + map.set("snapshot", (): never => + unsupported("context.assert.snapshot()", "snapshot files and VM loading are unavailable"), + ); + } + if (!map.has("fileSnapshot")) { + map.set("fileSnapshot", (): never => + unsupported("context.assert.fileSnapshot()", "snapshot files are unavailable"), + ); + } + for (const [name, method] of map) { + Object.defineProperty(assertions, name, { + configurable: true, + enumerable: true, + writable: true, + value: (...args: unknown[]): unknown => { + count(); + return Reflect.apply(method, context, args); + }, + }); + } + // Every member of TestAssertions is installed by the map above, retaining its call contract. + return assertions; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/context.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/context.ts new file mode 100644 index 000000000..ba4cca904 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/context.ts @@ -0,0 +1,283 @@ +/** Adapted from nodejs/node lib/internal/test_runner/test.js (TestContext, + * SuiteContext, TestPlan, waitFor), v24.20.0, + * 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT (see LICENSE). + * Local changes: typed component state, Web timers, no host file/worker lookup. */ +import { createAssertions, type TestAssertions } from "./assert.js"; +import { MockTracker } from "./mock.js"; +import { + failure, + invalidArgType, + milliseconds, + validateFunction, + validateObject, + validateUint32, + type TestFailure, +} from "./errors.js"; +import type { + HookFn, + HookOptions, + PlanOptions, + TestFn, + TestOptions, + WaitForOptions, +} from "./types.js"; + +export type HookName = "before" | "after" | "beforeEach" | "afterEach"; +export interface ContextState { + name: string; + fullName: string; + signal: AbortSignal; + passed: boolean; + error: TestFailure | null; + tags: readonly string[]; + plan: TestPlan | null; + mock: MockTracker | null; + runOnly: boolean; + skipped?: boolean | string; + todo?: boolean | string; + add( + name?: string | TestFn | TestOptions, + options?: TestOptions | TestFn, + fn?: TestFn, + ): Promise; + hook(name: HookName, fn?: HookFn, options?: HookOptions): void; + diagnostic(message: unknown): void; + log(message: string, data?: unknown): void; +} +export class TestPlan { + expected: number; + actual = 0; + readonly wait: boolean | number | undefined; + #resolve?: () => void; + #reject?: (error: unknown) => void; + #timer?: ReturnType; + constructor(count: number, options: PlanOptions = {}) { + validateUint32(count, "count"); + validateObject(options, "options"); + const { wait } = options; + if (typeof wait === "number") { + milliseconds(wait, "options.wait"); + } else if (wait !== undefined && typeof wait !== "boolean") { + throw invalidArgType("options.wait", ["boolean", "number"], wait); + } + this.expected = count; + this.wait = wait; + } + count(): void { + this.actual++; + if (this.#resolve && this.actual >= this.expected) { + if (this.actual === this.expected) { + this.#resolve(); + } else { + this.#reject?.(this.#error()); + } + this.dispose(); + } + } + #error(): TestFailure { + return failure(`plan expected ${this.expected} assertions but received ${this.actual}`); + } + check(): void | Promise { + if (this.actual === this.expected) { + return; + } + if (this.actual > this.expected || this.wait === undefined || this.wait === false) { + throw this.#error(); + } + return new Promise((resolve, reject): void => { + this.#resolve = resolve; + this.#reject = reject; + if (typeof this.wait === "number") { + this.#timer = setTimeout((): void => { + reject( + failure( + `plan timed out after ${this.wait}ms with ${this.actual} assertions when expecting ${this.expected}`, + "testTimeoutFailure", + ), + ); + }, this.wait); + } + }); + } + dispose(): void { + clearTimeout(this.#timer); + this.#resolve = undefined; + this.#reject = undefined; + } +} + +class TestContextImplementation { + readonly #test: ContextState; + #assert?: TestAssertions; + constructor(test: ContextState) { + this.#test = test; + } + get signal(): AbortSignal { + return this.#test.signal; + } + get name(): string { + return this.#test.name; + } + get filePath(): string | undefined { + return undefined; + } + get fullName(): string { + return this.#test.fullName; + } + get error(): TestFailure | null { + return this.#test.error; + } + get passed(): boolean { + return this.#test.passed; + } + get attempt(): number { + return 0; + } + get tags(): readonly string[] { + return this.#test.tags; + } + get workerId(): number | undefined { + return undefined; + } + diagnostic(message: string): void { + this.#test.diagnostic(message); + } + log(message: string, data?: unknown): void { + this.#test.log(message, data); + } + plan(count: number, options?: PlanOptions): void { + if (this.#test.plan !== null) { + throw failure("cannot set plan more than once"); + } + this.#test.plan = new TestPlan(count, options); + } + get assert(): TestAssertions { + // Node captures the plan when the assertion object is first accessed. + const plan = this.#test.plan; + return (this.#assert ??= createAssertions(this, (): void => { + plan?.count(); + })); + } + get mock(): MockTracker { + return (this.#test.mock ??= new MockTracker()); + } + runOnly(value: boolean): void { + this.#test.runOnly = !!value; + } + skip(message?: string): void { + this.#test.skipped = message ?? true; + } + todo(message?: string): void { + this.#test.todo = message ?? true; + } + test(name?: string, fn?: TestFn): Promise; + test(name?: string, options?: TestOptions, fn?: TestFn): Promise; + test(options?: TestOptions, fn?: TestFn): Promise; + test(fn?: TestFn): Promise; + test( + name?: string | TestOptions | TestFn, + options?: TestOptions | TestFn, + fn?: TestFn, + ): Promise { + this.#test.plan?.count(); + return this.#test.add(name, options, fn); + } + before(fn?: HookFn, options?: HookOptions): void { + this.#test.hook("before", fn, options); + } + after(fn?: HookFn, options?: HookOptions): void { + this.#test.hook("after", fn, options); + } + beforeEach(fn?: HookFn, options?: HookOptions): void { + this.#test.hook("beforeEach", fn, options); + } + afterEach(fn?: HookFn, options?: HookOptions): void { + this.#test.hook("afterEach", fn, options); + } + waitFor(condition: () => T | PromiseLike, options: WaitForOptions = {}): Promise { + validateFunction(condition, "condition"); + validateObject(options, "options"); + const { interval = 50, timeout = 1000 } = options; + milliseconds(interval, "options.interval"); + milliseconds(timeout, "options.timeout"); + return new Promise((resolve, reject): void => { + const noError = Symbol(); + let cause: unknown = noError; + let poller: ReturnType | undefined; + let ended = false; + const timer = setTimeout((): void => { + ended = true; + clearTimeout(poller); + const error = new Error("waitFor() timed out"); + if (cause !== noError) { + error.cause = cause; + } + reject(error); + }, timeout); + const poll = async (): Promise => { + try { + const result = await condition(); + if (ended) { + return; + } + ended = true; + clearTimeout(timer); + resolve(result); + } catch (error) { + if (ended) { + return; + } + cause = error; + poller = setTimeout(poll, interval); + } + }; + void poll(); + }); + } +} +// TypeScript does not narrow assertion calls through class getters (TS2775). +// The public interface declares assert as a readonly property, as @types/node +// does, while the implementation retains Node's lazy prototype getter. +export interface TestContext extends Omit { + readonly assert: TestAssertions; +} +Object.defineProperty(TestContextImplementation, "name", { + value: "TestContext", + configurable: true, +}); +export const TestContext: { + new (state: ContextState): TestContext; + readonly prototype: TestContext; +} = TestContextImplementation; + +// Keep the public suite context prototype separate from the test context. +export class SuiteContext { + readonly #suite: ContextState; + constructor(suite: ContextState) { + this.#suite = suite; + } + get signal(): AbortSignal { + return this.#suite.signal; + } + get name(): string { + return this.#suite.name; + } + get filePath(): string | undefined { + return undefined; + } + get fullName(): string { + return this.#suite.fullName; + } + get passed(): boolean { + return this.#suite.passed; + } + get attempt(): number { + return 0; + } + diagnostic(message: string): void { + this.#suite.diagnostic(message); + } + log(message: string, data?: unknown): void { + this.#suite.log(message, data); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/errors.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/errors.ts new file mode 100644 index 000000000..838b74044 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/errors.ts @@ -0,0 +1,53 @@ +/** ERR_TEST_FAILURE adapted from nodejs/node lib/internal/errors.js, + * v24.20.0, 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT (see LICENSE). + * Uses jco-std error codes and inspection instead of Node internal bindings. */ +import { inspect } from "../assert/inspect.js"; +import { isObject } from "../stream/shared.js"; +import { codedError, invalidArgType, outOfRange, unsupportedNodeApi } from "../errors/core.js"; +export { + invalidArgType, + invalidArgValue, + validateFunction, + validateObject, + validateUint32, + deprecatedNodeApi, +} from "../errors/core.js"; + +export type TestFailure = Error & { + code: "ERR_TEST_FAILURE"; + failureType: string; + cause?: unknown; +}; +export function failure(cause: unknown, failureType = "testCodeFailure"): TestFailure { + const value = (isObject(cause) ? cause.message : undefined) ?? cause; + const message = typeof value === "string" ? value : inspect(value); + const error: TestFailure = Object.assign(codedError(new Error(message), "ERR_TEST_FAILURE"), { + failureType, + }); + error.cause = cause; + return error; +} +export function unsupported(api: string, reason: string): never { + throw unsupportedNodeApi(`node:test ${api}`, reason); +} +export function boolean(value: unknown, name: string): asserts value is boolean { + if (typeof value !== "boolean") { + throw invalidArgType(name, "boolean", value); + } +} +export function integer(value: unknown, name: string, min = 0): asserts value is number { + if (typeof value !== "number") { + throw invalidArgType(name, "number", value); + } + if (!Number.isSafeInteger(value) || value < min) { + throw outOfRange(name, `>= ${min} and <= ${Number.MAX_SAFE_INTEGER}`, value); + } +} +export function milliseconds(value: unknown, name: string): asserts value is number { + if (typeof value !== "number") { + throw invalidArgType(name, "number", value); + } + if (Number.isNaN(value) || value < 0 || value > 2147483647) { + throw outOfRange(name, ">= 0 && <= 2147483647", value); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/index.ts new file mode 100644 index 000000000..25a850080 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/index.ts @@ -0,0 +1,57 @@ +/** Node v24.20.0 public facade, adapted from nodejs/node lib/test.js, + * 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT (see LICENSE). + * Component tests report TAP to the engine console without setting host exitCode. */ +import { createTestHarness } from "./runner.js"; +import { formatTap } from "./tap.js"; +import type { TestEvent, TestModule } from "./types.js"; +let started = false; +const harness = createTestHarness({ + report(event: TestEvent): void { + if (!started) { + console.log("TAP version 13"); + started = true; + } + const text = formatTap(event); + if (text) { + console.log(text.trimEnd()); + } + }, +}); +const test: TestModule = harness.module; +export default test; +export const { + after, + afterEach, + assert, + before, + beforeEach, + describe, + expectFailure, + getTestContext, + it, + mock, + only, + run, + skip, + snapshot, + suite, + todo, +} = test; +export { test }; +export type * from "./types.js"; +export type { TestContext, SuiteContext } from "./context.js"; +export type { + MockFunctionContext, + MockFunctionCall, + MockPropertyContext, + MockPropertyAccess, + MockTracker, + Mock, + MockOptions, + MockMethodOptions, + MockModuleOptions, + MockModuleContext, + MockTimers, + MockTimersOptions, +} from "./mock.js"; +export type { TestAssertions, AssertionRegistry, SnapshotConfiguration } from "./assert.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/mock.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/mock.ts new file mode 100644 index 000000000..948a799c0 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/mock.ts @@ -0,0 +1,475 @@ +/** + * Adapted from nodejs/node lib/internal/test_runner/mock/mock.js, + * v24.20.0, commit 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT (see LICENSE). + * Local changes: explicit TypeScript types, standard intrinsics and shared error + * helpers replace primordials/internal validators. Loader and native timer mocks + * fail immediately. Symbol methods restore like string methods (upstream fix). + * Types adapted from DefinitelyTyped @types/node 24.13.3 test.d.ts (MIT). + */ +import { + boolean, + integer, + invalidArgType, + invalidArgValue, + unsupported, + validateFunction, + validateObject, + deprecatedNodeApi, +} from "./errors.js"; + +// `never[]` accepts arbitrary call signatures without erasing their inferred types. +type Callable = (...args: never[]) => unknown; +export type MockableFunction = Callable | (new (...args: never[]) => object); +export type MockArguments = F extends (...args: infer A) => unknown + ? A + : F extends new (...args: infer A) => object + ? A + : never; +export type MockResult = F extends (...args: never[]) => infer R + ? R + : F extends new (...args: never[]) => infer R + ? R + : never; +export interface MockOptions { + times?: number; +} +export interface MockMethodOptions extends MockOptions { + getter?: boolean; + setter?: boolean; +} +export interface MockFunctionCall { + arguments: MockArguments; + error: unknown; + result: MockResult | undefined; + stack: Error; + target: F | undefined; + this: unknown; +} +export type Mock = F & { mock: MockFunctionContext }; +interface Restore { + original: MockableFunction; + object?: object; + methodName?: string | symbol; + descriptor?: PropertyDescriptor; +} +interface FunctionState { + calls: MockFunctionCall[]; + mocks: Map; + implementation: F; + restore: Restore; + times: number; +} +const states = new WeakMap>(); + +export class MockFunctionContext { + readonly #state: FunctionState; + constructor(implementation: F, restore: Restore, times: number) { + this.#state = { calls: [], mocks: new Map(), implementation, restore, times }; + // State is only used by the type-preserving Proxy; its arguments/results come from F. + states.set(this, this.#state); + } + get calls(): MockFunctionCall[] { + return this.#state.calls.slice(); + } + callCount(): number { + return this.#state.calls.length; + } + mockImplementation(implementation: F): void { + validateFunction(implementation, "implementation"); + this.#state.implementation = implementation; + } + mockImplementationOnce(implementation: F, onCall?: number): void { + validateFunction(implementation, "implementation"); + const call = onCall ?? this.callCount(); + integer(call, "onCall", this.callCount()); + this.#state.mocks.set(call, implementation); + } + restore(): void { + const { object, methodName, descriptor, original } = this.#state.restore; + if (methodName !== undefined && object && descriptor) { + Object.defineProperty(object, methodName, { ...descriptor }); + } else { + this.#state.implementation = original as F; + } + } + resetCalls(): void { + this.#state.calls = []; + } +} + +export interface MockPropertyAccess { + type: "get" | "set"; + value: T; + stack: Error; +} +export type MockProperty = T & { + mock: MockPropertyContext; +}; +export class MockPropertyContext { + readonly #object: object; + readonly #propertyName: string | symbol; + readonly #originalValue: T; + readonly #descriptor: PropertyDescriptor; + #value: T; + #accesses: MockPropertyAccess[] = []; + readonly #onceValues = new Map(); + constructor(object: object, propertyName: string | symbol, ...values: [] | [T]) { + this.#object = object; + this.#propertyName = propertyName; + this.#originalValue = Reflect.get(object, propertyName) as T; + this.#value = values.length ? values[0] : this.#originalValue; + const descriptor = Object.getOwnPropertyDescriptor(object, propertyName); + if (!descriptor) { + throw invalidArgValue("propertyName", propertyName, "is not a property of the object"); + } + this.#descriptor = descriptor; + Object.defineProperty(object, propertyName, { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + get: (): T => { + const value = this.#getAccessValue(this.#value); + this.#accesses.push({ type: "get", value, stack: new Error() }); + return value; + }, + set: this.mockImplementation.bind(this), + }); + } + get accesses(): MockPropertyAccess[] { + return this.#accesses.slice(); + } + accessCount(): number { + return this.#accesses.length; + } + mockImplementation(value: T): void { + if (!this.#descriptor.writable) { + throw invalidArgValue("propertyName", this.#propertyName, "cannot be set"); + } + const next = this.#getAccessValue(value); + this.#accesses.push({ type: "set", value: next, stack: new Error() }); + this.#value = next; + } + #getAccessValue(value: T): T { + const index = this.#accesses.length; + if (!this.#onceValues.has(index)) { + return value; + } + const next = this.#onceValues.get(index)!; + this.#onceValues.delete(index); + return next; + } + mockImplementationOnce(value: T, onAccess?: number): void { + const index = onAccess ?? this.accessCount(); + integer(index, "onAccess", this.accessCount()); + this.#onceValues.set(index, value); + } + resetAccesses(): void { + this.#accesses = []; + } + restore(): void { + Object.defineProperty(this.#object, this.#propertyName, { + ...this.#descriptor, + value: this.#originalValue, + }); + } +} + +export interface MockTimersOptions { + apis?: readonly ("setTimeout" | "setInterval" | "setImmediate" | "Date")[]; + now?: number | Date; +} +export class MockTimers { + enable(options?: MockTimersOptions): void { + if (Array.isArray(options)) { + throw deprecatedNodeApi("mock.timers.enable(timers)", "mock.timers.enable({ apis })"); + } + unsupported("mock.timers.enable()", "shared Node timer internals are unavailable"); + } + tick(_milliseconds?: number): void { + unsupported("mock.timers.tick()", "shared Node timer internals are unavailable"); + } + runAll(): void { + unsupported("mock.timers.runAll()", "shared Node timer internals are unavailable"); + } + setTime(_milliseconds: number): void { + unsupported("mock.timers.setTime()", "shared Node timer internals are unavailable"); + } + // No timer state can have been installed by this tracker. + reset(): void {} + [Symbol.dispose](): void { + this.reset(); + } +} +export interface MockModuleOptions { + cache?: boolean; + exports?: object; + /** @deprecated Use exports.default. */ defaultExport?: unknown; + /** @deprecated Use exports. */ namedExports?: object; +} +export interface MockModuleContext { + restore(): void; +} +const defaultFunction = function (): void {}; +function validateTimes(times: unknown): asserts times is number { + if (times !== Infinity) { + integer(times, "options.times", 1); + } +} +function key(value: unknown, name: string): asserts value is string | symbol { + if (typeof value !== "string" && typeof value !== "symbol") { + throw invalidArgType(name, ["string", "symbol"], value); + } +} +function descriptorInChain(object: object, name: PropertyKey): PropertyDescriptor | undefined { + for (let host: object | null = object; host !== null; host = Object.getPrototypeOf(host)) { + const descriptor = Object.getOwnPropertyDescriptor(host, name); + if (descriptor) { + return descriptor; + } + } + return undefined; +} + +export class MockTracker { + #mocks: { restore(): void }[] = []; + #timers?: MockTimers; + get timers(): MockTimers { + return (this.#timers ??= new MockTimers()); + } + fn void>( + original?: F, + implementation?: F, + options?: MockOptions, + ): Mock; + fn(original: F, options: MockOptions): Mock; + fn(options?: MockOptions): Mock<() => void>; + fn( + original: MockableFunction | MockOptions = function (): void {}, + implementation: MockableFunction | MockOptions = original, + options: MockOptions = {}, + ): Mock { + 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; + validateTimes(times); + return this.#setupMock(new MockFunctionContext(implementation, { original }, times), original); + } + method( + object: T, + methodName: K, + options?: MockMethodOptions, + ): Mock>; + method( + object: T, + methodName: K, + implementation: F, + options?: MockMethodOptions, + ): Mock; + method( + object: object, + methodName: PropertyKey, + implementation: MockableFunction | MockMethodOptions = defaultFunction, + options: MockMethodOptions = {}, + ): Mock { + key(methodName, "methodName"); + if (typeof object !== "function") { + validateObject(object, "object"); + } + if (implementation !== null && typeof implementation === "object") { + options = implementation; + implementation = defaultFunction; + } + validateFunction(implementation, "implementation"); + validateObject(options, "options"); + const { getter = false, setter = false, times = Infinity } = options; + boolean(getter, "options.getter"); + boolean(setter, "options.setter"); + validateTimes(times); + if (getter && setter) { + throw invalidArgValue("options.setter", setter, "cannot be used with 'options.getter'"); + } + const descriptor = descriptorInChain(object, methodName); + const original: unknown = getter + ? descriptor?.get + : setter + ? descriptor?.set + : descriptor?.value; + if (typeof original !== "function") { + throw invalidArgValue("methodName", original, "must be a method"); + } + // Runtime validation above establishes a callable function. + const fn = original as MockableFunction; + const context = new MockFunctionContext( + implementation === defaultFunction ? fn : implementation, + { original: fn, object, methodName, descriptor }, + times, + ); + const mock = this.#setupMock(context, fn); + const mockDescriptor: PropertyDescriptor = { + configurable: descriptor!.configurable, + enumerable: descriptor!.enumerable, + }; + if (getter) { + mockDescriptor.get = mock as Mock; + mockDescriptor.set = descriptor!.set; + } else if (setter) { + mockDescriptor.get = descriptor!.get; + mockDescriptor.set = mock as Mock; + } else { + mockDescriptor.writable = descriptor!.writable; + mockDescriptor.value = mock; + } + Object.defineProperty(object, methodName, mockDescriptor); + return mock; + } + getter( + object: T, + methodName: K, + implementation?: (() => T[K]) | MockMethodOptions, + options?: MockMethodOptions, + ): Mock<() => T[K]> { + if (implementation !== null && typeof implementation === "object") { + options = implementation; + implementation = undefined; + } + options ??= {}; + validateObject(options, "options"); + const { getter = true } = options; + boolean(getter, "options.getter"); + if (getter === false) { + throw invalidArgValue("options.getter", getter, "cannot be false"); + } + // The accessor descriptor validated by method has this getter signature. + return this.method(object, methodName, implementation ?? defaultFunction, { + ...options, + getter, + }) as Mock<() => T[K]>; + } + setter( + object: T, + methodName: K, + implementation?: ((value: T[K]) => void) | MockMethodOptions, + options?: MockMethodOptions, + ): Mock<(value: T[K]) => void> { + if (implementation !== null && typeof implementation === "object") { + options = implementation; + implementation = undefined; + } + options ??= {}; + validateObject(options, "options"); + const { setter = true } = options; + boolean(setter, "options.setter"); + if (setter === false) { + throw invalidArgValue("options.setter", setter, "cannot be false"); + } + return this.method(object, methodName, implementation ?? defaultFunction, { + ...options, + setter, + }); + } + property( + object: T, + propertyName: K, + ...values: [] | [T[K]] + ): MockProperty { + validateObject(object, "object"); + key(propertyName, "propertyName"); + const context = new MockPropertyContext(object, propertyName, ...values); + this.#mocks.push(context); + // Proxy adds only the mock context; all other properties retain T's contract. + return new Proxy(object, { + get(target: T, property: PropertyKey, receiver: unknown): unknown { + return property === "mock" ? context : Reflect.get(target, property, receiver); + }, + }) as MockProperty; + } + module(_specifier: string | URL, _options?: MockModuleOptions): MockModuleContext { + return unsupported( + "mock.module()", + "component modules are linked before execution; runtime loader hooks are unavailable", + ); + } + reset(): void { + this.restoreAll(); + this.#timers?.reset(); + this.#mocks = []; + } + restoreAll(): void { + for (const mock of this.#mocks) { + mock.restore(); + } + } + #setupMock(context: MockFunctionContext, original: F): Mock { + const state = states.get(context)!; + const next = (): MockableFunction => { + const index = state.calls.length; + const implementation = state.mocks.get(index) ?? state.implementation; + if (index + 1 === state.times) { + context.restore(); + } + state.mocks.delete(index); + return implementation; + }; + const proxy = new Proxy(original, { + apply(_target: F, thisArg: unknown, argumentsList: unknown[]): unknown { + const implementation = next(); + let result: unknown; + let error: unknown; + try { + result = Reflect.apply(implementation, thisArg, argumentsList); + } catch (caught) { + error = caught; + throw caught; + } finally { + state.calls.push({ + arguments: argumentsList as never[], + error, + result, + stack: new Error(), + target: undefined, + this: thisArg, + }); + } + return result; + }, + construct( + target: F, + argumentsList: unknown[], + newTarget: new (...args: never[]) => object, + ): object { + const implementation = next(); + let result: object | undefined; + let error: unknown; + try { + result = Reflect.construct(implementation, argumentsList, newTarget); + } catch (caught) { + error = caught; + throw caught; + } finally { + state.calls.push({ + arguments: argumentsList as never[], + error, + result, + stack: new Error(), + target, + this: result, + }); + } + return result!; + }, + get(target: F, property: PropertyKey, receiver: unknown): unknown { + return property === "mock" ? context : Reflect.get(target, property, receiver); + }, + }); + this.#mocks.push(context); + return proxy as Mock; + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/reporters.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/reporters.ts new file mode 100644 index 000000000..975d66bdf --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/reporters.ts @@ -0,0 +1,336 @@ +/** + * Adapted from nodejs/node lib/{test/reporters.js,internal/test_runner/reporter/ + * {dot,junit,spec,lcov,utils}.js}, v24.20.0, + * 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT (see LICENSE). + * Local changes: typed events, jco-std streams/path/inspect, no process/terminal + * discovery or ANSI colors. JUnit hostname is empty; engine stack formatting and + * human-readable coverage tables are intentionally simplified. + */ +import { Transform } from "../stream/index.js"; +import type { + TransformOptions, + TransformCallback, + Transform as TransformStream, +} from "../stream/types.js"; +import { createPath } from "../path.js"; +// Coverage events carry absolute file paths and their own working directory. +const posix = createPath({ initialCwd: () => "/", getEnvironment: () => [] }); +import { inspect } from "../assert/inspect.js"; +import { invalidArgType } from "./errors.js"; +import type { TestEvent, TestEventSource, TestLocation, TestResult } from "./types.js"; +import { tap } from "./tap.js"; +export { tap }; +export type { TestEvent, TestEventSource } from "./types.js"; + +function formatResult( + type: "test:pass" | "test:fail", + data: TestResult, + showError = true, + prefix = "", + indent = "", +): string { + let symbol = type === "test:pass" ? "✔ " : "✖ "; + let title = `${data.name}${data.details.duration_ms ? ` (${data.details.duration_ms}ms)` : ""}`; + if (data.skip !== undefined) { + symbol = "﹣ "; + title += ` # ${typeof data.skip === "string" && data.skip.length ? data.skip : "SKIP"}`; + } else if (data.todo !== undefined) { + title += ` # ${typeof data.todo === "string" && data.todo.length ? data.todo : "TODO"}`; + if (type === "test:fail") { + symbol = "⚠ "; + } + } else if (data.expectFailure !== undefined) { + title += " # EXPECTED FAILURE"; + } + const error = data.details.error; + return `${prefix}${indent}${symbol}${title}${showError && error ? `\n${indent} ${inspect(error.cause ?? error)}\n` : ""}`; +} +export async function* dot(source: TestEventSource): AsyncGenerator { + let count = 0; + const failed: TestResult[] = []; + for await (const { type, data } of source) { + if (type === "test:pass") { + yield "."; + } + if (type === "test:fail") { + yield "X"; + failed.push(data); + } + if ((type === "test:pass" || type === "test:fail") && ++count === 20) { + yield "\n"; + count = 0; + } + } + yield "\n"; + if (failed.length) { + yield "\nFailed tests:\n\n"; + for (const test of failed) { + yield formatResult("test:fail", test); + } + } +} +interface XmlNode { + tag?: string; + attrs: Record; + nesting: number; + children: (XmlNode | string)[]; + parent?: XmlNode; + comment?: string; +} +function escapeContent(text = ""): string { + return text.replace(/(&)(?!#\d{1,7};)/g, "&").replace(/\n`; + } + const attrs = Object.entries(node.attrs) + .map(([key, value]) => `${key}="${escapeAttribute(String(value))}"`) + .join(" "); + if (!node.children.length) { + return `${indent}<${node.tag} ${attrs}/>\n`; + } + return `${indent}<${node.tag} ${attrs}>\n${node.children.map(xml).join("")}${indent}\n`; +} +function childNode( + tag: string, + nesting: number, + attrs: Record, + children: string[] = [], +): XmlNode { + return { tag, nesting, attrs, children }; +} +export async function* junit(source: TestEventSource): AsyncGenerator { + yield '\n'; + yield "\n"; + const roots: XmlNode[] = []; + let current: XmlNode | undefined; + function start(data: TestLocation): XmlNode { + const node: XmlNode = { + attrs: { name: data.name }, + nesting: data.nesting, + parent: current, + children: [], + }; + if (current) { + current.children.push(node); + } else { + roots.push(node); + } + return (current = node); + } + for await (const { type, data } of source) { + if (type === "test:start") { + start(data); + } else if (type === "test:pass" || type === "test:fail") { + const node = + current?.attrs.name === data.name && current.nesting === data.nesting + ? current + : start(data); + current = node.parent; + node.attrs.time = (data.details.duration_ms / 1000).toFixed(6); + const children = node.children.filter( + (child): child is XmlNode => typeof child !== "string" && child.comment === undefined, + ); + if (children.length) { + node.tag = "testsuite"; + Object.assign(node.attrs, { + disabled: 0, + errors: 0, + tests: children.length, + failures: children.filter( + (child) => + child.attrs.failures || + child.children.some((c) => typeof c !== "string" && c.tag === "failure"), + ).length, + skipped: children.filter( + (child) => + child.attrs.skipped || + child.children.some((c) => typeof c !== "string" && c.tag === "skipped"), + ).length, + timestamp: new Date(Date.now() - data.details.duration_ms).toISOString(), + hostname: "", + }); + } else { + node.tag = "testcase"; + node.attrs.classname = data.classname ?? "test"; + if (data.file) { + node.attrs.file = data.file; + } + if (data.skip) { + node.children.push( + childNode("skipped", data.nesting + 1, { type: "skipped", message: data.skip }), + ); + } + if (data.todo) { + node.children.push( + childNode("skipped", data.nesting + 1, { type: "todo", message: data.todo }), + ); + } + if (type === "test:fail") { + const error = data.details.error; + node.children.push( + childNode( + "failure", + data.nesting + 1, + { type: error?.failureType ?? error?.code, message: error?.message.trim() ?? "" }, + [inspect(error)], + ), + ); + node.attrs.failure = error?.message ?? ""; + } + } + } else if (type === "test:diagnostic" || type === "test:log") { + (current?.children ?? roots).push({ + attrs: {}, + nesting: data.nesting, + children: [], + comment: data.message, + }); + } + } + for (const root of roots) { + yield xml(root); + } + yield "\n"; +} +function event(value: unknown): TestEvent { + if ( + !value || + typeof value !== "object" || + !("type" in value) || + typeof value.type !== "string" || + !value.type.startsWith("test:") + ) { + throw invalidArgType("event", "TestEvent", value); + } + // Reporter streams consume the documented Node TestEvent discriminated union. + return value as TestEvent; +} +class SpecReporter extends Transform { + #stack: TestLocation[] = []; + #failed: TestResult[] = []; + constructor(_options?: TransformOptions) { + super({ writableObjectMode: true }); + } + _transform(chunk: unknown, _encoding: string, callback: TransformCallback): void { + try { + const { type, data } = event(chunk); + let text = ""; + if (type === "test:start") { + this.#stack.unshift(data); + } else if (type === "test:pass" || type === "test:fail") { + this.#stack.shift(); + while (this.#stack.length) { + const parent = this.#stack.pop()!; + text += `${" ".repeat(parent.nesting)}▶ ${parent.name}\n`; + } + text = `${formatResult(type, data, false, text, " ".repeat(data.nesting))}\n`; + if (type === "test:fail" && data.details.error?.failureType !== "subtestsFailed") { + this.#failed.push(data); + } + } else if (type === "test:stdout" || type === "test:stderr") { + text = data.message; + } else if (type === "test:diagnostic" || type === "test:log") { + text = `${" ".repeat(data.nesting)}ℹ ${data.message}\n`; + } else if (type === "test:summary") { + text = this.#failures(); + } else if (type === "test:coverage") { + text = data.summary.files + .map( + (file) => + `ℹ ${file.path}: ${file.coveredLineCount}/${file.totalLineCount} lines covered\n`, + ) + .join(""); + } else if (type === "test:interrupted") { + text = `\nInterrupted while running:\n${data.tests.map((test) => `${" ".repeat(test.nesting)}⚠ ${test.name}\n`).join("")}`; + } + callback(null, text || undefined); + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))); + } + } + #failures(): string { + if (!this.#failed.length) { + return ""; + } + const text = `\n✖ failing tests:\n${this.#failed.map((test) => formatResult("test:fail", test)).join("\n")}`; + this.#failed = []; + return text; + } +} +class LcovReporter extends Transform { + constructor(options?: TransformOptions) { + super({ ...options, writableObjectMode: true }); + } + _transform(chunk: unknown, _encoding: string, callback: TransformCallback): void { + try { + const item = event(chunk); + if (item.type !== "test:coverage") { + callback(null); + return; + } + const { workingDirectory, files } = item.data.summary; + let text = "TN:\n"; + for (const file of files) { + text += `SF:${posix.relative(workingDirectory, file.path)}\n`; + let counts = ""; + file.functions.forEach((fn, index): void => { + const name = fn.name || `anonymous_${index}`; + text += `FN:${fn.line},${name}\n`; + counts += `FNDA:${fn.count},${name}\n`; + }); + text += `${counts}FNF:${file.totalFunctionCount}\nFNH:${file.coveredFunctionCount}\n`; + file.branches.forEach((branch, index): void => { + text += `BRDA:${branch.line},${index},0,${branch.count}\n`; + }); + text += `BRF:${file.totalBranchCount}\nBRH:${file.coveredBranchCount}\n`; + for (const line of [...file.lines].sort((a, b) => a.line - b.line)) { + text += `DA:${line.line},${line.count}\n`; + } + text += `LH:${file.coveredLineCount}\nLF:${file.totalLineCount}\nend_of_record\n`; + } + callback(null, text); + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))); + } + } +} +export interface ReporterConstructor { + (options?: TransformOptions): TransformStream; + new (options?: TransformOptions): TransformStream; +} +// The public wrappers are both callable and constructible, like lib/test/reporters.js. +export const spec: ReporterConstructor = function spec( + options?: TransformOptions, +): TransformStream { + return new SpecReporter(options); +} as ReporterConstructor; +export const lcov: ReporterConstructor = function lcov( + options?: TransformOptions, +): TransformStream { + return new LcovReporter(options); +} as ReporterConstructor; +export interface Reporters { + readonly dot: typeof dot; + readonly junit: typeof junit; + spec: ReporterConstructor; + readonly tap: typeof tap; + lcov: ReporterConstructor; +} +const reporters = {} as Reporters; +Object.defineProperties(reporters, { + dot: { configurable: true, enumerable: true, get: (): typeof dot => dot }, + junit: { configurable: true, enumerable: true, get: (): typeof junit => junit }, + spec: { configurable: true, enumerable: true, value: spec }, + tap: { configurable: true, enumerable: true, get: (): typeof tap => tap }, + lcov: { configurable: true, enumerable: true, value: lcov }, +}); +export default reporters; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/runner.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/runner.ts new file mode 100644 index 000000000..8b1939f59 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/runner.ts @@ -0,0 +1,811 @@ +/** + * Component scheduler adapted from nodejs/node lib/internal/test_runner/ + * {harness,test,tag_filter}.js at v24.20.0, + * 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT (see LICENSE). + * Keeps Node argument normalization, hook phases, test-failure resolution, + * expected failures and scoped contexts. Replaces process/AsyncResource bootstrap + * with a serial microtask queue. No implicit context propagation across await, + * file discovery, process exit handlers, or engine instrumentation is claimed. + */ +import { AbortController } from "../abort-globals.js"; +import { isPromiseLike as thenable, validateAbortSignal } from "../stream/shared.js"; +import nodeAssert, { type AssertPredicate } from "../assert/index.js"; +import { assert, snapshot } from "./assert.js"; +import { + TestContext, + SuiteContext, + TestPlan, + type ContextState, + type HookName, +} from "./context.js"; +import { MockTracker } from "./mock.js"; +import { + failure, + invalidArgType, + invalidArgValue, + milliseconds, + unsupported, + validateFunction, + validateObject, + validateUint32, + type TestFailure, +} from "./errors.js"; +import type { + Done, + ExpectFailure, + Hook, + HookFn, + HookOptions, + RunOptions, + SuiteFn, + TestEvent, + TestFn, + TestFunction, + TestModule, + TestOptions, + TestResult, + TestsStream, +} from "./types.js"; + +type Body = TestFn | SuiteFn; +type Arguments = [name?: string | TestOptions | Body, options?: TestOptions | Body, fn?: Body]; +interface HookRecord { + fn: HookFn; + options: HookOptions; +} +export interface HarnessOptions { + report?: (event: TestEvent) => void; + only?: boolean; +} +export interface TestHarness { + module: TestModule; + drain(): Promise; +} +function normalize( + [name, options, fn]: Arguments, + overrides: TestOptions, +): { name: string; fn: Body; options: TestOptions } { + if (typeof name === "function") { + fn = name; + } else if (name !== null && typeof name === "object") { + fn = typeof options === "function" ? options : undefined; + options = name; + } else if (typeof options === "function") { + fn = options; + } + if (options === null || typeof options !== "object") { + options = {}; + } + if (typeof fn !== "function") { + fn = (): void => {}; + } + return { + name: typeof name === "string" && name !== "" ? name : fn.name || "", + fn, + options: { ...options, ...overrides }, + }; +} +function tags(value: unknown, inherited: readonly string[]): readonly string[] { + if (value === undefined) { + return inherited; + } + if (!Array.isArray(value)) { + throw invalidArgType("options.tags", "Array", value); + } + const result = new Set(inherited); + for (let i = 0; i < value.length; i++) { + const tag: unknown = value[i]; + if (typeof tag !== "string") { + throw invalidArgType(`options.tags[${i}]`, "string", tag); + } + if (tag.length === 0) { + throw invalidArgValue(`options.tags[${i}]`, tag, "must not be empty"); + } + result.add(tag.toLowerCase()); + } + return Object.freeze([...result]); +} +function isFailure(error: unknown): error is TestFailure { + return error instanceof Error && "code" in error && error.code === "ERR_TEST_FAILURE"; +} +function expectFailure( + value: ExpectFailure | undefined, +): { label?: string; match?: AssertPredicate } | undefined { + if (value === undefined || value === false) { + return undefined; + } + if (typeof value === "string") { + return { label: value }; + } + if (typeof value === "function" || value instanceof RegExp) { + return { match: value }; + } + if (typeof value !== "object") { + return {}; + } + if (value === null || Object.keys(value).length === 0) { + throw invalidArgValue("options.expectFailure", value, "must not be an empty object"); + } + if ("label" in value || "match" in value) { + return value as { label?: string; match?: AssertPredicate }; + } + return { match: value }; +} + +export function createTestHarness(options: HarnessOptions = {}): TestHarness { + let current: TestNode | undefined; + let currentContext: TestContext | SuiteContext | undefined; + let asyncBodies = 0; + let drainPromise: Promise | undefined; + let rootBefore = false; + let rootBeforeError: TestFailure | undefined; + let rootAfter = false; + const pending: TestNode[] = []; + const rootHooks: Record = { + before: [], + after: [], + beforeEach: [], + afterEach: [], + }; + const report = options.report ?? ((): void => {}); + const globalMock = new MockTracker(); + const counts = { + tests: 0, + passed: 0, + failed: 0, + cancelled: 0, + skipped: 0, + todo: 0, + suites: 0, + topLevel: 0, + }; + let globalNumber = 0; + const start = Date.now(); + + function scope( + node: TestNode | undefined, + context: TestContext | SuiteContext | undefined, + fn: () => T, + ): T { + const previous = current; + const previousContext = currentContext; + current = node; + currentContext = context; + try { + return fn(); + } finally { + current = previous; + currentContext = previousContext; + } + } + function registrationParent(): TestNode | undefined { + if (!current && asyncBodies) { + unsupported( + "implicit test context", + "use t.test() and t hooks after await; the engine cannot propagate async context", + ); + } + return current; + } + function registerHook(target: HookRecord[], fn?: HookFn, hookOptions: HookOptions = {}): void { + if (fn === undefined) { + fn = (): void => {}; + } + validateFunction(fn, "fn"); + validateObject(hookOptions, "options"); + validateAbortSignal(hookOptions.signal); + if (hookOptions.timeout !== undefined && hookOptions.timeout !== Infinity) { + milliseconds(hookOptions.timeout, "options.timeout"); + } + target.push({ fn, options: hookOptions }); + } + async function invoke( + node: TestNode, + context: TestContext, + fn: HookFn | TestFn, + hookOptions?: HookOptions, + cleanup = false, + ): Promise { + let callbackResolve!: () => void; + let callbackReject!: (reason: unknown) => void; + let calls = 0; + const callback = new Promise((resolve, reject): void => { + callbackResolve = resolve; + callbackReject = reject; + }); + // Always observe callback rejections, including a callback + promise conflict. + void callback.catch((): void => {}); + const done: Done = (error?: unknown): void => { + if (++calls > 1) { + node.fail(failure("callback invoked multiple times", "multipleCallbackInvocations")); + return; + } + if (error) { + callbackReject(error); + } else { + callbackResolve(); + } + }; + const result = scope(node, context, (): unknown => + Reflect.apply(fn, context, fn.length === 2 ? [context, done] : [context]), + ); + let promise: Promise; + if (fn.length === 2) { + if (thenable(result)) { + void Promise.resolve(result).catch((): void => {}); + throw failure("passed a callback but also returned a Promise", "callbackAndPromisePresent"); + } + promise = callback; + } else { + promise = Promise.resolve(result); + } + asyncBodies++; + try { + await node.interruptible(promise, hookOptions, cleanup); + } finally { + asyncBodies--; + } + } + async function hooks( + node: TestNode, + records: readonly HookRecord[], + cleanup = false, + ): Promise { + for (const hook of records) { + try { + await invoke(node, node.context, hook.fn, hook.options, cleanup); + } catch (error) { + throw failure(error, "hookFailed"); + } + } + } + + class TestNode implements ContextState { + readonly name: string; + readonly fullName: string; + readonly parent?: TestNode; + readonly suite: boolean; + readonly body: Body; + readonly config: TestOptions; + readonly controller = new AbortController(); + readonly signal = this.controller.signal; + readonly context: TestContext; + readonly suiteContext: SuiteContext; + readonly children: TestNode[] = []; + readonly hooks: Record = { + before: [], + after: [], + beforeEach: [], + afterEach: [], + }; + readonly tags: readonly string[]; + readonly expected?: { label?: string; match?: AssertPredicate }; + readonly number: number; + readonly nesting: number; + readonly done: Promise; + readonly diagnostics: TestEvent[] = []; + #resolve!: () => void; + #childrenTail: Promise = Promise.resolve(); + #beforePromise?: Promise; + #outerAbort?: () => void; + #deadline?: number; + running = false; + finished = false; + passed = false; + error: TestFailure | null = null; + plan: TestPlan | null = null; + mock: MockTracker | null = null; + runOnly = false; + skipped?: boolean | string; + todo?: boolean | string; + constructor(args: Arguments, isSuite: boolean, overrides: TestOptions = {}, parent?: TestNode) { + const parsed = normalize(args, overrides); + this.config = parsed.options; + this.name = parsed.name; + this.body = parsed.fn; + this.suite = isSuite; + this.parent = parent; + this.fullName = parent ? `${parent.fullName} > ${this.name}` : this.name; + this.nesting = parent ? parent.nesting + 1 : 0; + this.number = parent ? parent.children.length + 1 : ++globalNumber; + const { concurrency, timeout, signal, skip, todo, plan } = this.config; + if (concurrency != null) { + if (typeof concurrency === "number") { + validateUint32(concurrency, "options.concurrency", true); + } else if (typeof concurrency !== "boolean") { + throw invalidArgType("options.concurrency", ["boolean", "number"], concurrency); + } + if (concurrency === true || (typeof concurrency === "number" && concurrency > 1)) { + unsupported( + "options.concurrency", + "concurrent tests require engine async-context support", + ); + } + } + if (timeout != null && timeout !== Infinity) { + milliseconds(timeout, "options.timeout"); + } + validateAbortSignal(signal); + this.tags = tags(this.config.tags, parent?.tags ?? Object.freeze([])); + this.expected = expectFailure(this.config.expectFailure) ?? parent?.expected; + this.skipped = skip !== undefined && skip !== false ? skip : undefined; + this.todo = todo !== undefined && todo !== false ? todo : parent?.todo; + if (options.only && !this.config.only && (parent?.runOnly || !parent)) { + this.skipped = "'only' option not set"; + } + if (!options.only && (this.config.only || parent?.runOnly)) { + this.diagnostic("'only' and 'runOnly' require the --test-only command-line option."); + } + if (plan !== undefined) { + this.plan = new TestPlan(plan); + } + this.context = new TestContext(this); + this.suiteContext = new SuiteContext(this); + this.done = new Promise((resolve): void => { + this.#resolve = resolve; + }); + if (signal) { + this.#outerAbort = (): void => { + this.controller.abort(signal.reason); + }; + if (signal.aborted) { + this.#outerAbort(); + } else { + signal.addEventListener("abort", this.#outerAbort, { once: true }); + } + } + report({ type: "test:enqueue", data: this.location() }); + } + location(): { name: string; nesting: number } { + return { name: this.name, nesting: this.nesting }; + } + build(): void { + if (!this.suite || this.skipped !== undefined) { + return; + } + try { + const result = scope(this, this.suiteContext, (): unknown => + Reflect.apply(this.body, this.suiteContext, [this.suiteContext]), + ); + if (thenable(result)) { + void Promise.resolve(result).catch((): void => {}); + unsupported( + "async suite callback", + "declare suites synchronously and use async test bodies with explicit contexts", + ); + } + } catch (error) { + this.fail(failure(error)); + } + } + add( + name?: string | TestOptions | TestFn, + options?: TestOptions | TestFn, + fn?: TestFn, + ): Promise { + return add([name, options, fn], false, {}, this); + } + hook(name: HookName, fn?: HookFn, options?: HookOptions): void { + if (this.finished) { + throw failure( + "test could not be started because its parent finished", + "parentAlreadyFinished", + ); + } + registerHook(this.hooks[name], fn, options); + if (name === "before" && this.running) { + const record = this.hooks.before.at(-1)!; + const running = hooks(this, [record]); + const prior = this.#beforePromise; + this.#beforePromise = prior ? Promise.all([prior, running]).then((): void => {}) : running; + void this.#beforePromise.catch((error: unknown): void => { + this.fail(isFailure(error) ? error : failure(error)); + }); + } + } + diagnostic(message: unknown): void { + this.diagnostics.push({ + type: "test:diagnostic", + data: { nesting: this.nesting, message: String(message) }, + }); + } + log(message: string, data?: unknown): void { + if (typeof message !== "string") { + throw invalidArgType("message", "string", message); + } + this.diagnostics.push({ type: "test:log", data: { nesting: this.nesting, message, data } }); + } + fail(error: TestFailure): void { + if (this.error) { + return; + } + this.error = error; + this.passed = false; + if (this.expected) { + if (this.expected.match !== undefined) { + try { + nodeAssert.throws((): never => { + throw error.cause ?? error; + }, this.expected.match); + } catch (cause) { + this.error = failure( + "The test failed, but the error did not match the expected validation", + ); + this.error.cause = cause; + return; + } + } + this.passed = true; + } + } + before(): Promise { + return (this.#beforePromise ??= hooks(this, this.hooks.before)); + } + queue(child: TestNode): void { + this.#childrenTail = this.#childrenTail.then(async (): Promise => { + if (!this.running || this.finished || this.signal.aborted) { + child.fail( + failure("test did not finish before its parent and was cancelled", "cancelledByParent"), + ); + } else { + try { + await this.before(); + } catch (error) { + child.fail(isFailure(error) ? error : failure(error)); + } + } + await child.execute(); + }); + } + async interruptible( + promise: Promise, + hookOptions?: HookOptions, + cleanup = false, + ): Promise { + const signal = hookOptions?.signal ?? (cleanup ? new AbortController().signal : this.signal); + const timeout = hookOptions?.timeout; + const remaining = + timeout ?? + (cleanup || this.#deadline === undefined + ? undefined + : Math.max(0, this.#deadline - Date.now())); + if (signal.aborted) { + throw failure(signal.reason ?? "The operation was aborted", "testAborted"); + } + let timer: ReturnType | undefined; + let abort!: () => void; + const stop = new Promise((_resolve, reject): void => { + abort = (): void => { + reject(failure(signal.reason ?? "The operation was aborted", "testAborted")); + }; + signal.addEventListener("abort", abort, { once: true }); + if (remaining !== undefined && remaining !== Infinity) { + timer = setTimeout((): void => { + reject( + failure( + `test timed out after ${timeout ?? this.config.timeout ?? this.parent?.config.timeout}ms`, + "testTimeoutFailure", + ), + ); + }, remaining); + } + }); + try { + return await Promise.race([promise, stop]); + } finally { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + } + } + async execute(): Promise { + if (this.running || this.finished) { + return this.done; + } + this.running = true; + const started = Date.now(); + const timeout = this.config.timeout ?? this.parent?.config.timeout; + if (timeout !== undefined && timeout !== Infinity) { + this.#deadline = started + timeout; + } + report({ type: "test:dequeue", data: this.location() }); + report({ type: "test:start", data: this.location() }); + const ancestors: TestNode[] = []; + for (let parent = this.parent; parent; parent = parent.parent) { + ancestors.unshift(parent); + } + let runAfterEach = false; + try { + if (this.signal.aborted) { + throw failure(this.signal.reason ?? "The operation was aborted", "testAborted"); + } + if (!this.error && this.skipped === undefined) { + if (!this.suite) { + runAfterEach = true; + await hooks(this, rootHooks.beforeEach); + for (const ancestor of ancestors) { + await hooks(this, ancestor.hooks.beforeEach); + } + await invoke(this, this.context, this.body as TestFn); + } else { + await this.before(); + for (const child of this.children) { + this.queue(child); + } + } + await this.interruptible(this.#childrenTail); + await this.#beforePromise; + await this.interruptible(Promise.resolve(this.plan?.check())); + const failed = this.children.filter( + (child) => !child.passed && child.todo === undefined, + ).length; + if (failed) { + throw failure(`${failed} subtest${failed === 1 ? "" : "s"} failed`, "subtestsFailed"); + } + } + if (!this.error) { + if (this.expected && this.skipped === undefined) { + this.fail(failure("test was expected to fail but passed", "expectedFailure")); + this.passed = false; + } else { + this.passed = true; + } + } + } catch (error) { + this.fail(isFailure(error) ? error : failure(error)); + } finally { + // Context.passed/error are available to cleanup hooks, before mock reset. + if (runAfterEach) { + for (const ancestor of ancestors.reverse()) { + try { + await hooks(this, ancestor.hooks.afterEach, true); + } catch (error) { + this.fail(isFailure(error) ? error : failure(error)); + } + } + try { + await hooks(this, rootHooks.afterEach, true); + } catch (error) { + this.fail(isFailure(error) ? error : failure(error)); + } + } + try { + await hooks(this, this.hooks.after, true); + } catch (error) { + this.fail(isFailure(error) ? error : failure(error)); + } + this.finished = true; + this.controller.abort(); + for (const child of this.children) { + if (!child.finished) { + child.controller.abort(); + } + } + // A failed suite still reports every declared child as cancelled. + for (const child of this.children) { + if (!child.running) { + child.fail( + failure( + "test did not finish before its parent and was cancelled", + "cancelledByParent", + ), + ); + await child.execute(); + } + } + await this.#childrenTail; + this.mock?.reset(); + this.plan?.dispose(); + if (this.#outerAbort) { + this.config.signal?.removeEventListener("abort", this.#outerAbort); + } + if (this.children.length) { + report({ + type: "test:plan", + data: { nesting: this.nesting + 1, count: this.children.length }, + }); + } + const result: TestResult = { + ...this.location(), + testNumber: this.number, + details: { duration_ms: Date.now() - started, type: this.suite ? "suite" : "test" }, + tags: this.tags, + }; + if (this.error) { + result.details.error = this.error; + } + if (this.skipped !== undefined) { + result.skip = this.skipped; + } + if (this.todo !== undefined) { + result.todo = this.todo; + } + if (this.expected) { + result.expectFailure = this.expected.label ?? true; + } + if (this.suite) { + counts.suites++; + } else { + counts.tests++; + } + if (!this.parent) { + counts.topLevel++; + } + if (this.skipped !== undefined) { + counts.skipped++; + } else if (this.todo !== undefined) { + counts.todo++; + } else if (this.passed) { + counts.passed++; + } else { + counts.failed++; + if ( + ["testAborted", "cancelledByParent", "testTimeoutFailure"].includes( + this.error?.failureType ?? "", + ) + ) { + counts.cancelled++; + } + } + report({ type: "test:complete", data: result }); + report({ type: this.passed ? "test:pass" : "test:fail", data: result }); + for (const diagnostic of this.diagnostics) { + report(diagnostic); + } + this.#resolve(); + } + } + } + function add( + args: Arguments, + suite: boolean, + overrides: TestOptions = {}, + parent = registrationParent(), + ): Promise { + if (typeof AbortController !== "function") { + unsupported( + "test()", + "this engine does not provide AbortController; mocks and reporters remain available", + ); + } + const node = new TestNode(args, suite, overrides, parent); + if (parent) { + parent.children.push(node); + if (parent.finished || (parent.suite && parent.running)) { + node.fail( + failure("test could not be started because its parent finished", "parentAlreadyFinished"), + ); + } + } + node.build(); + if (parent?.suite && !parent.running) { + return Promise.resolve(); + } + if (parent) { + parent.queue(node); + } else { + pending.push(node); + schedule(); + } + return suite ? Promise.resolve() : node.done; + } + async function drain(): Promise { + while (pending.length) { + const node = pending.shift()!; + if (!rootBefore) { + rootBefore = true; + try { + await hooks(node, rootHooks.before); + } catch (error) { + rootBeforeError = isFailure(error) ? error : failure(error); + } + } + if (rootBeforeError) { + node.fail(rootBeforeError); + } + await node.execute(); + // Let awaiting callers append their next test before considering the batch drained. + await Promise.resolve(); + if (!pending.length && !rootAfter) { + rootAfter = true; + try { + await hooks(node, rootHooks.after, true); + } catch (error) { + counts.failed++; + report({ + type: "test:fail", + data: { + name: "after", + nesting: 0, + testNumber: ++globalNumber, + details: { + type: "test", + duration_ms: 0, + error: isFailure(error) ? error : failure(error), + }, + }, + }); + } + } + } + report({ type: "test:plan", data: { nesting: 0, count: globalNumber } }); + report({ + type: "test:summary", + data: { + success: counts.failed === 0, + counts: { ...counts }, + duration_ms: Date.now() - start, + }, + }); + } + function schedule(): void { + if (!drainPromise) { + drainPromise = Promise.resolve() + .then(drain) + .finally((): void => { + drainPromise = undefined; + if (pending.length) { + schedule(); + } + }); + } + } + function callable(isSuite: boolean): TestFunction { + const test = function ( + name?: string | TestOptions | Body, + options?: TestOptions | Body, + fn?: Body, + ): Promise { + return add([name, options, fn], isSuite); + }; + Object.defineProperty(test, "name", { value: "test", configurable: true }); + return Object.assign(test, { + expectFailure: (...args: Arguments): Promise => + add(args, isSuite, { expectFailure: true }), + skip: (...args: Arguments): Promise => add(args, isSuite, { skip: true }), + todo: (...args: Arguments): Promise => add(args, isSuite, { todo: true }), + only: (...args: Arguments): Promise => add(args, isSuite, { only: true }), + }); + } + function hook(name: HookName): Hook { + return (fn?: HookFn, options?: HookOptions): void => { + const parent = registrationParent(); + if (parent) { + parent.hook(name, fn, options); + } else { + registerHook(rootHooks[name], fn, options); + } + }; + } + const test = callable(false); + const suite = callable(true); + const module = Object.assign(test, { + after: hook("after"), + afterEach: hook("afterEach"), + before: hook("before"), + beforeEach: hook("beforeEach"), + describe: suite, + getTestContext: (): TestContext | SuiteContext | undefined => currentContext, + it: test, + run: (_options?: RunOptions): TestsStream => + unsupported( + "run()", + "test file discovery, runtime imports, process isolation and coverage require a Node runtime", + ), + suite, + test, + }); + Object.defineProperties(module, { + mock: { configurable: true, enumerable: true, get: (): MockTracker => globalMock }, + snapshot: { configurable: true, enumerable: true, get: (): typeof snapshot => snapshot }, + assert: { configurable: true, enumerable: true, get: (): typeof assert => assert }, + }); + // Object.assign and defineProperties establish all aliases and getter namespaces. + return { + module: module as TestModule, + drain: async (): Promise => { + do { + await drainPromise; + } while (drainPromise); + }, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/tap.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/tap.ts new file mode 100644 index 000000000..786f7ba6a --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/tap.ts @@ -0,0 +1,101 @@ +/** Adapted from nodejs/node lib/internal/test_runner/reporter/tap.js, + * v24.20.0, 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT (see LICENSE). + * Typed events and portable error inspection replace internal bindings. YAML + * error details omit engine stack frames; coverage uses a portable text summary. */ +import type { TestEvent, TestEventSource } from "./types.js"; +import { inspect } from "../assert/inspect.js"; +export 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 yaml(indent: string, name: string, value: unknown): string { + if (value === undefined) { + return ""; + } + if (typeof value === "string") { + return `${indent} ${name}: ${JSON.stringify(value)}\n`; + } + return `${indent} ${name}: ${typeof value === "object" && value !== null ? JSON.stringify(inspect(value)) : String(value)}\n`; +} +export function formatTap(event: TestEvent): string { + const { type, data } = event; + switch (type) { + case "test:pass": + case "test:fail": { + const indent = " ".repeat(data.nesting); + let line = `${indent}${type === "test:pass" ? "ok" : "not ok"} ${data.testNumber}`; + if (data.name) { + line += ` ${tapEscape(`- ${data.name}`)}`; + } + for (const [name, value] of [ + ["SKIP", data.skip], + ["TODO", data.todo], + ["EXPECTED FAILURE", data.expectFailure], + ] as const) { + if (value !== undefined) { + line += ` # ${name}${typeof value === "string" && value.length ? ` ${tapEscape(value)}` : ""}`; + break; + } + } + line += `\n${indent} ---\n`; + line += yaml(indent, "duration_ms", data.details.duration_ms); + line += yaml(indent, "type", data.details.type); + const error = data.details.error; + if (error) { + line += yaml(indent, "failureType", error.failureType); + line += yaml(indent, "error", error.message); + line += yaml(indent, "code", error.code); + if (error.cause !== undefined) { + line += yaml(indent, "cause", error.cause); + } + } + return `${line}${indent} ...\n`; + } + case "test:start": + return `${" ".repeat(data.nesting)}# Subtest: ${tapEscape(data.name)}\n`; + case "test:plan": + return `${" ".repeat(data.nesting)}1..${data.count}\n`; + case "test:diagnostic": + case "test:log": + return `${" ".repeat(data.nesting)}# ${tapEscape(data.message)}\n`; + case "test:stdout": + case "test:stderr": + return data.message + .split(/\n|\r\n/) + .filter(Boolean) + .map((line) => `# ${tapEscape(line)}\n`) + .join(""); + case "test:interrupted": + return data.tests + .map( + (test) => + `# ${tapEscape(`Interrupted while running: ${test.name}${test.file ? ` at ${test.file}:${test.line}:${test.column}` : ""}`)}\n`, + ) + .join(""); + case "test:coverage": + return data.summary.files + .map( + (file) => + `# ${tapEscape(file.path)}: ${file.coveredLineCount}/${file.totalLineCount} lines covered\n`, + ) + .join(""); + default: + return ""; + } +} +export async function* tap(source: TestEventSource): AsyncGenerator { + yield "TAP version 13\n"; + for await (const event of source) { + const text = formatTap(event); + if (text) { + yield text; + } + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/types.ts new file mode 100644 index 000000000..c0d7c2368 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/types.ts @@ -0,0 +1,155 @@ +/** Signatures adapted from @types/node 24.13.3 test.d.ts (DefinitelyTyped, MIT), + * reconciled with Node v24.20.0, 71b8b174857e25106d39b61a9e6f30d927da8b01. + * Self-contained types use the portable assert and stream contracts. */ +import type { AssertPredicate } from "../assert/index.js"; +import type { Readable } from "../stream/types.js"; +import type { TestContext, SuiteContext } from "./context.js"; +import type { MockTracker } from "./mock.js"; +import type { AssertionRegistry, SnapshotConfiguration } from "./assert.js"; +import type { TestFailure } from "./errors.js"; + +export type Done = (result?: unknown) => void; +export type TestFn = (this: TestContext, t: TestContext, done: Done) => void | PromiseLike; +export type SuiteFn = (this: SuiteContext, s: SuiteContext) => void | PromiseLike; +export type HookFn = (this: TestContext, t: TestContext, done: Done) => void | PromiseLike; +export type ExpectFailure = + | boolean + | string + | AssertPredicate + | { label?: string; match?: AssertPredicate }; +export interface TestOptions { + concurrency?: number | boolean; + only?: boolean; + skip?: boolean | string; + todo?: boolean | string; + expectFailure?: ExpectFailure; + signal?: AbortSignal; + timeout?: number; + plan?: number; + tags?: readonly string[]; +} +export interface HookOptions { + timeout?: number; + signal?: AbortSignal; +} +export interface PlanOptions { + wait?: boolean | number; +} +export interface WaitForOptions { + interval?: number; + timeout?: number; +} +export interface TestCall { + (name?: string, fn?: F): Promise; + (name?: string, options?: TestOptions, fn?: F): Promise; + (options?: TestOptions, fn?: F): Promise; + (fn?: F): Promise; +} +export interface TestFunction extends TestCall { + skip: TestCall; + todo: TestCall; + only: TestCall; + expectFailure: TestCall; +} +export type Hook = (fn?: HookFn, options?: HookOptions) => void; +export interface TestModule extends TestFunction { + test: TestModule; + it: TestModule; + suite: TestFunction; + describe: TestFunction; + before: Hook; + after: Hook; + beforeEach: Hook; + afterEach: Hook; + getTestContext(): TestContext | SuiteContext | undefined; + run(options?: RunOptions): TestsStream; + readonly mock: MockTracker; + readonly assert: AssertionRegistry; + readonly snapshot: SnapshotConfiguration; +} +export interface RunOptions extends TestOptions { + files?: readonly string[]; + cwd?: string; + globPatterns?: readonly string[]; + forceExit?: boolean; + isolation?: "process" | "none"; + inspectPort?: number | (() => number); + setup?: (stream: TestsStream) => void | Promise; + execArgv?: readonly string[]; + argv?: readonly string[]; + watch?: boolean; + shard?: { index: number; total: number }; + testNamePatterns?: string | RegExp | readonly (string | RegExp)[]; + testSkipPatterns?: string | RegExp | readonly (string | RegExp)[]; + testTagFilters?: readonly string[]; + coverage?: boolean; + coverageIncludeGlobs?: readonly string[]; + coverageExcludeGlobs?: readonly string[]; + lineCoverage?: number; + branchCoverage?: number; + functionCoverage?: number; + updateSnapshots?: boolean; + rerunFailuresFilePath?: string; +} +export interface TestLocation { + name: string; + nesting: number; + file?: string; + line?: number; + column?: number; +} +export interface TestResult extends TestLocation { + testNumber: number; + details: { duration_ms: number; type: "test" | "suite"; error?: TestFailure }; + skip?: boolean | string; + todo?: boolean | string; + expectFailure?: boolean | string; + tags?: readonly string[]; + classname?: string; +} +export interface CoverageFile { + path: string; + functions: { name: string; line: number; count: number }[]; + branches: { line: number; count: number }[]; + lines: { line: number; count: number }[]; + totalFunctionCount: number; + coveredFunctionCount: number; + totalBranchCount: number; + coveredBranchCount: number; + totalLineCount: number; + coveredLineCount: number; +} +export interface TestSummary { + success: boolean; + counts: { + tests: number; + failed: number; + passed: number; + cancelled: number; + skipped: number; + todo: number; + suites: number; + topLevel: number; + }; + duration_ms: number; +} +export type TestEvent = + | { type: "test:start" | "test:enqueue" | "test:dequeue"; data: TestLocation } + | { type: "test:pass" | "test:fail" | "test:complete"; data: TestResult } + | { type: "test:plan"; data: { nesting: number; count: number } } + | { + type: "test:diagnostic" | "test:log" | "test:stdout" | "test:stderr"; + data: { nesting: number; message: string; data?: unknown }; + } + | { + type: "test:coverage"; + data: { nesting: number; summary: { workingDirectory: string; files: CoverageFile[] } }; + } + | { type: "test:summary"; data: TestSummary } + | { type: "test:interrupted"; data: { tests: TestLocation[] } } + | { type: "test:watch:drained" | "test:watch:restarted"; data?: undefined }; +export type TestEventSource = AsyncIterable | Iterable; +/** run() is unavailable in a component; this describes its Node-compatible return contract. */ +export interface TestsStream extends Readable { + [Symbol.asyncIterator](): AsyncIterableIterator; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/test.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/test.ts new file mode 100644 index 000000000..3cf317bb0 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/test.ts @@ -0,0 +1,59 @@ +import { execFile } from "node:child_process"; +import { createTestHarness } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/runner.js"; +import type { + TestEvent, + TestResult, + TestModule, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/types.js"; +export { createTestHarness }; +export function harness(only = false): { + test: TestModule; + events: TestEvent[]; + drain(): Promise; + results(): TestResult[]; +} { + const events: TestEvent[] = []; + const instance = createTestHarness({ + only, + report(event): void { + events.push(event); + }, + }); + return { + test: instance.module, + events, + drain: instance.drain, + results: (): TestResult[] => + events.flatMap((event) => + event.type === "test:pass" || event.type === "test:fail" ? [event.data] : [], + ), + }; +} +export async function oracle(source: string): Promise { + if (process.version !== "v24.20.0") { + throw new Error(`node:test oracle requires v24.20.0; got ${process.version}`); + } + const text = await new Promise((resolve, reject): void => { + const child = execFile( + process.execPath, + ["--input-type=module", "-e", source], + { encoding: "utf8" }, + (error, stdout): void => { + if (error) { + reject(error); + } else { + resolve(stdout); + } + }, + ); + child.stdin?.end(); + }); + const record = text.split("\n").find((line) => line.startsWith("RESULT:")); + if (!record) { + throw new Error(text); + } + return JSON.parse(record.slice(7)); +} +export function errorCode(error: unknown): unknown { + return error instanceof Error && "code" in error ? error.code : undefined; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/after-each.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/after-each.ts new file mode 100644 index 000000000..57ced3768 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/after-each.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("afterEach runs inner-to-outer, including failed tests", async () => { + const h = harness(); + const order: string[] = []; + h.test.afterEach((t): void => { + order.push(`outer:${t.passed}`); + }); + h.test.suite("suite", (): void => { + h.test.afterEach((t): void => { + order.push(`inner:${t.passed}`); + }); + h.test("one", (): never => { + throw new Error("failure"); + }); + h.test("two"); + }); + await h.drain(); + expect(order).toEqual(["inner:false", "outer:false", "inner:true", "outer:true"]); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/after.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/after.ts new file mode 100644 index 000000000..17c285878 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/after.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("after runs on success, failure and timeout, with mocks still installed", async () => { + const h = harness(); + const order: string[] = []; + const object = { value: (): number => 1 }; + h.test.after((): void => { + order.push("root"); + }); + await h.test("failure", (t): never => { + t.mock.method(object, "value", (): number => 2); + t.after((): void => { + order.push(`${t.passed}:${object.value()}`); + }); + throw new Error("failure"); + }); + await h.drain(); + expect(order).toEqual(["false:2", "root"]); + expect(object.value()).toBe(1); + const timed = harness(); + let cleanup = false; + await timed.test("timeout", { timeout: 2 }, async (t): Promise => { + t.after((): void => { + cleanup = true; + }); + await new Promise((): void => {}); + }); + await timed.drain(); + expect(cleanup).toBe(true); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/assert.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/assert.ts new file mode 100644 index 000000000..2345122a3 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/assert.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +import type { TestContext } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/context.js"; +test("context assertions reuse jco-std assertions and registered methods receive context", async () => { + const h = harness(); + let received: unknown; + h.test.assert.register("custom", function (this: TestContext, value: number): void { + received = [this.name, value]; + }); + await h.test("assertions", (t: TestContext): void => { + t.assert.deepStrictEqual({ a: [1] }, { a: [1] }); + t.assert.partialDeepStrictEqual({ a: 1, b: 2 }, { a: 1 }); + t.assert.throws((): never => { + throw new Error("expected"); + }, /expected/); + Reflect.apply(Reflect.get(t.assert, "custom"), t.assert, [42]); + }); + await h.drain(); + expect(received).toEqual(["assertions", 42]); + expect(h.results()[0].details.error).toBeUndefined(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/before-each.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/before-each.ts new file mode 100644 index 000000000..ee0a077bc --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/before-each.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("beforeEach inherits outer-to-inner and sees the test context", async () => { + const h = harness(); + const order: string[] = []; + h.test.beforeEach((t): void => { + order.push(`outer:${t.name}`); + }); + h.test.suite("suite", (): void => { + h.test.beforeEach((t): void => { + order.push(`inner:${t.name}`); + }); + h.test("one"); + h.test.skip("skip"); + h.test("two"); + }); + await h.drain(); + expect(order).toEqual(["outer:one", "inner:one", "outer:two", "inner:two"]); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/before.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/before.ts new file mode 100644 index 000000000..8aaf83843 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/before.ts @@ -0,0 +1,66 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("before hooks complete once before children and propagate failures", async () => { + const h = harness(); + const order: string[] = []; + h.test.before(async (): Promise => { + await Promise.resolve(); + order.push("root"); + }); + await h.test("parent", async (t): Promise => { + t.before((_ctx, done): void => { + order.push("before"); + done(); + }); + await t.test("one", (): void => { + order.push("one"); + }); + await t.test("two", (): void => { + order.push("two"); + }); + }); + await h.drain(); + expect(order).toEqual(["root", "before", "one", "two"]); + const broken = harness(); + broken.test.suite("broken", (): void => { + broken.test.before((): never => { + throw new Error("before"); + }); + broken.test("never", (): never => { + throw new Error("body"); + }); + }); + await broken.drain(); + expect(broken.results().at(-1)?.details.error?.failureType).toBe("hookFailed"); +}); +test("context.before starts synchronously and root failures apply to every test", async () => { + const h = harness(); + const order: string[] = []; + await h.test("before", (t): void => { + t.before((): void => { + order.push("before"); + }); + order.push("body"); + }); + await h.drain(); + expect(order).toEqual(["before", "body"]); + const broken = harness(); + let ran = false; + broken.test.before((): never => { + throw new Error("setup"); + }); + await Promise.all([ + broken.test("one", (): void => { + ran = true; + }), + broken.test("two", (): void => { + ran = true; + }), + ]); + await broken.drain(); + expect(ran).toBe(false); + expect(broken.results().map((r) => r.details.error?.failureType)).toEqual([ + "hookFailed", + "hookFailed", + ]); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/context.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/context.ts new file mode 100644 index 000000000..1fd021688 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/context.ts @@ -0,0 +1,33 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +import type { TestContext } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/context.js"; +test("context fields, diagnostics, logs, directives and end-of-test signal", async () => { + const h = harness(); + let context: TestContext | undefined; + await h.test("context", { tags: ["FAST"] }, (t): void => { + context = t; + expect(t.name).toBe("context"); + expect(t.fullName).toBe("context"); + expect(t.filePath).toBeUndefined(); + expect(t.workerId).toBeUndefined(); + expect(t.attempt).toBe(0); + expect(t.error).toBeNull(); + expect(t.passed).toBe(false); + expect(t.signal.aborted).toBe(false); + t.diagnostic("diagnostic"); + t.log("log", { key: 1 }); + t.skip("context skip"); + t.todo("context todo"); + }); + await h.drain(); + expect(context!.signal.aborted).toBe(true); + expect(h.results()[0]).toMatchObject({ + skip: "context skip", + todo: "context todo", + tags: ["fast"], + }); + expect(h.events).toContainEqual({ + type: "test:log", + data: { nesting: 0, message: "log", data: { key: 1 } }, + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/expect-failure.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/expect-failure.ts new file mode 100644 index 000000000..6e2265b48 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/expect-failure.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("expected failures match predicates and reject unexpected passes", async () => { + const h = harness(); + await h.test.expectFailure("expected", (): never => { + throw new Error("expected"); + }); + await h.test("match", { expectFailure: /match/ }, (): never => { + throw new Error("match"); + }); + await h.test("mismatch", { expectFailure: /different/ }, (): never => { + throw new Error("match"); + }); + await h.test.expectFailure("unexpected pass", (): void => {}); + await h.drain(); + expect(h.events.flatMap((e) => (e.type === "test:pass" ? [e.data.name] : []))).toEqual([ + "expected", + "match", + ]); + expect(h.results().at(-1)?.details.error?.failureType).toBe("expectedFailure"); + expect(h.results()[2].details.error?.message).toMatch(/did not match/); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/get-test-context.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/get-test-context.ts new file mode 100644 index 000000000..244e2b6ee --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/get-test-context.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("implicit context is scoped synchronously; explicit context survives awaits", async () => { + const h = harness(); + expect(h.test.getTestContext()).toBeUndefined(); + h.test.suite("suite", (s): void => { + expect(h.test.getTestContext()).toBe(s); + }); + await h.test("test", async (t): Promise => { + expect(h.test.getTestContext()).toBe(t); + await Promise.resolve(); + expect(h.test.getTestContext()).toBeUndefined(); + expect(() => h.test("implicit")).toThrow(/use t.test/); + await t.test("explicit", (child): void => { + expect(h.test.getTestContext()).toBe(child); + }); + }); + await h.drain(); + expect(h.test.getTestContext()).toBeUndefined(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-fn.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-fn.ts new file mode 100644 index 000000000..3f6b09e44 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-fn.ts @@ -0,0 +1,61 @@ +import { expect, test } from "vitest"; +import { mock as native } from "node:test"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("mock.fn preserves this, arguments, results, errors, and times like Node", () => { + const observations: unknown[] = []; + const original = function (this: { base: number }, a: number): number { + return this.base + a; + }; + const implementation = function (a: number): number { + if (a < 0) { + throw new Error("negative"); + } + return a * 2; + }; + const tracker = new MockTracker(); + const mocks = [ + tracker.fn(original, implementation, { times: 2 }), + native.fn(original, implementation, { times: 2 }), + ]; + for (const fn of mocks) { + const object = { base: 10, fn }; + expect(object.fn(2)).toBe(4); + expect(() => object.fn(-1)).toThrow("negative"); + expect(object.fn(2)).toBe(12); + observations.push( + fn.mock.calls.map((call) => ({ + args: call.arguments, + result: call.result, + error: call.error instanceof Error ? call.error.message : undefined, + receiver: call.this === object, + stack: call.stack instanceof Error, + target: call.target, + })), + ); + } + tracker.reset(); + native.reset(); + expect(observations[0]).toEqual(observations[1]); +}); +test("fn overloads and invalid options", () => { + const tracker = new MockTracker(); + expect(tracker.fn()()).toBeUndefined(); + expect(tracker.fn({ times: 1 })()).toBeUndefined(); + expect(tracker.fn((): number => 3, { times: 1 })()).toBe(3); + expect(() => tracker.fn({ times: 0 })).toThrow(/options.times/); +}); +test("constructor mocks retain constructibility, prototypes and typed results", () => { + class Value { + constructor(readonly value: number) {} + } + const tracker = new MockTracker(); + const MockValue = tracker.fn(Value); + const instance = new MockValue(7); + expect(instance).toBeInstanceOf(Value); + expect(instance.value).toBe(7); + const result: Value | undefined = MockValue.mock.calls[0].result; + expect(result).toBe(instance); + expect(MockValue.mock.calls[0].target).toBe(Value); + expect(MockValue.mock.calls[0].this).toBe(instance); + expect(MockValue.mock.calls[0].arguments).toEqual([7]); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-function-context.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-function-context.ts new file mode 100644 index 000000000..6547f6f91 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-function-context.ts @@ -0,0 +1,17 @@ +import { expect, test } from "vitest"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("one-shot implementations, call history copies, reset and restore", () => { + const tracker = new MockTracker(); + const fn = tracker.fn((): number => 1); + fn.mock.mockImplementation((): number => 2); + fn.mock.mockImplementationOnce((): number => 3, 1); + expect([fn(), fn(), fn()]).toEqual([2, 3, 2]); + const calls = fn.mock.calls; + calls.pop(); + expect(fn.mock.callCount()).toBe(3); + expect(() => fn.mock.mockImplementationOnce((): number => 4, 1)).toThrow(/onCall/); + fn.mock.resetCalls(); + expect(fn.mock.callCount()).toBe(0); + fn.mock.restore(); + expect(fn()).toBe(1); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-getter.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-getter.ts new file mode 100644 index 000000000..d11b65d3b --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-getter.ts @@ -0,0 +1,27 @@ +import { expect, test } from "vitest"; +import { mock as native } from "node:test"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("getter mocks preserve the setter and restore descriptors", () => { + for (const create of [ + (object: { value: number }) => new MockTracker().getter(object, "value", (): number => 7), + (object: { value: number }) => native.getter(object, "value", (): number => 7), + ]) { + let value = 1; + const object = { + get value(): number { + return value; + }, + set value(next: number) { + value = next; + }, + }; + const original = Object.getOwnPropertyDescriptor(object, "value"); + const fn = create(object); + object.value = 3; + expect(object.value).toBe(7); + expect(fn.mock.callCount()).toBe(1); + fn.mock.restore(); + expect(object.value).toBe(3); + expect(Object.getOwnPropertyDescriptor(object, "value")).toEqual(original); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-method.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-method.ts new file mode 100644 index 000000000..5471140c2 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-method.ts @@ -0,0 +1,27 @@ +import { expect, test } from "vitest"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("method spies preserve descriptors and restore inherited and symbol methods", () => { + const tracker = new MockTracker(); + const symbol = Symbol("method"); + class Base { + method(): number { + return 1; + } + [symbol](): number { + return 2; + } + } + const object = new Base(); + const original = object.method; + const fn = tracker.method(object, "method", (): number => 3); + expect(object.method()).toBe(3); + expect(fn.mock.callCount()).toBe(1); + tracker.method(object, symbol, (): number => 4); + expect(object[symbol]()).toBe(4); + tracker.restoreAll(); + expect(object.method).toBe(original); + expect(object[symbol]()).toBe(2); + expect(Object.getOwnPropertyDescriptor(object, "method")).toEqual( + Object.getOwnPropertyDescriptor(Base.prototype, "method"), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-module.ts new file mode 100644 index 000000000..790c19048 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-module.ts @@ -0,0 +1,15 @@ +import { expect, test } from "vitest"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("module mocking does not access loader options or deprecated exports", () => { + const tracker = new MockTracker(); + let touched = false; + expect(() => + tracker.module("node:fs", { + get namedExports(): object { + touched = true; + throw new Error("getter"); + }, + }), + ).toThrow(/runtime loader hooks/); + expect(touched).toBe(false); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-property-context.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-property-context.ts new file mode 100644 index 000000000..a7b09705c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-property-context.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("property contexts reset history, validate indices and reject readonly writes", () => { + const tracker = new MockTracker(); + const object = { value: 1 }; + const proxy = tracker.property(object, "value"); + expect(proxy.value).toBe(1); + const accesses = proxy.mock.accesses; + accesses.pop(); + expect(proxy.mock.accessCount()).toBe(1); + expect(() => proxy.mock.mockImplementationOnce(3, 0)).toThrow(/onAccess/); + proxy.mock.resetAccesses(); + expect(proxy.mock.accessCount()).toBe(0); + proxy.mock.restore(); + expect(object.value).toBe(1); + const readonly = Object.defineProperty({}, "value", { + configurable: true, + writable: false, + value: 1, + }) as { value: number }; + const frozen = tracker.property(readonly, "value"); + expect(() => frozen.mock.mockImplementation(2)).toThrow(/cannot be set/); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-property.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-property.ts new file mode 100644 index 000000000..be3f1f0d3 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-property.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +import { oracle } from "../helpers/test.js"; +test("property mock records accesses, one-shot undefined and restoration like Node", async () => { + const tracker = new MockTracker(); + const object = { value: 1 as number | undefined }; + const proxy = tracker.property(object, "value", 2); + const first = object.value; + object.value = 3; + proxy.mock.mockImplementationOnce(undefined); + const next = proxy.value; + const last = object.value; + const accesses = proxy.mock.accesses.map(({ type, value }) => ({ type, value })); + tracker.reset(); + const result = JSON.parse( + JSON.stringify({ first, next, last, accesses, restored: object.value }), + ); + expect(result).toEqual( + await oracle( + `import { mock } from 'node:test'; const object={value:1}; const proxy=mock.property(object,'value',2);const first=object.value;object.value=3;proxy.mock.mockImplementationOnce(undefined);const next=proxy.value;const last=object.value;const accesses=proxy.mock.accesses.map(({type,value})=>({type,value}));mock.reset();console.log('RESULT:'+JSON.stringify({first,next,last,accesses,restored:object.value}));`, + ), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-reset.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-reset.ts new file mode 100644 index 000000000..4ef1ddbd8 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-reset.ts @@ -0,0 +1,14 @@ +import { expect, test } from "vitest"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("reset restores mocks and forgets them, retaining their call history", () => { + const tracker = new MockTracker(); + const object = { fn: (): number => 1 }; + const fn = tracker.method(object, "fn", (): number => 2); + fn(); + tracker.reset(); + expect(object.fn()).toBe(1); + expect(fn.mock.callCount()).toBe(1); + object.fn = (): number => 3; + tracker.restoreAll(); + expect(object.fn()).toBe(3); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-restore-all.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-restore-all.ts new file mode 100644 index 000000000..cef83ceed --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-restore-all.ts @@ -0,0 +1,12 @@ +import { expect, test } from "vitest"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("restoreAll restores tracked mocks on repeat calls", () => { + const tracker = new MockTracker(); + const object = { fn: (): number => 1 }; + tracker.method(object, "fn", (): number => 2); + tracker.restoreAll(); + expect(object.fn()).toBe(1); + object.fn = (): number => 3; + tracker.restoreAll(); + expect(object.fn()).toBe(1); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-setter.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-setter.ts new file mode 100644 index 000000000..941051640 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-setter.ts @@ -0,0 +1,25 @@ +import { expect, test } from "vitest"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("setter mocks preserve getters and reject conflicting options", () => { + const tracker = new MockTracker(); + let value = 1; + const object = { + get value(): number { + return value; + }, + set value(next: number) { + value = next; + }, + }; + const fn = tracker.setter(object, "value", (next: number): void => { + value = next * 2; + }); + object.value = 3; + expect(object.value).toBe(6); + expect(fn.mock.calls[0].arguments).toEqual([3]); + tracker.reset(); + object.value = 4; + expect(object.value).toBe(4); + expect(() => tracker.setter(object, "value", { setter: false })).toThrow(/cannot be false/); + expect(() => tracker.setter(object, "value", { getter: true })).toThrow(/cannot be used/); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-timers.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-timers.ts new file mode 100644 index 000000000..788ebd4d1 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/mock-timers.ts @@ -0,0 +1,25 @@ +import { expect, test } from "vitest"; +import { MockTracker } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/mock.js"; +test("native timer mocks reject before reading options or changing globals", () => { + const tracker = new MockTracker(); + const originalDate = Date; + const originalTimeout = setTimeout; + let read = false; + expect(() => + tracker.timers.enable({ + get apis(): [] { + read = true; + throw new Error("getter"); + }, + }), + ).toThrow(/shared Node timer internals/); + expect(() => tracker.timers.tick()).toThrow(/not supported/); + expect(() => tracker.timers.runAll()).toThrow(/not supported/); + expect(() => tracker.timers.setTime(1)).toThrow(/not supported/); + tracker.timers.reset(); + tracker.timers[Symbol.dispose](); + expect(read).toBe(false); + expect(Date).toBe(originalDate); + expect(setTimeout).toBe(originalTimeout); + expect(() => Reflect.apply(tracker.timers.enable, tracker.timers, [[]])).toThrow(/deprecated/); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/module.ts new file mode 100644 index 000000000..661a66961 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/module.ts @@ -0,0 +1,42 @@ +import { expect, test } from "vitest"; +import * as native from "node:test"; +import * as shim from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/index.js"; +import * as nativeReporters from "node:test/reporters"; +import * as reporters from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/reporters.js"; + +test("complete named exports, callable aliases, getter namespaces, and descriptors", () => { + expect(Object.keys(shim).sort()).toEqual(Object.keys(native).sort()); + expect(Object.keys(shim.default)).toEqual(Object.keys(native.default)); + expect(shim.default).toBe(shim.test); + expect(shim.it).toBe(shim.test); + expect(shim.describe).toBe(shim.suite); + expect(Object.getPrototypeOf(shim.assert)).toBeNull(); + expect(Object.getPrototypeOf(shim.snapshot)).toBeNull(); + for (const key of Object.keys(native.default)) { + const expected = Object.getOwnPropertyDescriptor(native.default, key)!; + const actual = Object.getOwnPropertyDescriptor(shim.default, key)!; + expect([actual.configurable, actual.enumerable, actual.writable, typeof actual.get]).toEqual([ + expected.configurable, + expected.enumerable, + expected.writable, + typeof expected.get, + ]); + expect(Reflect.get(shim.default, key)).toBe(Reflect.get(shim, key)); + } + expect(shim.default.length).toBe(native.default.length); + expect(shim.default.name).toBe(native.default.name); + expect(Object.keys(reporters).sort()).toEqual(Object.keys(nativeReporters).sort()); + expect(Object.keys(reporters.default)).toEqual( + Object.keys(Reflect.get(nativeReporters, "default")), + ); + for (const key of Object.keys(Reflect.get(nativeReporters, "default"))) { + const expected = Object.getOwnPropertyDescriptor(Reflect.get(nativeReporters, "default"), key)!; + const actual = Object.getOwnPropertyDescriptor(reporters.default, key)!; + expect([actual.configurable, actual.enumerable, actual.writable, typeof actual.get]).toEqual([ + expected.configurable, + expected.enumerable, + expected.writable, + typeof expected.get, + ]); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/only.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/only.ts new file mode 100644 index 000000000..59d0d66d4 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/only.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("only selects tests in explicit only mode and otherwise emits a diagnostic", async () => { + const h = harness(true); + const calls: string[] = []; + await h.test("skip", (): void => { + calls.push("skip"); + }); + await h.test.only("run", (): void => { + calls.push("run"); + }); + await h.drain(); + expect(calls).toEqual(["run"]); + const normal = harness(); + await normal.test.only("warning"); + await normal.drain(); + expect( + normal.events.some( + (e) => e.type === "test:diagnostic" && e.data.message.includes("--test-only"), + ), + ).toBe(true); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/plan.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/plan.ts new file mode 100644 index 000000000..2d7c3a2bf --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/plan.ts @@ -0,0 +1,31 @@ +import type { TestContext } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/context.js"; +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("plans count assertions and subtests, detect mismatches and wait for scheduled assertions", async () => { + const h = harness(); + await h.test("planned", async (t: TestContext): Promise => { + t.plan(2); + t.assert.strictEqual(1, 1); + await t.test("child"); + }); + await h.test("missing", (t: TestContext): void => { + t.plan(1); + }); + await h.test("wait", (t: TestContext): void => { + t.plan(1, { wait: 50 }); + setTimeout((): void => { + t.assert.ok(true); + }, 1); + }); + await h.test("twice", (t: TestContext): void => { + t.plan(1); + t.plan(2); + }); + await h.drain(); + expect(h.results().find((r) => r.name === "planned")?.details.error).toBeUndefined(); + expect(h.results().find((r) => r.name === "missing")?.details.error?.message).toBe( + "plan expected 1 assertions but received 0", + ); + expect(h.results().find((r) => r.name === "wait")?.details.error).toBeUndefined(); + expect(h.results().at(-1)?.details.error?.message).toBe("cannot set plan more than once"); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-dot.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-dot.ts new file mode 100644 index 000000000..f7318b214 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-dot.ts @@ -0,0 +1,27 @@ +import { expect, test } from "vitest"; +import { dot } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/reporters.js"; +import { dot as native } from "node:test/reporters"; +test("dot matches Node's non-terminal line wrapping", async () => { + const events = Array.from({ length: 21 }, (_, index) => ({ + type: "test:pass" as const, + data: { + name: `test ${index}`, + nesting: 0, + testNumber: index + 1, + details: { type: "test" as const, duration_ms: 0 }, + }, + })); + const actual: string[] = []; + for await (const chunk of dot(events)) { + actual.push(chunk); + } + async function* source(): AsyncGenerator<(typeof events)[number], void, unknown> { + yield* events; + } + const expected: string[] = []; + for await (const chunk of native(source())) { + expected.push(chunk); + } + expect(actual.join("")).toBe("....................\n.\n"); + expect(actual).toEqual(expected); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-junit.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-junit.ts new file mode 100644 index 000000000..9ee036864 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-junit.ts @@ -0,0 +1,32 @@ +import { expect, test } from "vitest"; +import { junit } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/reporters.js"; +import { junit as native } from "node:test/reporters"; +import type { TestEvent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/types.js"; +test("JUnit matches Node for leaf tests and escapes XML attributes", async () => { + const events = [ + { type: "test:start", data: { name: 'one<&"', nesting: 0 } }, + { + type: "test:pass", + data: { + name: 'one<&"', + nesting: 0, + testNumber: 1, + details: { type: "test", duration_ms: 1 }, + skip: "later", + }, + }, + ] satisfies TestEvent[]; + async function* source(): AsyncGenerator<(typeof events)[number], void, unknown> { + yield* events; + } + let actual = ""; + for await (const chunk of junit(source())) { + actual += chunk; + } + let expected = ""; + for await (const chunk of native(source())) { + expected += chunk; + } + expect(actual).toEqual(expected); + expect(actual).toContain('name="one<&&quot;"'); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-lcov.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-lcov.ts new file mode 100644 index 000000000..71ab8408a --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-lcov.ts @@ -0,0 +1,41 @@ +import { expect, test } from "vitest"; +import { lcov } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/reporters.js"; +import { lcov as native } from "node:test/reporters"; +import { text } from "../../../../../../src/wasi/0.2.x/node/24.x.x/stream/consumers.js"; +import type { TestEvent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/types.js"; +test("LCOV uses shared path/streams and matches Node's coverage encoding", async () => { + const event: TestEvent = { + type: "test:coverage", + data: { + nesting: 0, + summary: { + workingDirectory: "/work", + files: [ + { + path: "/work/source.js", + functions: [{ name: "", line: 1, count: 1 }], + branches: [{ line: 2, count: 0 }], + lines: [ + { line: 2, count: 0 }, + { line: 1, count: 1 }, + ], + totalFunctionCount: 1, + coveredFunctionCount: 1, + totalBranchCount: 1, + coveredBranchCount: 0, + totalLineCount: 2, + coveredLineCount: 1, + }, + ], + }, + }, + }; + const reporter = new lcov(); + reporter.end(event); + const oracle = native(); + oracle.end(event); + const actual = await text(reporter); + expect(actual).toBe(await text(oracle)); + expect(actual).toContain("SF:source.js\n"); + expect(actual).toContain("DA:1,1\nDA:2,0\n"); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-spec.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-spec.ts new file mode 100644 index 000000000..8efbd7952 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; +import { spec } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/reporters.js"; +import { spec as native } from "node:test/reporters"; +import { text } from "../../../../../../src/wasi/0.2.x/node/24.x.x/stream/consumers.js"; +test("spec is callable and constructible and formats test results like Node", async () => { + const event = { + type: "test:pass", + data: { name: "one", nesting: 0, testNumber: 1, details: { type: "test", duration_ms: 2 } }, + }; + const reporter = spec(); + reporter.end(event); + const oracle = native(); + oracle.end(event); + expect(await text(reporter)).toBe(await text(oracle)); + const constructed = new spec(); + constructed.end(event); + expect(await text(constructed)).toBe("✔ one (2ms)\n"); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-tap.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-tap.ts new file mode 100644 index 000000000..42b910902 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/reporters-tap.ts @@ -0,0 +1,28 @@ +import { expect, test } from "vitest"; +import { tap } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/reporters.js"; +import type { TestEvent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/test/types.js"; +test("TAP escapes user-controlled names and reports failure diagnostics", async () => { + const events: TestEvent[] = [ + { type: "test:start", data: { name: "name\n# SKIP", nesting: 0 } }, + { + type: "test:pass", + data: { + name: "name\n# SKIP", + nesting: 0, + testNumber: 1, + skip: "reason", + details: { type: "test", duration_ms: 0 }, + }, + }, + { type: "test:plan", data: { nesting: 0, count: 1 } }, + ]; + let text = ""; + for await (const chunk of tap(events)) { + text += chunk; + } + expect(text).toContain("TAP version 13\n"); + expect(text).toContain("# SKIP reason"); + expect(text).toContain("1..1\n"); + expect(text).not.toContain("name\n# SKIP"); + expect(text).toContain('type: "test"'); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/run.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/run.ts new file mode 100644 index 000000000..d3d0c05ce --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/run.ts @@ -0,0 +1,15 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("run throws immediately without reading options or launching a host", () => { + const h = harness(); + let read = false; + expect(() => + h.test.run({ + get files(): string[] { + read = true; + throw new Error("getter"); + }, + }), + ).toThrow(/node:test run\(\).*not supported/); + expect(read).toBe(false); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/skip.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/skip.ts new file mode 100644 index 000000000..89222fadb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/skip.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("skip aliases prevent callbacks and preserve reasons", async () => { + const h = harness(); + let touched = false; + await h.test.skip("skipped", (): void => { + touched = true; + }); + await h.test("reason", { skip: "later" }, (): void => { + touched = true; + }); + await h.test.suite.skip("suite", (): void => { + touched = true; + }); + await h.drain(); + expect(touched).toBe(false); + expect(h.results().map((r) => r.skip)).toEqual([true, "later", true]); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/snapshot.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/snapshot.ts new file mode 100644 index 000000000..e37299044 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/snapshot.ts @@ -0,0 +1,32 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("snapshot APIs reject before serializers, path callbacks or values are accessed", async () => { + const h = harness(); + let touched = false; + expect(() => + h.test.snapshot.setDefaultSnapshotSerializers([ + (): string => { + touched = true; + return ""; + }, + ]), + ).toThrow(/not supported/); + expect(() => + h.test.snapshot.setResolveSnapshotPath((): string => { + touched = true; + return ""; + }), + ).toThrow(/not supported/); + await h.test("snapshots", (t): void => { + const value = { + get value(): never { + touched = true; + throw new Error("getter"); + }, + }; + expect(() => t.assert.snapshot(value)).toThrow(/not supported/); + expect(() => t.assert.fileSnapshot(value, "somewhere")).toThrow(/not supported/); + }); + await h.drain(); + expect(touched).toBe(false); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/suite.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/suite.ts new file mode 100644 index 000000000..5f75854a0 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/suite.ts @@ -0,0 +1,50 @@ +import { expect, test } from "vitest"; +import { harness, oracle } from "../helpers/test.js"; + +test("suites declare synchronously and run nested tests and hooks in Node order", async () => { + const h = harness(); + const order: string[] = []; + await h.test.suite("suite", (s): void => { + order.push(`build:${s.name}`); + h.test.before((): void => { + order.push("before"); + }); + h.test.after((): void => { + order.push("after"); + }); + h.test("one", (): void => { + order.push("one"); + }); + h.test.suite("inner", (): void => { + h.test("two", (): void => { + order.push("two"); + }); + }); + }); + await h.drain(); + expect(order).toEqual(["build:suite", "before", "one", "two", "after"]); + expect(order).toEqual( + await oracle( + `import {suite,test,before,after} from 'node:test'; const order=[]; suite('suite',s=>{order.push('build:'+s.name);before(()=>order.push('before'));after(()=>order.push('after'));test('one',()=>order.push('one'));suite('inner',()=>{test('two',()=>order.push('two'));});});process.on('beforeExit',()=>console.log('RESULT:'+JSON.stringify(order)));`, + ), + ); + expect(h.results().map((r) => [r.name, r.details.type])).toEqual([ + ["one", "test"], + ["two", "test"], + ["inner", "suite"], + ["suite", "suite"], + ]); +}); +test("async suite declarations fail explicitly and cancel declared children", async () => { + const h = harness(); + h.test.suite("async", async (): Promise => { + h.test("child", (): never => { + throw new Error("should be cancelled"); + }); + }); + await h.drain(); + expect(h.results().at(-1)?.details.error?.cause).toMatchObject({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + }); + expect(h.results()[0].details.error?.failureType).toBe("cancelledByParent"); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/test.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/test.ts new file mode 100644 index 000000000..47b8077eb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; + +test("sync, promise, thenable and callback tests settle with undefined", async () => { + const h = harness(); + const calls: string[] = []; + const pending = [ + h.test("sync", function (t): void { + expect(this).toBe(t); + calls.push(t.name); + }), + h.test("promise", async (t): Promise => { + await Promise.resolve(); + calls.push(t.name); + }), + h.test("callback", (t, done): void => { + Promise.resolve().then((): void => { + calls.push(t.name); + done(); + }); + }), + ]; + expect(await Promise.all(pending)).toEqual([undefined, undefined, undefined]); + await h.drain(); + expect(calls).toEqual(["sync", "promise", "callback"]); + expect(h.results().map((r) => r.details.error)).toEqual([undefined, undefined, undefined]); +}); +test("failure resolves test promises and is visible during cleanup", async () => { + const h = harness(); + const errors: unknown[] = []; + await h.test("throws", (t): void => { + t.after((): void => { + errors.push([t.passed, t.error?.cause]); + }); + throw new Error("boom"); + }); + await h.test("rejects", async (): Promise => { + throw "rejected"; + }); + await h.test("callback error", (_t, done): void => { + done("callback error"); + }); + await h.test("both", (_t, done): Promise => { + done(); + return Promise.resolve(); + }); + await h.drain(); + expect(errors).toEqual([[false, new Error("boom")]]); + expect(h.results().map((r) => r.details.error?.failureType)).toEqual([ + "testCodeFailure", + "testCodeFailure", + "testCodeFailure", + "callbackAndPromisePresent", + ]); +}); +test("overloads, nested explicit contexts across await, and parent failures", async () => { + const h = harness(); + await h.test(function named(): void {}); + await h.test({ tags: ["FAST", "fast"] }, function options(): void {}); + await h.test("parent", async (t): Promise => { + await Promise.resolve(); + await t.test("nested", (child): void => { + expect(child.fullName).toBe("parent > nested"); + throw new Error("child"); + }); + }); + await h.drain(); + expect(h.results().map((r) => r.name)).toEqual(["named", "options", "nested", "parent"]); + expect(h.results()[1].tags).toEqual(["fast"]); + expect(h.results().at(-1)?.details.error?.failureType).toBe("subtestsFailed"); +}); +test("abort, timeout, invalid options and concurrent execution rejection", async () => { + const h = harness(); + expect(() => h.test("bad", { timeout: -1 })).toThrow(/options.timeout/); + expect(() => h.test("bad", { concurrency: 0 })).toThrow(/options.concurrency/); + expect(() => h.test("bad", { concurrency: true })).toThrow(/concurrent tests/); + expect(() => h.test("bad", { tags: [""] })).toThrow(/options.tags/); + const controller = new AbortController(); + controller.abort("stop"); + await h.test("aborted", { signal: controller.signal }, (): never => { + throw new Error("must not run"); + }); + await h.test("timeout", { timeout: 5 }, async (): Promise => new Promise((): void => {})); + await h.drain(); + expect(h.results().map((r) => r.details.error?.failureType)).toEqual([ + "testAborted", + "testTimeoutFailure", + ]); +}); +test("timeout cancels nested tests and hooks once, before the parent reports", async () => { + const h = harness(); + await h.test("parent", { timeout: 5 }, async (t): Promise => { + t.beforeEach(async (): Promise => new Promise((): void => {})); + await t.test("child", (): never => { + throw new Error("must not run"); + }); + }); + await h.drain(); + expect(h.results().map((r) => r.name)).toEqual(["child", "parent"]); + expect(h.results().every((r) => r.details.error)).toBe(true); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/todo.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/todo.ts new file mode 100644 index 000000000..0eed4ef97 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/todo.ts @@ -0,0 +1,14 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("todo executes bodies while excluding their failures from the summary", async () => { + const h = harness(); + let touched = false; + await h.test.todo("todo", (): void => { + touched = true; + throw new Error("later"); + }); + await h.drain(); + expect(touched).toBe(true); + expect(h.results()[0].todo).toBe(true); + expect(h.events.at(-1)).toMatchObject({ type: "test:summary", data: { success: true } }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/wait-for.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/wait-for.ts new file mode 100644 index 000000000..f6823e8ea --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/test/wait-for.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; +import { harness } from "../helpers/test.js"; +test("waitFor retries thrown/rejected conditions and resolves even falsy values", async () => { + const h = harness(); + await h.test("waitFor", async (t): Promise => { + let count = 0; + expect( + await t.waitFor( + (): number => { + if (++count < 3) { + throw new Error("retry"); + } + return 0; + }, + { interval: 1, timeout: 100 }, + ), + ).toBe(0); + expect(count).toBe(3); + await expect( + t.waitFor( + (): never => { + throw new Error("cause"); + }, + { interval: 1, timeout: 5 }, + ), + ).rejects.toMatchObject({ message: "waitFor() timed out", cause: new Error("cause") }); + expect(() => t.waitFor((): void => {}, { interval: -1 })).toThrow(/options.interval/); + }); + await h.drain(); + expect(h.results()[0].details.error).toBeUndefined(); +}); diff --git a/packages/jco/src/node-builtins/index.ts b/packages/jco/src/node-builtins/index.ts index c8fa32eb8..311ade4cf 100644 --- a/packages/jco/src/node-builtins/index.ts +++ b/packages/jco/src/node-builtins/index.ts @@ -13,6 +13,7 @@ import { createAsyncHooksBuiltin } from "./async-hooks.js"; import { createEventsBuiltin } from "./events.js"; import { createProcessBuiltin } from "./process.js"; import { createOsBuiltin } from "./os.js"; +import { createTestBuiltin } from "./test.js"; import { createSqliteBuiltin } from "./sqlite.js"; import { createReadlineBuiltin } from "./readline.js"; import { createReplBuiltin } from "./repl.js"; @@ -70,6 +71,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui createSqliteBuiltin, createReadlineBuiltin, createReplBuiltin, + createTestBuiltin, createStringDecoderBuiltin, createTtyBuiltin, createStreamBuiltin, diff --git a/packages/jco/src/node-builtins/test.ts b/packages/jco/src/node-builtins/test.ts new file mode 100644 index 000000000..3889d895b --- /dev/null +++ b/packages/jco/src/node-builtins/test.ts @@ -0,0 +1,10 @@ +import { type BuiltinContext, type BuiltinAdapter, builtin, starReexportAdapter, stdModule } from "./shared.js"; + +/** The component test runner and reporters require no additional WIT capabilities. */ +export function createTestBuiltin({ options }: BuiltinContext): BuiltinAdapter { + return builtin(["node:test", "node:test/reporters"], (specifier) => + specifier === "node:test" + ? starReexportAdapter(stdModule(options.testModule, "test"), "test") + : starReexportAdapter(stdModule(options.testReportersModule, "test/reporters"), "reporters"), + ); +} diff --git a/packages/jco/src/node-builtins/types.ts b/packages/jco/src/node-builtins/types.ts index cff259285..f932c7175 100644 --- a/packages/jco/src/node-builtins/types.ts +++ b/packages/jco/src/node-builtins/types.ts @@ -58,6 +58,9 @@ export interface NodeBuiltinOptions { osModule?: string; /** Path to the versioned node:sqlite guest module. */ sqliteModule?: string; + /** Paths to the versioned test runner and reporters (overridable for tests). */ + testModule?: string; + testReportersModule?: string; /** Override the lazy node:process facade for integration tests. */ processModule?: string; /** Path to jco-std's versioned `node:string_decoder` module (overridable for tests) */ diff --git a/packages/jco/test/fixtures/componentize/node-test/source.js b/packages/jco/test/fixtures/componentize/node-test/source.js new file mode 100644 index 000000000..f259e7efc --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-test/source.js @@ -0,0 +1,119 @@ +import test, { + suite, + it, + before, + after, + beforeEach, + afterEach, + mock, + run as runTests, + getTestContext, +} from "node:test"; +import * as namespace from "node:test"; +import reporters, { dot, tap, junit, spec, lcov } from "node:test/reporters"; +import assert from "node:assert/strict"; + +const report = { + identity: + test === it && + test === namespace.test && + suite === namespace.describe && + test.mock === mock && + reporters.tap === tap && + reporters.spec === spec && + reporters.lcov === lcov, + lifecycle: [], + passed: [], + errors: [], +}; +const object = { value: () => 1 }; +if (typeof AbortController === "function") { + suite("suite", (s) => { + report.suiteName = s.name; + before(() => report.lifecycle.push("before")); + after(() => report.lifecycle.push("after")); + beforeEach((t) => report.lifecycle.push(`beforeEach:${t.name}`)); + afterEach((t) => { + report.lifecycle.push(`afterEach:${t.name}`); + report.passed.push([t.name, t.passed]); + }); + test("sync", (t) => { + t.plan(4); + t.assert.strictEqual(1 + 1, 2); + t.assert.deepStrictEqual({ list: [1] }, { list: [1] }); + t.assert.strictEqual(getTestContext(), t); + t.mock.method(object, "value", () => 3); + t.assert.strictEqual(object.value(), 3); + }); + it("async", async (t) => { + await Promise.resolve(); + assert.equal(object.value(), 1); + await t.test("nested", (child) => { + assert.equal(child.fullName, "suite > async > nested"); + const fn = child.mock.fn((a, b) => a + b); + assert.equal(fn(2, 3), 5); + report.mockArgs = fn.mock.calls[0].arguments; + }); + }); + test("callback", (_t, done) => { + done(); + }); + test.skip("skipped", () => { + throw new Error("must not execute"); + }); + test.expectFailure("failure", () => { + throw new Error("expected"); + }); + }); + // A following top-level test waits for the preceding suite in the serial queue. + await test("sentinel", (t) => { + t.after(() => { + report.sentinel = t.passed; + }); + for (const operation of [ + () => runTests(), + () => mock.module("node:fs"), + () => mock.timers.enable(), + () => t.assert.snapshot({}), + ]) { + try { + operation(); + } catch (error) { + report.errors.push(error.code); + } + } + }); +} else { + try { + test("unsupported engine"); + } catch (error) { + report.runner = error.code; + } +} +const property = mock.property({ value: 1 }, "value", 2); +report.property = property.value; +property.mock.restore(); +report.restored = property.value; +mock.reset(); +const events = [ + { + type: "test:pass", + data: { name: "reporter", nesting: 0, testNumber: 1, details: { type: "test", duration_ms: 0 } }, + }, +]; +let dots = "", + taps = "", + xml = ""; +for await (const chunk of dot(events)) { + dots += chunk; +} +for await (const chunk of tap(events)) { + taps += chunk; +} +for await (const chunk of junit(events)) { + xml += chunk; +} +report.reporters = dots === ".\n" && taps.includes("ok 1 - reporter") && xml.includes("testcase"); +export function run() { + return JSON.stringify(report); +} diff --git a/packages/jco/test/fixtures/componentize/node-test/source.wit b/packages/jco/test/fixtures/componentize/node-test/source.wit new file mode 100644 index 000000000..84fdfd442 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-test/source.wit @@ -0,0 +1,2 @@ +package test:node-test; +world test { export run: func() -> string; } diff --git a/packages/jco/test/node/test.js b/packages/jco/test/node/test.js new file mode 100644 index 000000000..a25325262 --- /dev/null +++ b/packages/jco/test/node/test.js @@ -0,0 +1,111 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test, vi } from "vitest"; +import { bundleComponentSource } from "../../src/bundle.js"; +import { nodeBuiltinPlugin } from "../../src/node-builtins/index.js"; +import { COMPONENT_JS_FIXTURES_DIR } from "../common.js"; +import { exec, getTmpDir, jcoPath, transpileComponent } from "../helpers.js"; + +const fixtureDir = join(COMPONENT_JS_FIXTURES_DIR, "node-test"); +const std = (path) => fileURLToPath(new URL(`../../../jco-std/dist/wasi/0.2.x/node/24.x.x/${path}`, import.meta.url)); +// The installed package predates these APIs; use local builds for the shared +// stream dependencies as well as the new test modules. +const overrides = { + testModule: std("test/index.js"), + testReportersModule: std("test/reporters.js"), + assertModule: std("assert/index.js"), + streamModule: std("stream/index.js"), + streamPromisesModule: std("stream/promises.js"), + streamSchedulerModule: std("stream/scheduler.js"), + streamEmitterModule: std("stream/emitter.js"), + stringDecoderModule: std("string-decoder.js"), + eventsModule: std("events.js"), +}; + +test("test adapters resolve lazily without WIT capabilities and leave bare imports alone", () => { + const onWitRequirement = vi.fn(); + const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { ...overrides, onWitRequirement }); + for (const name of ["node:test", "node:test/reporters"]) { + expect(plugin.resolveId(name)).toBe(`\0jco-node-builtin:${name}`); + } + for (const name of ["test", "test/reporters", "node:test/unknown"]) { + expect(plugin.resolveId(name)).toBeNull(); + } + expect(onWitRequirement).not.toHaveBeenCalled(); +}); + +test.each(["starlingmonkey", "quickjs"])( + "runs ordinary node:test imports in %s", + async (backend) => { + const outputDir = await getTmpDir(); + const entry = join(outputDir, "source.js"); + const componentPath = join(outputDir, "component.wasm"); + const source = await bundleComponentSource(join(fixtureDir, "source.js"), { + plugins: [nodeBuiltinPlugin({ imports: [], exports: [] }, overrides)], + }); + await writeFile(entry, source); + await exec( + jcoPath, + "componentize", + entry, + "--backend", + backend, + "-w", + join(fixtureDir, "source.wit"), + "-n", + "test", + "-o", + componentPath, + { closeStdin: true }, + ); + const { modulePath } = await transpileComponent({ componentPath, name: "node-test" }); + const component = await import(modulePath); + const result = JSON.parse(component.run()); + if (backend === "quickjs") { + expect(result).toEqual({ + identity: true, + lifecycle: [], + passed: [], + errors: [], + runner: "ERR_JCO_UNSUPPORTED_NODE_API", + property: 2, + restored: 1, + reporters: true, + }); + return; + } + expect(result).toEqual({ + identity: true, + suiteName: "suite", + sentinel: true, + lifecycle: [ + "before", + "beforeEach:sync", + "afterEach:sync", + "beforeEach:async", + "beforeEach:nested", + "afterEach:nested", + "afterEach:async", + "beforeEach:callback", + "afterEach:callback", + "beforeEach:failure", + "afterEach:failure", + "after", + ], + passed: [ + ["sync", true], + ["nested", true], + ["async", true], + ["callback", true], + ["failure", true], + ], + errors: Array(4).fill("ERR_JCO_UNSUPPORTED_NODE_API"), + mockArgs: [2, 3], + property: 2, + restored: 1, + reporters: true, + }); + }, + 600_000, +);