Add t.assert.snapshot and t.assert.fileSnapshot

node:test changes, matching Node 24.22:

- t.assert.snapshot(value[, options]) serializes the value with Node's
  default serializer, JSON.stringify(value, null, 2), or with an
  options.serializers array literal applied in order, and compares it
  with assert.strictEqual against the snapshot stored under
  "<test full name> <n>" in "<entry file>.snapshot".
- t.assert.fileSnapshot(value, path[, options]) compares the serialized
  value with the contents of path.
- Snapshot files are read in the form Node writes them, exports[`key`] =
  `value`; statements, with template escapes and CRLF line endings
  cooked as in a template literal.
- Missing snapshots, unreadable snapshot files and throwing serializers
  throw Node's ERR_INVALID_STATE errors, with Node's messages.
- Running the binary with Node's --test-update-snapshots flag as its
  first argument records the values and writes the snapshot files after
  the last test, keys sorted as Node sorts them. The flag is kept out of
  process.argv and appears in process.execArgv, which is otherwise [].
- Snapshot assertions count toward the test's plan.

The snapshot fixture compares the reporter output with Node, plain and
with SCRIPTC_SAN=1, against a snapshot file Node wrote. The update-mode
harness test runs Node and the binary with --test-update-snapshots and
compares the files they write. The node:test normalization also drops
the "... N lines matching cause stack trace ..." line Node prints for an
error cause, with the stack frames it summarizes.
This commit is contained in:
Chris Tate
2026-10-18 05:31:40 -05:00
parent 0df712eb7c
commit 89b5baedd7
30 changed files with 1160 additions and 50 deletions
@@ -1 +1,2 @@
- **Concurrent tests:** with `{ concurrency }` above 1, subtests run on their own fibers, and results are reported in declaration order, as in Node. Concurrent bodies resume in Node's order around `beforeEach` hooks and nested suites. Other runner steps, such as `afterEach` and `after` hooks, take no microtask turns between bodies, so code that observes the interleaving of concurrent bodies across those steps can see a different order than Node.
- **Snapshots:** `t.assert.snapshot` and `t.assert.fileSnapshot` compile, with Node's default serializer, `JSON.stringify(value, null, 2)`, or an `options.serializers` array literal. A compiled test reads the snapshot file Node writes beside the entry file, `<entry file>.snapshot`, at the entry file's path when the program was compiled. Running the binary with Node's flag `--test-update-snapshots` as its first argument writes the snapshot files instead; as in Node, the flag is kept out of `process.argv` and appears in `process.execArgv`. Snapshot files are read in the form Node writes them, `` exports[`key`] = `value`; `` statements. The `snapshot.setDefaultSnapshotSerializers` and `snapshot.setResolveSnapshotPath` module functions throw `SC2020`.
@@ -1,4 +1,4 @@
`process.argv[0]` is `"scriptc"`, `process.argv[1]` is the binary's path, and program arguments begin at index 2. `process.version` and `process.versions.node` report the Node.js release the runtime is verified against.
`process.argv[0]` is `"scriptc"`, `process.argv[1]` is the binary's path, and program arguments begin at index 2. In programs that use `node:test`, a leading `--test-update-snapshots` argument is Node's snapshot update flag: it is kept out of `process.argv` and appears in `process.execArgv`, which is otherwise `[]`. `process.version` and `process.versions.node` report the Node.js release the runtime is verified against.
Uncaught exceptions print `Uncaught <value>` to stderr instead of Node.js's source excerpt and stack-trace block; thrown strings and numbers print their value. Exit codes and output written before the exception are preserved. Runtime errors expose `message` and Node's `code`, but not `errno`, `syscall`, or `path`.
@@ -2,6 +2,6 @@
"schemaVersion": 3,
"nodeVersion": "24.22.0",
"nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93",
"artifactVersion": "823524db8f12948bdc73",
"artifactVersion": "56f660f5e2eb7391a048",
"rowCount": 3880
}
@@ -689,9 +689,9 @@
"apiEntries": 117,
"static": {
"supported": 0,
"partial": 51,
"partial": 53,
"refused": 0,
"not-implemented": 66,
"not-implemented": 64,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -66401,9 +66401,9 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"detail": "No static compiler lowering is registered for this API yet.",
"verification": "registry-gap"
"status": "partial",
"detail": "Implemented for the documented scriptc module-loader subset.",
"verification": "test-backed"
}
},
{
@@ -66424,9 +66424,9 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"detail": "No static compiler lowering is registered for this API yet.",
"verification": "registry-gap"
"status": "partial",
"detail": "Implemented for the documented scriptc module-loader subset.",
"verification": "test-backed"
}
},
{
@@ -7,8 +7,8 @@
"tierItemCount": 3456,
"tiers": {
"static": {
"audit-partial": 1140,
"verify-gap": 2189,
"audit-partial": 1142,
"verify-gap": 2187,
"replace-refusal": 40,
"classify": 2,
"implement": 85
@@ -67667,12 +67667,15 @@
"priority": "high",
"tiers": {
"static": {
"status": "not-implemented",
"action": "verify-gap",
"verification": "registry-gap",
"confidence": "low",
"source": "compiler-unmatched:test",
"tests": []
"status": "partial",
"action": "audit-partial",
"verification": "test-backed",
"confidence": "high",
"source": "compiler-feature:test.context.assert.fileSnapshot",
"tests": [
"tests/fixtures/node-test/cases/snapshot.ts",
"tests/harness/node-test.test.ts"
]
}
}
},
@@ -67692,12 +67695,15 @@
"priority": "high",
"tiers": {
"static": {
"status": "not-implemented",
"action": "verify-gap",
"verification": "registry-gap",
"confidence": "low",
"source": "compiler-unmatched:test",
"tests": []
"status": "partial",
"action": "audit-partial",
"verification": "test-backed",
"confidence": "high",
"source": "compiler-feature:test.context.assert.snapshot",
"tests": [
"tests/fixtures/node-test/cases/snapshot.ts",
"tests/harness/node-test.test.ts"
]
}
}
},
@@ -746,9 +746,9 @@
"apiEntries": 117,
"static": {
"supported": 0,
"partial": 51,
"partial": 53,
"refused": 0,
"not-implemented": 66,
"not-implemented": 64,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -90951,6 +90951,7 @@
"tests/fixtures/node-test/cases/mock-timers-date.ts",
"tests/fixtures/node-test/cases/mock-timers.ts",
"tests/fixtures/node-test/cases/only-nested.ts",
"tests/fixtures/node-test/cases/snapshot.ts",
"tests/fixtures/node-test/cases/suite-variants.ts",
"tests/fixtures/node-test/cases/todo-suites.ts",
"tests/harness/node-test.test.ts"
@@ -93602,8 +93603,12 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:test"
"status": "partial",
"evidence": "compiler-feature:test.context.assert.fileSnapshot",
"tests": [
"tests/fixtures/node-test/cases/snapshot.ts",
"tests/harness/node-test.test.ts"
]
},
"anchorSource": "exact"
},
@@ -93625,8 +93630,12 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:test"
"status": "partial",
"evidence": "compiler-feature:test.context.assert.snapshot",
"tests": [
"tests/fixtures/node-test/cases/snapshot.ts",
"tests/harness/node-test.test.ts"
]
},
"anchorSource": "exact"
},
@@ -354,6 +354,7 @@
{ "chapter": "url", "parent": "Class: `URL`", "signatures": ["`href` Type: {string}", "`pathname` Type: {string}", "`search` Type: {string}", "`hash` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/node24-url-mutation.ts", "tests/corpus/node24-url-mutation-coercion.js", "tests/fixtures/node-types/url-mutation.ts"] },
{ "chapter": "url", "signatures": ["`port` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/2946-url-port-hash.ts"] },
{ "chapter": "url", "signatures": ["Legacy `urlObject`", "`urlObject.auth`", "`urlObject.hash`", "`urlObject.host`", "`urlObject.hostname`", "`urlObject.href`", "`urlObject.path`", "`urlObject.pathname`", "`urlObject.port`", "`urlObject.protocol`", "`urlObject.query`", "`urlObject.search`", "`urlObject.slashes`"], "status": "partial", "evidence": ["tests/corpus/5221-url-parse-shapes.ts", "tests/corpus/5222-url-parse-hosts.ts", "tests/corpus/5223-url-parse-query.ts", "tests/corpus/5224-url-format-objects.ts", "tests/fixtures/node-types/url-legacy.ts"] },
{ "chapter": "test", "parent": "`context.assert`", "symbols": ["context.assert.snapshot", "context.assert.fileSnapshot"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/snapshot.ts", "tests/harness/node-test.test.ts"] },
{ "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.diagnostic", "context.name"], "status": "partial", "evidence": ["tests/harness/node-test.test.ts"] },
{ "chapter": "test", "parent": "Class: `MockTracker`", "symbols": ["mock.fn"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-fn.ts", "tests/harness/node-test.test.ts"] },
{ "chapter": "test", "parent": "Class: `MockTracker`", "symbols": ["mock.method", "mock.reset", "mock.restoreAll"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-method.ts", "tests/corpus/5810-mock-method-records.ts", "tests/corpus/5811-mock-tracker-restore.ts", "tests/harness/node-test.test.ts"] },
+5
View File
@@ -3338,7 +3338,12 @@ declare module "node:test" {
beforeEach(fn: HookFn): void;
afterEach(fn: HookFn): void;
}
interface AssertSnapshotOptions {
readonly serializers?: ReadonlyArray<(value: any) => any> | undefined;
}
interface TestContextAssert {
snapshot(value: any, options?: AssertSnapshotOptions): void;
fileSnapshot(value: any, path: string, options?: AssertSnapshotOptions): void;
ok(value: unknown, message?: string): void;
strictEqual(actual: unknown, expected: unknown, message?: string): void;
notStrictEqual(actual: unknown, expected: unknown, message?: string): void;
@@ -2066,7 +2066,10 @@ export class LlEmitter {
if (usesHttp) this.declare(`declare void @scr_http_dyn_install()`);
if (usesFetch) this.declare(`declare void @scr_fetch_install()`);
if (usesNodeTest) this.declare(`declare i32 @scr_test_exit_code()`);
if (usesNodeTest) {
this.declare(`declare i32 @scr_test_exit_code()`);
this.declare(`declare void @scr_test_init()`);
}
if (snapshotsTlsCa) {
this.declare(`declare void @scr_tls_ca_install()`);
}
@@ -2666,6 +2669,8 @@ export class LlEmitter {
...(usesHttp2 ? [` call void @scr_http2_dyn_install()`] : []),
...(usesStream ? [` call void @scr_stream_install()`] : []),
` call void @scr_lib_init(i32 %argc, ptr %argv)`,
// node:test takes Node's --test-update-snapshots out of argv first.
...(usesNodeTest ? [` call void @scr_test_init()`] : []),
...(asyncEntry
? [` %top = call ptr @${mangleAsyncSpawn(this.mod.entry)}()`]
: [` call void @${mangleFunction(this.mod.entry)}()`]),
@@ -200,6 +200,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"os.userHomedir": "scr_os_user_homedir",
"os.tmpdir": "scr_os_tmpdir",
"process.argv": "scr_process_argv",
"process.execArgv": "scr_process_exec_argv",
"process.platform": "scr_process_platform",
"process.cwd": "scr_process_cwd",
"process.pid": "scr_process_pid",
@@ -66,6 +66,14 @@ export function emitTestLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVal
return finishRuntimeCall(host, e, "scr_test_ctx_mock_restore_all", a);
case "test.ctxMockReset":
return finishRuntimeCall(host, e, "scr_test_ctx_mock_reset", a);
case "test.snapshotValue":
return finishRuntimeCall(host, e, "scr_test_snapshot_value", a);
case "test.ctxSnapshot":
return finishRuntimeCall(host, e, "scr_test_ctx_snapshot", a);
case "test.fileSnapshot":
return finishRuntimeCall(host, e, "scr_test_file_snapshot", a);
case "test.snapshotSerialize":
return finishRuntimeCall(host, e, "scr_test_snapshot_serialize", a);
case "test.mockTimersEnable":
return finishRuntimeCall(host, e, "scr_test_mock_timers_enable", a);
case "test.mockTimersTick":
@@ -106,6 +106,7 @@ const INERT_LIB_CALLS = new Set([
"regex.groupParseInt",
"process.cwd",
"process.argv",
"process.execArgv",
"process.platform",
"process.execPath",
"process.isTTY",
@@ -362,10 +362,10 @@ export function lowerProcessProperty(
if (member === "connected") {
return { kind: "libCall", fn: "process.connected", args: [], type: BOOL, loc };
}
// process.execArgv: the extra CLI arguments Node itself consumed — a
// compiled binary consumed none, so the honest answer is a fresh [].
// process.execArgv: the Node runtime flags kept out of process.argv —
// a leading --test-update-snapshots in node:test programs, else [].
if (member === "execArgv") {
return { kind: "arrayLit", elems: [], type: arrayOf(STRING), loc };
return { kind: "libCall", fn: "process.execArgv", args: [], type: arrayOf(STRING), loc };
}
// process._exiting — the runtime's exit-sequence flag (true while
// 'exit' listeners run), Node's own undocumented member.
@@ -21,12 +21,14 @@ import {
type IrStmt,
type IrType,
type SrcLoc,
BOOL,
DYN,
F64,
STRING,
VOID,
isUnitType,
} from "../../ir/ir.js";
import { numLit, strLit } from "../../ir/build.js";
import { dynUndefinedExpr, numLit, strLit, varRef } from "../../ir/build.js";
import {
lowerMockContextCall,
lowerMockContextProperty,
@@ -518,7 +520,11 @@ export function lowerNodeTestModuleCall(
);
break;
case "snapshot":
lowerer.noLowering("test.snapshot", expr, "snapshot testing has no lowering");
lowerer.noLowering(
"test.snapshot",
expr,
"t.assert.snapshot and t.assert.fileSnapshot are the supported snapshot surface",
);
break;
default:
lowerer.noLowering(
@@ -557,6 +563,136 @@ export function lowerTestDirectCall(
return lowerRegistration(lowerer, expr, "test(...)", loc, 0, null);
}
/** t.assert.snapshot(value[, options]) and t.assert.fileSnapshot(value,
* path[, options]), Node's SnapshotManager assertions: the value runs
* through options.serializers, by default Node's JSON.stringify(value,
* null, 2), and String() of the result is compared with
* assert.strictEqual against the snapshot that scr_test.c reads from
* "<entry file>.snapshot" or `path`, or records in update mode. A throw
* while serializing becomes Node's ERR_INVALID_STATE. */
function lowerSnapshotAssert(
lowerer: Lowerer,
call: ts.CallExpression,
member: "snapshot" | "fileSnapshot",
testCtx: ts.Expression,
): IrExpr {
const loc = locOf(call);
const what = `t.assert.${member}`;
const t = lowerer.lowerExpr(testCtx);
const stmts: IrStmt[] = [];
const slot = (name: string, type: IrType, init: IrExpr): IrExpr => {
const local = lowerer.declareHiddenLocal(name, type);
stmts.push({ kind: "varDecl", localId: local.id, init, loc });
return varRef(local.id, type, loc);
};
// Arguments evaluate in order before the assertion serializes, as in Node.
const valueNode = call.arguments[0];
const input = slot(
"%snapshotInput",
DYN,
valueNode ? lowerer.lowerExprExpecting(valueNode, DYN) : dynUndefinedExpr(loc),
);
let path: IrExpr | null = null;
if (member === "fileSnapshot") {
const pathNode = call.arguments[1];
path = slot(
"%snapshotPath",
STRING,
pathNode ? lowerer.lowerExprExpecting(pathNode, STRING) : strLit("", loc),
);
}
const serializers =
snapshotSerializers(lowerer, call.arguments[member === "snapshot" ? 1 : 2], what)?.map((fn) =>
slot("%snapshotSerializer", DYN, fn),
) ?? null;
if (call.arguments.length > (member === "snapshot" ? 2 : 3)) {
lowerer.noLowering(`${what} with ${call.arguments.length} arguments`, call);
}
const text: IrExpr = {
kind: "toString",
operand: {
kind: "libCall",
fn: "test.snapshotSerialize",
args: [
input,
serializers === null
? dynUndefinedExpr(loc)
: { kind: "dynArrLit", elems: serializers, type: DYN, loc },
],
type: DYN,
loc,
},
type: STRING,
loc,
};
const actual = slot(
"%snapshotActual",
STRING,
member === "snapshot"
? { kind: "libCall", fn: "test.snapshotValue", args: [text], type: STRING, loc }
: text,
);
const expected: IrExpr =
member === "snapshot"
? {
kind: "libCall",
fn: "test.ctxSnapshot",
args: [t, actual, strLit(`${lowerer.entry.fileName}.snapshot`, loc)],
type: STRING,
loc,
}
: { kind: "libCall", fn: "test.fileSnapshot", args: [actual, path!], type: STRING, loc };
const no: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
return {
kind: "seqExpr",
stmts,
result: {
kind: "libCall",
fn: "assert.eqStr",
args: [actual, expected, no, no, strLit("", loc), no],
type: VOID,
loc,
},
type: VOID,
loc,
};
}
/** The serializer functions of a snapshot options object literal, as
* checked-dynamic values in order; null without a serializers option.
* Options that are not an object literal, a serializers list that is not
* an array literal, and other options fence. */
function snapshotSerializers(
lowerer: Lowerer,
options: ts.Expression | undefined,
what: string,
): IrExpr[] | null {
if (options === undefined) return null;
const hint = "pass the options as an object literal: { serializers: [(value) => ...] }";
if (!ts.isObjectLiteralExpression(options)) {
lowerer.noLowering(`${what} options that are not an object literal`, options, hint);
}
let fns: IrExpr[] | null = null;
for (const prop of options.properties) {
const name = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : null;
if (
name !== "serializers" ||
!(ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop))
) {
lowerer.noLowering(`${what} options other than serializers`, prop, hint);
}
const list = ts.isPropertyAssignment(prop) ? prop.initializer : prop.name;
if (!ts.isArrayLiteralExpression(list)) {
lowerer.noLowering(`${what} with a non-literal serializers list`, list, hint);
}
fns = list.elements.map((el) => {
if (ts.isSpreadElement(el)) lowerer.noLowering(`${what} with a spread serializer`, el, hint);
return lowerer.lowerExprExpecting(el, DYN);
});
}
return fns;
}
/** `mock.timers` (tracker null) or `t.mock.timers` on a TestContext;
* null for other expressions. */
function mockTimersReceiver(
@@ -712,7 +848,10 @@ export function lowerTestMethodCall(
lowerer.mapTypeOf(lowerer.typeOf(access.expression.expression))?.kind === "testCtx" &&
lowerer.isStdlibMember(access.expression)
) {
const served = lowerer.lowerAssertModuleCall(call, { module: "assert", member }, loc);
const served =
member === "snapshot" || member === "fileSnapshot"
? lowerSnapshotAssert(lowerer, call, member, access.expression.expression)
: lowerer.lowerAssertModuleCall(call, { module: "assert", member }, loc);
// Each t.assert call counts against the test's plan (t.plan).
if (served)
return {
@@ -18,6 +18,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"test.suite",
// mock.timers: Node's ERR_INVALID_STATE and ERR_INVALID_ARG_VALUE, and
// the throws of the timer callbacks tick and runAll run.
"test.ctxSnapshot",
"test.fileSnapshot",
"test.snapshotSerialize",
"test.mockTimersEnable",
"test.mockTimersTick",
"test.mockTimersRunAll",
@@ -1475,6 +1475,18 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
// code per listed API ('T' setTimeout, 'I' setInterval), the `now`
// value and its flags (1 given, 2 from a Date). enable, tick, runAll and
// setTime throw Node's errors; tick and runAll run timer callbacks.
// t.assert.snapshot and fileSnapshot: the escaped, wrapped serialized
// value, and the stored value it is compared with (snapshot: the test,
// the value, the snapshot file; fileSnapshot: the value, the path).
// Both read or, in update mode, record; missing and unreadable
// snapshots throw Node's ERR_INVALID_STATE.
"test.snapshotValue": { argTypes: [STRING], result: STRING },
"test.ctxSnapshot": { argTypes: [TESTCTX_T, STRING, STRING], result: STRING },
"test.fileSnapshot": { argTypes: [STRING, STRING], result: STRING },
// Node's serializeValue over a dyn array of serializers (undefined for
// the default JSON.stringify(value, null, 2)); a throw becomes Node's
// "The provided serializers did not generate a string." ERR_INVALID_STATE.
"test.snapshotSerialize": { argTypes: [DYN, DYN], result: DYN },
"test.mockTimersEnable": { argTypes: [STRING, F64, F64], result: VOID },
"test.mockTimersTick": { argTypes: [F64], result: VOID },
"test.mockTimersRunAll": { argTypes: [], result: VOID },
@@ -2885,6 +2897,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
},
"fileHandle.stat": { argTypes: [FILEHANDLE_T], result: { kind: "promise", inner: STATS_T } },
"process.argv": { argTypes: [], result: arrayOf(STRING) },
// The Node runtime flag a node:test binary took out of process.argv
// (--test-update-snapshots), or [] — a fresh array per read.
"process.execArgv": { argTypes: [], result: arrayOf(STRING) },
"process.platform": { argTypes: [], result: STRING },
// The one libCall whose result type is program-dependent (union ids are
// per-module): the `result` here is a placeholder — the libCall case
@@ -196,6 +196,7 @@ export const LIB_NONDETERMINISTIC_PREFIXES: readonly [string, string][] = [
["blob.fileNew", "the live clock (File lastModified)"],
["process.env", "environment reads"],
["process.argv", "process.argv"],
["process.execArgv", "process.execArgv"],
["process.cwd", "process.cwd"],
["process.chdir", "process.chdir"],
["process.pid", "process identity"],
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/snapshot-fences.ts"
],
"diags": []
}
+33
View File
@@ -166,6 +166,13 @@ static SCR_TL char **scr_lib_argv_owned = NULL;
static SCR_TL char *scr_lib_argv_utf8 = NULL;
#endif
static SCR_TL bool scr_lib_has_fork = false;
/* A leading Node runtime flag taken out of process.argv (scr_lib_take_flag),
* which process.execArgv reports; NULL without one. */
static SCR_TL const char *scr_lib_exec_flag = NULL;
#if defined(_WIN32) && !defined(SCR_LIB)
/* The flag's index in the original Windows command line, -1 without one. */
static SCR_TL int scr_lib_exec_flag_index = -1;
#endif
#if !defined(_WIN32) && !defined(__wasi__) && !defined(SCR_LIB)
static void scr_title_init(int argc, char **argv);
#endif
@@ -321,6 +328,7 @@ static void scr_lib_prepare_utf8_argv(void) {
// scr_lib_init already consumed this private ASCII fork marker. Do not
// reintroduce it when replacing the narrow argument storage.
if (i == 1 && scr_lib_has_fork) continue;
if (i == scr_lib_exec_flag_index) continue;
int size = WideCharToMultiByte(CP_UTF8, 0, wide[i], -1, NULL, 0, NULL, NULL);
if (size == 0 || WideCharToMultiByte(CP_UTF8, 0, wide[i], -1, next, size, NULL, NULL) != size) {
free(text); free(args); LocalFree(wide);
@@ -565,6 +573,31 @@ const char *scr_lib_arg(int i) {
return scr_lib_argv[i];
}
bool scr_lib_take_flag(const char *flag) {
if (scr_lib_argc < 2 || strcmp(scr_lib_argv[1], flag) != 0) return false;
char **args = malloc((size_t)scr_lib_argc * sizeof(char *));
if (!args) scr_trap("out of memory");
args[0] = scr_lib_argv[0];
for (int i = 2; i < scr_lib_argc; i++) args[i - 1] = scr_lib_argv[i];
args[scr_lib_argc - 1] = NULL;
free(scr_lib_argv_owned);
scr_lib_argv_owned = args;
scr_lib_argv = args;
scr_lib_argc--;
#if defined(_WIN32) && !defined(SCR_LIB)
scr_lib_exec_flag_index = scr_lib_has_fork ? 2 : 1;
#endif
scr_lib_exec_flag = flag;
return true;
}
ScrArr *scr_process_exec_argv(void) {
ScrArr *arr = scr_arr_new(SCR_ELEM_STR, 1);
if (scr_lib_exec_flag)
scr_arr_push_ref(arr, scr_str_new(scr_lib_exec_flag, strlen(scr_lib_exec_flag)));
return arr;
}
bool scr_lib_fork_info(double *target, uintptr_t *read_handle,
uintptr_t *write_handle) {
if (!scr_lib_has_fork) return false;
+7
View File
@@ -46,6 +46,13 @@ ScrArr *scr_module_cache_keys(void);
int scr_lib_arg_count(void);
const char *scr_lib_arg(int i);
/* Takes a leading `flag` argument out of process.argv, as Node keeps its
* runtime flags out of it, and reports it in process.execArgv. Called
* right after scr_lib_init, before process.argv is first read. True when
* the flag was there. */
bool scr_lib_take_flag(const char *flag);
/* process.execArgv: the flag scr_lib_take_flag took, or [] (+1, fresh). */
ScrArr *scr_process_exec_argv(void);
/* Private self-reexec marker parsed and removed from process.argv by
* scr_lib_init. False in an ordinary parent invocation. */
bool scr_lib_fork_info(double *target, uintptr_t *read_handle,
+572
View File
@@ -35,6 +35,7 @@
#include "scr_platform.h"
#include "scr_runtime.h"
#include <errno.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
@@ -812,6 +813,575 @@ static void scr_test_print_failures(ScrTestCtx *t, bool *any) {
}
}
/* ── t.assert.snapshot and t.assert.fileSnapshot ───────────────────────
* Node's SnapshotManager (lib/internal/test_runner/snapshot.js). A
* snapshot is the serialized value, template-escaped and wrapped in
* newlines, stored under "<test full name> <n>" in "<entry file>.snapshot".
* Compare mode reads that file on the first assertion and returns the
* stored value, which the emitted assert.strictEqual compares; update
* mode records the value and writes every file, keys sorted, after the
* last test. The file is read in the form Node writes it:
* exports[`key`] = `value`; statements of template literals. */
typedef struct {
ScrStr *key; /* the snapshot id (cooked, as Node's exports keys) */
ScrStr *value; /* the escaped, wrapped value */
} ScrSnapEntry;
typedef struct ScrSnapFile {
ScrStr *path;
ScrSnapEntry *entries;
size_t n, cap;
ScrStr **names; /* nextId's counters: test full names ... */
long *counts; /* ... and the next number for each */
size_t nnames, ncap;
bool loaded;
struct ScrSnapFile *next;
} ScrSnapFile;
static SCR_TL ScrSnapFile *scr_snap_files = NULL;
/* Node's --test-update-snapshots: a leading argument of the binary,
* which scr_test_init takes out of process.argv. */
static SCR_TL bool scr_test_update_snapshots = false;
void scr_test_init(void) {
scr_test_update_snapshots = scr_lib_take_flag("--test-update-snapshots");
}
static void scr_snap_throw_state(const char *text, size_t len) {
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_puts(&b, "Invalid state: ");
scr_jb_write(&b, text, len);
ScrStr *msg = scr_jb_finish(&b);
scr_throw_error_msg_code(SCR_ERR_ERROR, msg->data, msg->len, "ERR_INVALID_STATE");
scr_str_release(msg);
}
/* "Cannot read snapshot file '<file>.'", with the tip when it is missing. */
static void scr_snap_throw_file(const char *verb, const ScrStr *file, bool missing) {
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_puts(&b, "Cannot ");
scr_jb_puts(&b, verb);
scr_jb_puts(&b, " snapshot file '");
scr_jb_put_str(&b, file);
scr_jb_puts(&b, ".'");
if (missing)
scr_jb_puts(&b, " Missing snapshots can be generated by rerunning the command with the "
"--test-update-snapshots flag.");
ScrStr *msg = scr_jb_finish(&b);
scr_snap_throw_state(msg->data, msg->len);
scr_str_release(msg);
}
/* Node's templateEscape: \ ` and ${ escaped for a template literal. */
static void scr_snap_put_escaped(ScrJsonBuf *b, const char *s, size_t n) {
for (size_t i = 0; i < n; i++) {
if (s[i] == '\\') scr_jb_puts(b, "\\\\");
else if (s[i] == '`') scr_jb_puts(b, "\\`");
else if (s[i] == '$' && i + 1 < n && s[i + 1] == '{') scr_jb_puts(b, "\\$");
else scr_jb_putc(b, s[i]);
}
}
/* Node's serializeValue: each serializer's result feeds the next; with no
* serializers, the default JSON.stringify(value, null, 2). A throw becomes
* Node's ERR_INVALID_STATE. Returns +1, or NULL after a throw. */
ScrDyn *scr_test_snapshot_serialize(ScrDyn *value, ScrDyn *serializers) {
ScrDyn *v;
if (serializers->kind != SCR_DYN_ARR) {
ScrDyn *undef = scr_dyn_undefined();
ScrDyn *space = scr_dyn_new_num(2);
v = scr_json_stringify_value(value, undef, space);
scr_dyn_release(undef);
scr_dyn_release(space);
} else {
v = scr_dyn_retain(value);
for (size_t i = 0; v && i < serializers->v.arr.len; i++) {
ScrDyn *next = scr_dyn_call(serializers->v.arr.items[i], &v, 1, "serializer");
scr_dyn_release(v);
v = next;
}
}
if (v && !scr_exc_pending()) return v;
scr_dyn_release(v);
scr_exc_clear();
static const char msg[] = "The provided serializers did not generate a string.";
scr_snap_throw_state(msg, sizeof msg - 1);
return NULL;
}
ScrStr *scr_test_snapshot_value(ScrStr *serialized) {
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_putc(&b, '\n');
scr_snap_put_escaped(&b, serialized->data, serialized->len);
scr_jb_putc(&b, '\n');
return scr_jb_finish(&b);
}
static ScrSnapFile *scr_snap_file(ScrStr *path) {
for (ScrSnapFile *f = scr_snap_files; f; f = f->next) {
if (f->path->len == path->len && memcmp(f->path->data, path->data, path->len) == 0) return f;
}
ScrSnapFile *f = calloc(1, sizeof *f);
if (!f) scr_test_oom();
f->path = scr_str_retain(path);
f->loaded = scr_test_update_snapshots;
f->next = scr_snap_files;
scr_snap_files = f;
return f;
}
/* nextId(name): "<name> <n>", counting from 1 per name. */
static ScrStr *scr_snap_next_id(ScrSnapFile *f, ScrStr *name) {
size_t i = 0;
while (i < f->nnames &&
!(f->names[i]->len == name->len && memcmp(f->names[i]->data, name->data, name->len) == 0))
i++;
if (i == f->nnames) {
if (f->nnames == f->ncap) {
f->ncap = f->ncap ? f->ncap * 2 : 8;
f->names = realloc(f->names, f->ncap * sizeof *f->names);
f->counts = realloc(f->counts, f->ncap * sizeof *f->counts);
if (!f->names || !f->counts) scr_test_oom();
}
f->names[i] = scr_str_retain(name);
f->counts[i] = 1;
f->nnames++;
}
char num[24];
int len = snprintf(num, sizeof num, " %ld", f->counts[i]++);
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_put_str(&b, name);
scr_jb_write(&b, num, (size_t)len);
return scr_jb_finish(&b);
}
static ScrSnapEntry *scr_snap_find(ScrSnapFile *f, const ScrStr *key) {
for (size_t i = 0; i < f->n; i++) {
const ScrStr *k = f->entries[i].key;
if (k->len == key->len && memcmp(k->data, key->data, key->len) == 0) return &f->entries[i];
}
return NULL;
}
/* Sets key to value (both moved), replacing an earlier entry. */
static void scr_snap_set(ScrSnapFile *f, ScrStr *key, ScrStr *value) {
ScrSnapEntry *e = scr_snap_find(f, key);
if (e) {
scr_str_release(key);
scr_str_release(e->value);
e->value = value;
return;
}
if (f->n == f->cap) {
f->cap = f->cap ? f->cap * 2 : 8;
f->entries = realloc(f->entries, f->cap * sizeof *f->entries);
if (!f->entries) scr_test_oom();
}
f->entries[f->n].key = key;
f->entries[f->n].value = value;
f->n++;
}
/* Appends the UTF-8 encoding of code point `cp`. */
static void scr_snap_put_cp(ScrJsonBuf *b, unsigned long cp) {
char u[4];
size_t n;
if (cp < 0x80) {
u[0] = (char)cp;
n = 1;
} else if (cp < 0x800) {
u[0] = (char)(0xC0 | (cp >> 6));
u[1] = (char)(0x80 | (cp & 0x3F));
n = 2;
} else if (cp < 0x10000) {
u[0] = (char)(0xE0 | (cp >> 12));
u[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
u[2] = (char)(0x80 | (cp & 0x3F));
n = 3;
} else {
u[0] = (char)(0xF0 | (cp >> 18));
u[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
u[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
u[3] = (char)(0x80 | (cp & 0x3F));
n = 4;
}
scr_jb_write(b, u, n);
}
static int scr_snap_hex(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
/* Reads `count` hex digits at s[*i]. */
static bool scr_snap_hex_run(const char *s, size_t n, size_t *i, int count, unsigned long *out) {
unsigned long v = 0;
for (int k = 0; k < count; k++) {
if (*i >= n) return false;
int h = scr_snap_hex(s[*i]);
if (h < 0) return false;
v = v * 16 + (unsigned long)h;
(*i)++;
}
*out = v;
return true;
}
/* The cooked value of the template literal whose body starts at s[*i],
* past its opening backtick; *i ends past the closing one. Returns NULL
* for an unterminated literal, an invalid escape, or a substitution. */
static ScrStr *scr_snap_template(const char *s, size_t n, size_t *i) {
ScrJsonBuf b;
scr_jb_init(&b);
while (*i < n) {
char c = s[(*i)++];
if (c == '`') return scr_jb_finish(&b);
if (c == '$' && *i < n && s[*i] == '{') break;
if (c == '\r') {
/* Template literals cook CR and CRLF to LF. */
if (*i < n && s[*i] == '\n') (*i)++;
scr_jb_putc(&b, '\n');
continue;
}
if (c != '\\') {
scr_jb_putc(&b, c);
continue;
}
if (*i >= n) break;
char e = s[(*i)++];
unsigned long cp;
switch (e) {
case 'n':
scr_jb_putc(&b, '\n');
continue;
case 't':
scr_jb_putc(&b, '\t');
continue;
case 'r':
scr_jb_putc(&b, '\r');
continue;
case 'b':
scr_jb_putc(&b, '\b');
continue;
case 'f':
scr_jb_putc(&b, '\f');
continue;
case 'v':
scr_jb_putc(&b, '\v');
continue;
case '0':
if (*i < n && s[*i] >= '0' && s[*i] <= '9') goto bad;
scr_jb_putc(&b, '\0');
continue;
case '\r':
if (*i < n && s[*i] == '\n') (*i)++;
continue; /* line continuation */
case '\n':
continue;
case 'x':
if (!scr_snap_hex_run(s, n, i, 2, &cp)) goto bad;
scr_snap_put_cp(&b, cp);
continue;
case 'u':
if (*i < n && s[*i] == '{') {
(*i)++;
cp = 0;
size_t start = *i;
while (*i < n && s[*i] != '}') {
int h = scr_snap_hex(s[*i]);
if (h < 0 || cp > 0x10FFFF) goto bad;
cp = cp * 16 + (unsigned long)h;
(*i)++;
}
if (*i >= n || *i == start || cp > 0x10FFFF) goto bad;
(*i)++;
} else if (!scr_snap_hex_run(s, n, i, 4, &cp)) {
goto bad;
}
if (cp >= 0xD800 && cp <= 0xDBFF && *i + 6 <= n && s[*i] == '\\' && s[*i + 1] == 'u') {
size_t j = *i + 2;
unsigned long lo;
if (scr_snap_hex_run(s, n, &j, 4, &lo) && lo >= 0xDC00 && lo <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
*i = j;
}
}
scr_snap_put_cp(&b, cp);
continue;
default:
if (e >= '1' && e <= '9') goto bad;
scr_jb_putc(&b, e); /* \\ \` \$ and identity escapes */
continue;
}
}
bad:
scr_str_release(scr_jb_finish(&b));
return NULL;
}
static void scr_snap_skip_space(const char *s, size_t n, size_t *i) {
while (*i < n && (s[*i] == ' ' || s[*i] == '\t' || s[*i] == '\n' || s[*i] == '\r')) (*i)++;
}
static bool scr_snap_expect(const char *s, size_t n, size_t *i, const char *text) {
size_t len = strlen(text);
if (*i + len > n || memcmp(s + *i, text, len) != 0) return false;
*i += len;
return true;
}
/* Parses the file's exports[`key`] = `value`; statements. Values are
* escaped again, as Node escapes the exports it read. */
static bool scr_snap_parse(ScrSnapFile *f, const char *s, size_t n) {
size_t i = 0;
for (;;) {
scr_snap_skip_space(s, n, &i);
if (i >= n) return true;
if (!scr_snap_expect(s, n, &i, "exports[`")) return false;
ScrStr *key = scr_snap_template(s, n, &i);
if (!key) return false;
scr_snap_skip_space(s, n, &i);
bool ok = scr_snap_expect(s, n, &i, "]");
scr_snap_skip_space(s, n, &i);
ok = ok && scr_snap_expect(s, n, &i, "=");
scr_snap_skip_space(s, n, &i);
ok = ok && scr_snap_expect(s, n, &i, "`");
ScrStr *raw = ok ? scr_snap_template(s, n, &i) : NULL;
if (!raw) {
scr_str_release(key);
return false;
}
scr_snap_skip_space(s, n, &i);
(void)scr_snap_expect(s, n, &i, ";");
ScrJsonBuf b;
scr_jb_init(&b);
scr_snap_put_escaped(&b, raw->data, raw->len);
scr_str_release(raw);
scr_snap_set(f, key, scr_jb_finish(&b));
}
}
static bool scr_snap_read(ScrSnapFile *f) {
if (f->loaded) return true;
FILE *fp = fopen(f->path->data, "rb");
if (!fp) {
scr_snap_throw_file("read", f->path, errno == ENOENT);
return false;
}
ScrJsonBuf b;
scr_jb_init(&b);
char chunk[8192];
size_t got;
while ((got = fread(chunk, 1, sizeof chunk, fp)) > 0) scr_jb_write(&b, chunk, got);
bool read_ok = !ferror(fp);
fclose(fp);
ScrStr *text = scr_jb_finish(&b);
bool ok = read_ok && scr_snap_parse(f, text->data, text->len);
scr_str_release(text);
if (!ok) {
scr_snap_throw_file("read", f->path, false);
return false;
}
f->loaded = true;
return true;
}
/* Node's escapeSnapshotKey: the JSON string body of the id, with ` and
* ${ escaped (+1). Update mode stores and sorts keys in this form. */
static ScrStr *scr_snap_escape_key(const ScrStr *key) {
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_put_json_str(&b, key);
ScrStr *json = scr_jb_finish(&b);
const char *s = json->data + 1;
size_t n = json->len - 2;
ScrJsonBuf out;
scr_jb_init(&out);
for (size_t i = 0; i < n; i++) {
if (s[i] == '`') scr_jb_puts(&out, "\\`");
else if (s[i] == '$' && i + 1 < n && s[i + 1] == '{') scr_jb_puts(&out, "\\$");
else scr_jb_putc(&out, s[i]);
}
scr_str_release(json);
return scr_jb_finish(&out);
}
/* t.assert.snapshot: the stored value for the test's next id (+1), or the
* recorded `value` in update mode. NULL after a throw. */
ScrStr *scr_test_ctx_snapshot(ScrTestCtx *t, ScrStr *value, ScrStr *file) {
ScrSnapFile *f = scr_snap_file(file);
ScrStr *name = scr_test_ctx_full_name(t);
ScrStr *id = scr_snap_next_id(f, name);
scr_str_release(name);
if (scr_test_update_snapshots) {
scr_snap_set(f, scr_snap_escape_key(id), scr_str_retain(value));
scr_str_release(id);
return scr_str_retain(value);
}
if (!scr_snap_read(f)) {
scr_str_release(id);
return NULL;
}
ScrSnapEntry *e = scr_snap_find(f, id);
if (!e) {
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_puts(&b, "Snapshot '");
scr_jb_put_str(&b, id);
scr_jb_puts(&b, "' not found in '");
scr_jb_put_str(&b, f->path);
scr_jb_puts(&b, ".' Missing snapshots can be generated by rerunning the command with the "
"--test-update-snapshots flag.");
ScrStr *msg = scr_jb_finish(&b);
scr_snap_throw_state(msg->data, msg->len);
scr_str_release(msg);
scr_str_release(id);
return NULL;
}
scr_str_release(id);
return scr_str_retain(e->value);
}
static bool scr_snap_write_text(const ScrStr *path, const char *data, size_t len) {
FILE *fp = fopen(path->data, "wb");
if (!fp) return false;
bool ok = fwrite(data, 1, len, fp) == len;
return fclose(fp) == 0 && ok;
}
/* mkdirSync(dirname(path), { recursive: true }); false after an error. */
static bool scr_snap_mkdir_parent(const ScrStr *path) {
size_t end = path->len;
while (end > 0 && path->data[end - 1] != '/'
#ifdef _WIN32
&& path->data[end - 1] != '\\'
#endif
)
end--;
if (end <= 1) return true;
ScrStr *dir = scr_str_new(path->data, end - 1);
scr_fs_mkdir_recursive(dir);
scr_str_release(dir);
if (!scr_exc_pending()) return true;
scr_exc_clear();
return false;
}
/* t.assert.fileSnapshot: the file's contents (+1), or the recorded value
* in update mode, which writes the file. NULL after a throw. */
ScrStr *scr_test_file_snapshot(ScrStr *value, ScrStr *path) {
if (scr_test_update_snapshots) {
if (!scr_snap_mkdir_parent(path) || !scr_snap_write_text(path, value->data, value->len)) {
scr_snap_throw_file("write", path, false);
return NULL;
}
return scr_str_retain(value);
}
FILE *fp = fopen(path->data, "rb");
if (!fp) {
scr_snap_throw_file("read", path, errno == ENOENT);
return NULL;
}
ScrJsonBuf b;
scr_jb_init(&b);
char chunk[8192];
size_t got;
while ((got = fread(chunk, 1, sizeof chunk, fp)) > 0) scr_jb_write(&b, chunk, got);
bool ok = !ferror(fp);
fclose(fp);
ScrStr *text = scr_jb_finish(&b);
if (!ok) {
scr_str_release(text);
scr_snap_throw_file("read", path, false);
return NULL;
}
return text;
}
/* The next UTF-16 code unit of a UTF-8 key: *low carries the low
* surrogate of an astral character to the following call. */
static unsigned long scr_snap_unit(const ScrStr *s, size_t *i, unsigned long *low) {
if (*low) {
unsigned long u = *low;
*low = 0;
return u;
}
const unsigned char *d = (const unsigned char *)s->data;
unsigned char c = d[*i];
size_t len = c < 0x80 ? 1 : c < 0xE0 ? 2 : c < 0xF0 ? 3 : 4;
if (*i + len > s->len) len = s->len - *i;
unsigned long cp = len == 1 ? c : len == 2 ? (c & 0x1Fu) : len == 3 ? (c & 0x0Fu) : (c & 0x07u);
for (size_t k = 1; k < len; k++) cp = (cp << 6) | (d[*i + k] & 0x3Fu);
*i += len;
if (cp < 0x10000) return cp;
*low = 0xDC00 + ((cp - 0x10000) & 0x3FF);
return 0xD800 + ((cp - 0x10000) >> 10);
}
/* Array.prototype.sort's default order: UTF-16 code units. */
static int scr_snap_key_cmp(const void *a, const void *b) {
const ScrStr *x = ((const ScrSnapEntry *)a)->key, *y = ((const ScrSnapEntry *)b)->key;
size_t i = 0, j = 0;
unsigned long xl = 0, yl = 0;
while ((i < x->len || xl) && (j < y->len || yl)) {
unsigned long u = scr_snap_unit(x, &i, &xl), v = scr_snap_unit(y, &j, &yl);
if (u != v) return u < v ? -1 : 1;
}
bool xmore = i < x->len || xl, ymore = j < y->len || yl;
return xmore == ymore ? 0 : xmore ? 1 : -1;
}
/* writeSnapshotFiles(): every snapshot file of the run, in update mode. */
static void scr_snap_write_all(void) {
if (!scr_test_update_snapshots) return;
for (ScrSnapFile *f = scr_snap_files; f; f = f->next) {
qsort(f->entries, f->n, sizeof *f->entries, scr_snap_key_cmp);
ScrJsonBuf b;
scr_jb_init(&b);
for (size_t i = 0; i < f->n; i++) {
if (i > 0) scr_jb_putc(&b, '\n');
scr_jb_puts(&b, "exports[`");
scr_jb_put_str(&b, f->entries[i].key);
scr_jb_puts(&b, "`] = `");
scr_jb_put_str(&b, f->entries[i].value);
scr_jb_puts(&b, "`;\n");
}
ScrStr *text = scr_jb_finish(&b);
if (!scr_snap_mkdir_parent(f->path) || !scr_snap_write_text(f->path, text->data, text->len)) {
fflush(stdout);
fprintf(stderr, "Error [ERR_INVALID_STATE]: Invalid state: Cannot write snapshot file '%.*s.'\n",
(int)f->path->len, f->path->data);
scr_test_suite_hook_failed = true;
}
scr_str_release(text);
}
}
static void scr_snap_free_all(void) {
while (scr_snap_files) {
ScrSnapFile *f = scr_snap_files;
scr_snap_files = f->next;
for (size_t i = 0; i < f->n; i++) {
scr_str_release(f->entries[i].key);
scr_str_release(f->entries[i].value);
}
for (size_t i = 0; i < f->nnames; i++) scr_str_release(f->names[i]);
free(f->entries);
free(f->names);
free(f->counts);
scr_str_release(f->path);
free(f);
}
}
/* ── the runner fiber ────────────────────────────────────────────────── */
static void scr_test_runner_entry(ScrFiber *self, void *argpack) {
@@ -823,6 +1393,7 @@ static void scr_test_runner_entry(ScrFiber *self, void *argpack) {
ScrTestCtx *root = scr_test_root;
scr_test_run_children(root);
scr_test_run_done = true;
scr_snap_write_all();
scr_test_count(root);
scr_test_info_line("tests", (double)scr_test_ct_tests);
scr_test_info_line("suites", (double)scr_test_ct_suites);
@@ -1618,6 +2189,7 @@ int scr_test_exit_code(void) {
if (scr_exit_frees()) {
scr_test_hooks_free(&scr_test_mocks);
scr_mt_free_all();
scr_snap_free_all();
}
/* The runner never finished: a test awaited a promise nobody settles
* and the loop exhausted (Node cancels the test and exits 1 — the
+14
View File
@@ -57,6 +57,20 @@ void scr_test_mock_reset(void);
void scr_test_ctx_mock_track(ScrTestCtx *t, ScrClosure *restore);
void scr_test_ctx_mock_restore_all(ScrTestCtx *t);
void scr_test_ctx_mock_reset(ScrTestCtx *t);
/* Called by node:test programs right after scr_lib_init: a leading
* --test-update-snapshots argument, Node's flag, turns on snapshot update
* mode and moves from process.argv to process.execArgv. */
void scr_test_init(void);
/* t.assert.snapshot and t.assert.fileSnapshot. snapshot_value escapes
* and wraps a serialized value (+1); ctx_snapshot returns the stored value
* for the test's next snapshot id in `file` and file_snapshot the
* contents of `path` (+1), or the recorded value in update mode. Both
* throw Node's ERR_INVALID_STATE for missing or unreadable snapshots and
* return NULL then. */
ScrStr *scr_test_snapshot_value(ScrStr *serialized);
ScrDyn *scr_test_snapshot_serialize(ScrDyn *value, ScrDyn *serializers);
ScrStr *scr_test_ctx_snapshot(ScrTestCtx *t, ScrStr *value, ScrStr *file);
ScrStr *scr_test_file_snapshot(ScrStr *value, ScrStr *path);
/* mock.timers on the module's `mock` and on t.mock: enable({ apis, now })
* (`apis` one code per listed API: 'T' setTimeout, 'I' setInterval, 'M'
* setImmediate, 'D' Date, 'W' scheduler.wait, 'A' AbortSignal.timeout;
+19
View File
@@ -0,0 +1,19 @@
// t.assert.snapshot and t.assert.fileSnapshot lower with no options and
// with an options object literal whose serializers is an array literal.
// The rest is refused, each with a named diagnostic: options that are not
// an object literal, a serializers list that is not an array literal, and
// a spread serializer.
import { test } from "node:test";
test("snapshots", (t) => {
t.assert.snapshot({ a: 1 }); // lowers
t.assert.snapshot(1, { serializers: [(v: number) => `${v}`] }); // lowers
t.assert.fileSnapshot("text", "out.txt"); // lowers
const options = { serializers: [(v: unknown) => String(v)] };
t.assert.snapshot(1, options); // options that are not an object literal
const serializers = [(v: unknown) => String(v)];
t.assert.snapshot(1, { serializers }); // a serializers list that is not an array literal
t.assert.fileSnapshot(1, "out.txt", { serializers: [...serializers] }); // a spread serializer
});
@@ -0,0 +1,6 @@
{
"f": 1,
"list": [
"a"
]
}
+72
View File
@@ -0,0 +1,72 @@
// @exit: 1
// t.assert.snapshot and t.assert.fileSnapshot in compare mode, against the
// snapshot.ts.snapshot file `node --test-update-snapshots` wrote (then
// edited so that one snapshot differs and one is missing). Values serialize
// with JSON.stringify(value, null, 2) unless options.serializers replace
// it; each test numbers its snapshots under its full name; a differing
// snapshot fails as assert.strictEqual does, and missing snapshots and
// files and throwing serializers throw ERR_INVALID_STATE.
import { describe, test } from "node:test";
describe("values", () => {
test("records and arrays", (t) => {
t.assert.snapshot({ zeta: 1, alpha: [1, "two", { b: true, a: null }] });
t.assert.snapshot([{ id: 3, name: "n" }]);
});
test("strings", (t) => {
t.assert.snapshot("plain");
t.assert.snapshot("tick ` dollar ${x} backslash \\ newline \n tab \t");
t.assert.snapshot("unicode é 漢 😀");
});
test("scalars", (t) => {
t.assert.snapshot(42);
t.assert.snapshot(-0.5);
t.assert.snapshot(true);
t.assert.snapshot(null);
t.assert.snapshot(undefined);
});
});
test("name with `backticks` and ${braces}", (t) => {
t.assert.snapshot({ ok: true });
});
describe("serializers", () => {
test("run in order", (t) => {
t.assert.snapshot({ n: 5 }, { serializers: [(v: { n: number }) => v.n * 2, (v: number) => `n is ${v}`] });
t.assert.snapshot("x", { serializers: [(v: string) => v.toUpperCase()] });
t.assert.snapshot(7, { serializers: [(v: number) => v + 1] });
});
test("a throwing serializer", (t) => {
t.assert.snapshot(1, {
serializers: [
() => {
throw new Error("no");
},
],
});
});
test("a circular value", (t) => {
const loop: { self?: unknown } = {};
loop.self = loop;
t.assert.snapshot(loop);
});
});
test("counts toward the plan", { plan: 2 }, (t) => {
t.assert.snapshot(1);
t.assert.ok(true);
});
test("differs from the stored snapshot", (t) => {
t.assert.snapshot({ count: 2 });
});
test("missing from the snapshot file", (t) => {
t.assert.snapshot("new");
});
test("file snapshots", (t) => {
t.assert.fileSnapshot({ f: 1, list: ["a"] }, "tests/fixtures/node-test/cases/snapshot-files/value.json");
t.assert.fileSnapshot("text", "tests/fixtures/node-test/cases/snapshot-files/missing.txt");
});
+82
View File
@@ -0,0 +1,82 @@
exports[`counts toward the plan 1`] = `
1
`;
exports[`differs from the stored snapshot 1`] = `
{
"count": 1
}
`;
exports[`name with \`backticks\` and \${braces} 1`] = `
{
"ok": true
}
`;
exports[`serializers > run in order 1`] = `
n is 10
`;
exports[`serializers > run in order 2`] = `
X
`;
exports[`serializers > run in order 3`] = `
8
`;
exports[`values > records and arrays 1`] = `
{
"zeta": 1,
"alpha": [
1,
"two",
{
"b": true,
"a": null
}
]
}
`;
exports[`values > records and arrays 2`] = `
[
{
"id": 3,
"name": "n"
}
]
`;
exports[`values > scalars 1`] = `
42
`;
exports[`values > scalars 2`] = `
-0.5
`;
exports[`values > scalars 3`] = `
true
`;
exports[`values > scalars 4`] = `
null
`;
exports[`values > scalars 5`] = `
undefined
`;
exports[`values > strings 1`] = `
"plain"
`;
exports[`values > strings 2`] = `
"tick \` dollar \${x} backslash \\\\ newline \\n tab \\t"
`;
exports[`values > strings 3`] = `
"unicode é 漢 😀"
`;
+27
View File
@@ -0,0 +1,27 @@
// Run with --test-update-snapshots (before the program arguments): the
// snapshot assertions record their values, the run writes
// snapshot-update.ts.snapshot with sorted keys and out/value.json, and the
// flag is in process.execArgv instead of process.argv.
import { describe, test } from "node:test";
console.log(JSON.stringify(process.argv.slice(2)), JSON.stringify(process.execArgv));
describe("suite", () => {
test("values", (t) => {
t.assert.snapshot({ b: 1, a: "x`${y}\\z\n" });
t.assert.snapshot([1, 2]);
t.assert.snapshot("é 😀 \u0001");
});
test("serializers", (t) => {
t.assert.snapshot(3, { serializers: [(v: number) => v * 2] });
});
});
test("zz last", (t) => {
t.assert.snapshot(undefined);
});
test("aa `first` ${x}", (t) => {
t.assert.snapshot(null);
t.assert.fileSnapshot({ f: [1] }, "out/value.json");
});
@@ -0,0 +1,26 @@
snapshot-fences.ts:14:24 - error SC2020: 't.assert.snapshot options that are not an object literal' is part of the standard library types but has no scriptc lowering yet
13 | const options = { serializers: [(v: unknown) => String(v)] };
14 | t.assert.snapshot(1, options); // options that are not an object literal
| ^~~~~~~
15 |
hint: pass the options as an object literal: { serializers: [(value) => ...] }
snapshot-fences.ts:17:26 - error SC2020: 't.assert.snapshot with a non-literal serializers list' is part of the standard library types but has no scriptc lowering yet
16 | const serializers = [(v: unknown) => String(v)];
17 | t.assert.snapshot(1, { serializers }); // a serializers list that is not an array literal
| ^~~~~~~~~~~
18 | t.assert.fileSnapshot(1, "out.txt", { serializers: [...serializers] }); // a spread serializer
hint: pass the options as an object literal: { serializers: [(value) => ...] }
snapshot-fences.ts:18:55 - error SC2020: 't.assert.fileSnapshot with a spread serializer' is part of the standard library types but has no scriptc lowering yet
17 | t.assert.snapshot(1, { serializers }); // a serializers list that is not an array literal
18 | t.assert.fileSnapshot(1, "out.txt", { serializers: [...serializers] }); // a spread serializer
| ^~~~~~~~~~~~~~
19 | });
hint: pass the options as an object literal: { serializers: [(value) => ...] }
+6 -4
View File
@@ -2,10 +2,11 @@
* node-test.test.ts's function, extracted so the Linux lane applies the
* IDENTICAL scrub to both of its in-container lanes. Node's spec reporter
* embeds real durations in every line (raw byte-compare is impossible for
* ANY node:test program); stack frames and the inspect property block a
* frame opens are runner-internal output the compiled runtime
* deliberately omits (SEMANTICS.md). Everything else must match
* byte-exactly. */
* ANY node:test program); stack frames, the "... N lines matching cause
* stack trace ..." line inspect folds an error cause's shared frames into,
* and the inspect property block a frame opens are runner-internal output
* the compiled runtime deliberately omits (SEMANTICS.md). Everything else
* must match byte-exactly. */
/** The documented normalization — durations, frames, the property block. */
export function normalizeNodeTestOutput(stdout: string): string {
@@ -16,6 +17,7 @@ export function normalizeNodeTestOutput(stdout: string): string {
if (/^\s*\}\s*$/.test(line)) inPropBlock = false;
continue;
}
if (/^\s+\.\.\. \d+ lines? matching cause stack trace \.\.\.$/.test(line)) continue;
if (/^\s+at /.test(line)) {
if (line.trimEnd().endsWith("{")) inPropBlock = true;
continue;
+58 -9
View File
@@ -11,10 +11,12 @@
* NORMALIZATIONS (both lanes):
* - "(1.234ms)" duration parentheticals → "(Xms)"
* - the "ℹ duration_ms N" summary line → "ℹ duration_ms X"
* - stack frames (" at ...") and the inspect property block a frame
* opens ("... {" through its closing "}") are dropped — Node prints
* its runner-internal frames and an error-property dump the compiled
* runtime deliberately omits (SEMANTICS.md).
* - stack frames (" at ..."), the "... N lines matching cause stack
* trace ..." line that folds an error cause's shared frames, and the
* inspect property block a frame opens ("... {" through its closing
* "}") are dropped — Node prints its runner-internal frames and an
* error-property dump the compiled runtime deliberately omits
* (SEMANTICS.md).
*
* Fixtures never console.log inside test bodies or hooks: Node's
* reporter stream lags console output RACILY (its ordering varies run to
@@ -25,7 +27,8 @@
* the RC audit over every case. */
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { globSync, mkdirSync, readFileSync } from "node:fs";
import { copyFileSync, globSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
@@ -41,11 +44,12 @@ interface ProgramRun {
exitCode: number;
}
function runLane(cmd: string, args: string[]): Promise<ProgramRun> {
function runLane(cmd: string, args: string[], cwd = repoRoot): Promise<ProgramRun> {
return new Promise((resolve, reject) => {
// cwd pinned to the repo root: both reporters render "test at" paths
// relative to the working directory, so the lanes must share one.
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd: repoRoot });
// cwd pinned to the repo root by default: both reporters render "test
// at" paths relative to the working directory, so the lanes must share
// one.
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd });
const out: Buffer[] = [];
let errText = "";
const timer = setTimeout(() => {
@@ -125,3 +129,48 @@ describe(`node:test differential (${cases.length} programs${sanitize ? ", saniti
120_000,
);
});
/* Snapshot update mode: Node's --test-update-snapshots flag, which the
* compiled binary takes as its leading argument. Each lane runs the
* fixture from its own temporary copy (the snapshot file sits beside the
* entry file and fileSnapshot paths resolve against the working
* directory), writes the snapshot files, and then passes in compare mode
* against them. The written files and the printed argv/execArgv match. */
describe(`node:test snapshot update mode${sanitize ? " (sanitized)" : ""}`, () => {
test("--test-update-snapshots writes the files Node writes", async () => {
const fixture = join(fixturesRoot, "update/snapshot-update.ts");
const lanes = await Promise.all(
(["node", "native"] as const).map(async (lane) => {
const dir = mkdtempSync(join(tmpdir(), `scriptc-snapshot-${lane}-`));
const entry = join(dir, "snapshot-update.ts");
copyFileSync(fixture, entry);
const binary = lane === "native" ? await build(entry) : null;
const run = (update: boolean) =>
binary
? runLane(binary, [...(update ? ["--test-update-snapshots"] : []), "one", "two"], dir)
: runLane(
"node",
[...(update ? ["--test-update-snapshots"] : []), entry, "one", "two"],
dir,
);
const update = await run(true);
const compare = await run(false);
return {
update,
compare,
snapshot: readFileSync(`${entry}.snapshot`, "utf8"),
file: readFileSync(join(dir, "out/value.json"), "utf8"),
};
}),
);
const [node, native] = lanes as [(typeof lanes)[number], (typeof lanes)[number]];
expect(native.snapshot).toBe(node.snapshot);
expect(native.file).toBe(node.file);
expect(normalize(native.update.stdout)).toBe(normalize(node.update.stdout));
expect(normalize(native.compare.stdout)).toBe(normalize(node.compare.stdout));
for (const lane of lanes) {
expect(lane.update.exitCode).toBe(0);
expect(lane.compare.exitCode).toBe(0);
}
}, 120_000);
});