diff --git a/docs/content/limitations/builtin-apis/test-runner.mdx b/docs/content/limitations/builtin-apis/test-runner.mdx index be138b3ca..7a50901db 100644 --- a/docs/content/limitations/builtin-apis/test-runner.mdx +++ b/docs/content/limitations/builtin-apis/test-runner.mdx @@ -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, `.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`. diff --git a/docs/content/limitations/process-arguments-and-errors.mdx b/docs/content/limitations/process-arguments-and-errors.mdx index 4cca7a808..d048b4154 100644 --- a/docs/content/limitations/process-arguments-and-errors.mdx +++ b/docs/content/limitations/process-arguments-and-errors.mdx @@ -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 ` 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`. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 1c1969968..500876593 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "823524db8f12948bdc73", + "artifactVersion": "56f660f5e2eb7391a048", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 3a986ebf7..568cdb4c9 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -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" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 565aadbb3..683d477a5 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -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" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index d3d1addfe..393955bc0 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -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" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index b86e033f9..cdd025b72 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -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"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index b9475d51f..8cb8c972f 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -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; diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 5b2561962..8bae1abaf 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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)}()`]), diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index c0b3725b7..6a85a8eb3 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -200,6 +200,7 @@ export const LIB_FN_SYMS: Record = { "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", diff --git a/packages/compiler/src/backend/llvm/lib-test.ts b/packages/compiler/src/backend/llvm/lib-test.ts index 8ceadae2f..d0d9a9bfc 100644 --- a/packages/compiler/src/backend/llvm/lib-test.ts +++ b/packages/compiler/src/backend/llvm/lib-test.ts @@ -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": diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 33110eaa4..2371147a9 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -106,6 +106,7 @@ const INERT_LIB_CALLS = new Set([ "regex.groupParseInt", "process.cwd", "process.argv", + "process.execArgv", "process.platform", "process.execPath", "process.isTTY", diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 2aa1a47ce..97bf1e963 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -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. diff --git a/packages/compiler/src/frontend/lowering/lower-test.ts b/packages/compiler/src/frontend/lowering/lower-test.ts index c616f3e73..d18aec305 100644 --- a/packages/compiler/src/frontend/lowering/lower-test.ts +++ b/packages/compiler/src/frontend/lowering/lower-test.ts @@ -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 + * ".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 { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 717c87639..1bb9cbd14 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -18,6 +18,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = 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", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index b93490f9d..a9bc17ea6 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -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 diff --git a/packages/compiler/src/library/ir-policy.ts b/packages/compiler/src/library/ir-policy.ts index 9fe80fdf3..3c3855e5c 100644 --- a/packages/compiler/src/library/ir-policy.ts +++ b/packages/compiler/src/library/ir-policy.ts @@ -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"], diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/snapshot-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/snapshot-fences.ts.json new file mode 100644 index 000000000..7449cca82 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/snapshot-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/snapshot-fences.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index baabbe31e..b71f95a32 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -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; diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index d240aadbd..4c973e3e5 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -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, diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index f8bca0cfa..9e6da5939 100644 --- a/packages/runtime/src/scr_test.c +++ b/packages/runtime/src/scr_test.c @@ -35,6 +35,7 @@ #include "scr_platform.h" #include "scr_runtime.h" +#include #include #include #include @@ -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 " " in ".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 '.'", 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): " ", 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 diff --git a/packages/runtime/src/scr_test.h b/packages/runtime/src/scr_test.h index 3e60f6abd..b1078cccd 100644 --- a/packages/runtime/src/scr_test.h +++ b/packages/runtime/src/scr_test.h @@ -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; diff --git a/tests/diagnostics/snapshot-fences.ts b/tests/diagnostics/snapshot-fences.ts new file mode 100644 index 000000000..afdf35b70 --- /dev/null +++ b/tests/diagnostics/snapshot-fences.ts @@ -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 +}); diff --git a/tests/fixtures/node-test/cases/snapshot-files/value.json b/tests/fixtures/node-test/cases/snapshot-files/value.json new file mode 100644 index 000000000..bb44c02ab --- /dev/null +++ b/tests/fixtures/node-test/cases/snapshot-files/value.json @@ -0,0 +1,6 @@ +{ + "f": 1, + "list": [ + "a" + ] +} \ No newline at end of file diff --git a/tests/fixtures/node-test/cases/snapshot.ts b/tests/fixtures/node-test/cases/snapshot.ts new file mode 100644 index 000000000..eb5d97ce4 --- /dev/null +++ b/tests/fixtures/node-test/cases/snapshot.ts @@ -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"); +}); diff --git a/tests/fixtures/node-test/cases/snapshot.ts.snapshot b/tests/fixtures/node-test/cases/snapshot.ts.snapshot new file mode 100644 index 000000000..ba201eb68 --- /dev/null +++ b/tests/fixtures/node-test/cases/snapshot.ts.snapshot @@ -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 é 漢 😀" +`; diff --git a/tests/fixtures/node-test/update/snapshot-update.ts b/tests/fixtures/node-test/update/snapshot-update.ts new file mode 100644 index 000000000..7d14509d1 --- /dev/null +++ b/tests/fixtures/node-test/update/snapshot-update.ts @@ -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"); +}); diff --git a/tests/harness/__snapshots__/snapshot-fences.ts.txt b/tests/harness/__snapshots__/snapshot-fences.ts.txt new file mode 100644 index 000000000..3f71fd710 --- /dev/null +++ b/tests/harness/__snapshots__/snapshot-fences.ts.txt @@ -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) => ...] } \ No newline at end of file diff --git a/tests/harness/node-test-normalize.ts b/tests/harness/node-test-normalize.ts index c150bb707..e02b5169b 100644 --- a/tests/harness/node-test-normalize.ts +++ b/tests/harness/node-test-normalize.ts @@ -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; diff --git a/tests/harness/node-test.test.ts b/tests/harness/node-test.test.ts index 7cf10ac50..054a77aaa 100644 --- a/tests/harness/node-test.test.ts +++ b/tests/harness/node-test.test.ts @@ -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 { +function runLane(cmd: string, args: string[], cwd = repoRoot): Promise { 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); +});