Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]()
Expand Down
9 changes: 9 additions & 0 deletions docs/src/interop/jco-std.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
88 changes: 88 additions & 0 deletions docs/src/interop/nodejs-builtins/supported-modules/test.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions packages/jco-std/LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions packages/jco-std/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
102 changes: 102 additions & 0 deletions packages/jco-std/src/wasi/0.2.x/node/24.x.x/test/assert.ts
Original file line number Diff line number Diff line change
@@ -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<Assert, (typeof methods)[number]> {
// 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<string, AssertionFunction> = 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;
}
Loading
Loading