diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 789a226326..5f1bdac8f7 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -88,6 +88,8 @@ title: "Limitations" ../limitations/builtin-apis/assertions.mdx +../limitations/builtin-apis/test-mocks.mdx + ../limitations/builtin-apis/exit-status.mdx ../limitations/builtin-apis/process-memory.mdx diff --git a/docs/content/limitations/builtin-apis/test-mocks.mdx b/docs/content/limitations/builtin-apis/test-mocks.mdx new file mode 100644 index 0000000000..c9e84a80d5 --- /dev/null +++ b/docs/content/limitations/builtin-apis/test-mocks.mdx @@ -0,0 +1 @@ +- **Test mocks:** `mock.fn`, `mock.method`, `mock.restoreAll` and `mock.reset` compile, on the module's `mock` and on `t.mock`. `mock.method` mocks a function-valued field of a record, such as an object literal or an interface-typed object. Class instances, builtin objects, and object literals whose methods use `this` throw `SC2020`. Calls through the object record it as `this`, including calls of a method read off the object and called on its own, where Node records `undefined`. `object.method === mock` is `false`, because the field holds a wrapper of the mock. `mock.fn()` without an original, `mock.getter`, `mock.setter`, `mock.property`, `mock.module`, `mock.timers` and mock options throw `SC2020`. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index f1e1faa40a..b99153c876 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": "ae3ec89c0c8935ca6c07", + "artifactVersion": "d91d55ce5aa065b163bb", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 2db9a5db2c..b11e5180e5 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": 42, + "partial": 45, "refused": 0, - "not-implemented": 75, + "not-implemented": 72, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -65389,9 +65389,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" } }, { @@ -65458,9 +65458,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" } }, { @@ -65481,9 +65481,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 b597d091c3..f1e5c2883a 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3472, "tiers": { "static": { - "audit-partial": 1061, - "verify-gap": 2284, + "audit-partial": 1064, + "verify-gap": 2281, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -66204,6 +66204,7 @@ "source": "compiler-feature:test.ctx.callCount", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] } @@ -66232,6 +66233,7 @@ "source": "compiler-feature:test.ctx.mockImplementation", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] } @@ -66260,6 +66262,7 @@ "source": "compiler-feature:test.ctx.mockImplementationOnce", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] } @@ -66288,6 +66291,7 @@ "source": "compiler-feature:test.ctx.resetCalls", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] } @@ -66316,6 +66320,7 @@ "source": "compiler-feature:test.ctx.restore", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] } @@ -66344,6 +66349,7 @@ "source": "compiler-feature:test.calls", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] } @@ -66668,12 +66674,17 @@ "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.mock.method", + "tests": [ + "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" + ] } } }, @@ -66743,12 +66754,17 @@ "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.mock.reset", + "tests": [ + "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" + ] } } }, @@ -66768,12 +66784,17 @@ "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.mock.restoreAll", + "tests": [ + "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/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 1c5821b6a5..f7e9862e32 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": 42, + "partial": 45, "refused": 0, - "not-implemented": 75, + "not-implemented": 72, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -90674,9 +90674,12 @@ "tests/corpus/1721-assert-throws-regex-class.ts", "tests/corpus/2936-static-loose-equality.ts", "tests/corpus/5515-assert-deep-strict-typed-messages.ts", + "tests/corpus/5810-mock-method-records.ts", + "tests/corpus/5811-mock-tracker-restore.ts", "tests/fixtures/node-test/cases/ctx-hooks.ts", "tests/fixtures/node-test/cases/ctx-plan.ts", "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/fixtures/node-test/cases/only-nested.ts", "tests/fixtures/node-test/cases/suite-variants.ts", "tests/fixtures/node-test/cases/todo-suites.ts", @@ -91661,6 +91664,7 @@ "evidence": "compiler-feature:test.ctx.callCount", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] }, @@ -91688,6 +91692,7 @@ "evidence": "compiler-feature:test.ctx.mockImplementation", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] }, @@ -91715,6 +91720,7 @@ "evidence": "compiler-feature:test.ctx.mockImplementationOnce", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] }, @@ -91742,6 +91748,7 @@ "evidence": "compiler-feature:test.ctx.resetCalls", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] }, @@ -91769,6 +91776,7 @@ "evidence": "compiler-feature:test.ctx.restore", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] }, @@ -91796,6 +91804,7 @@ "evidence": "compiler-feature:test.calls", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts" ] }, @@ -92122,8 +92131,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.mock.method", + "tests": [ + "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" + ] }, "anchorSource": "exact" }, @@ -92191,8 +92206,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.mock.reset", + "tests": [ + "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" + ] }, "anchorSource": "exact" }, @@ -92214,8 +92235,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.mock.restoreAll", + "tests": [ + "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" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index b4fafe62df..5bde31b88a 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -339,7 +339,8 @@ { "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": "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: `MockFunctionContext`", "symbols": ["calls", "ctx.callCount", "ctx.mockImplementation", "ctx.mockImplementationOnce", "ctx.resetCalls", "ctx.restore"], "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"] }, + { "chapter": "test", "parent": "Class: `MockFunctionContext`", "symbols": ["calls", "ctx.callCount", "ctx.mockImplementation", "ctx.mockImplementationOnce", "ctx.resetCalls", "ctx.restore"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-fn.ts", "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.before", "context.after", "context.beforeEach", "context.afterEach", "context.plan", "context.fullName"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/ctx-hooks.ts", "tests/fixtures/node-test/cases/ctx-plan.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "symbols": ["describe.skip", "describe.todo", "describe.only", "suite.skip", "suite.todo", "suite.only", "it.skip", "it.todo", "it.only"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/suite-variants.ts", "tests/fixtures/node-test/cases/only-nested.ts", "tests/fixtures/node-test/cases/todo-suites.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "util", "signatures": ["`util.promisify(original)`"], "status": "partial", "evidence": ["tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", "tests/corpus/2912-promisify-fs-readfile.ts", "tests/corpus/5625-promisify-exec.ts", "tests/corpus/5626-promisify-forms.ts", "tests/fixtures/node-types/promisify-fs.ts", "tests/corpus/5628-promisify-timers.ts", "tests/fixtures/node-types/promisify-timers.ts", "tests/corpus/5629-promisify-stream.ts", "tests/harness/project-config.test.ts"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 8aaf7a28ee..a4ba03db30 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3340,8 +3340,9 @@ declare module "node:test" { function after(fn: HookFn): void; function beforeEach(fn: HookFn): void; function afterEach(fn: HookFn): void; - /* The mock function surface: mock.fn and t.mock.fn, with the calls log - * and the implementation controls on fn.mock. */ + /* The mock function surface: mock.fn, mock.method and their t.mock + * twins, the tracker's restoreAll and reset, and the calls log and the + * implementation controls on fn.mock. */ interface MockFunctionCall< F extends Function, ReturnType = F extends (...args: any) => infer T ? T : unknown, @@ -3361,17 +3362,35 @@ declare module "node:test" { restore(): void; } type Mock = F & { mock: MockFunctionContext }; + interface MockFunctionOptions { + times?: number; + } + type FunctionPropertyNames = { + [K in keyof T]: T[K] extends Function ? K : never; + }[keyof T]; interface MockTracker { fn undefined>(original?: F): Mock; fn undefined, Implementation extends Function = F>( original?: F, implementation?: Implementation, ): Mock; - method( + method>( + object: MockedObject, + methodName: MethodName, + options?: MockFunctionOptions, + ): MockedObject[MethodName] extends Function ? Mock : never; + method< + MockedObject extends object, + MethodName extends FunctionPropertyNames, + Implementation extends Function, + >( object: MockedObject, - methodName: keyof MockedObject, - implementation?: Function, - ): Mock; + methodName: MethodName, + implementation: Implementation, + options?: MockFunctionOptions, + ): MockedObject[MethodName] extends Function + ? Mock + : never; reset(): void; restoreAll(): void; } diff --git a/packages/compiler/src/backend/llvm/lib-test.ts b/packages/compiler/src/backend/llvm/lib-test.ts index 3e73606056..79590cd9de 100644 --- a/packages/compiler/src/backend/llvm/lib-test.ts +++ b/packages/compiler/src/backend/llvm/lib-test.ts @@ -1,7 +1,6 @@ import { InternalCompilerError } from "../../errors.js"; import type { LibCallExpr, LlValue, LlvmEmitterContext } from "./expr-context.js"; import { abiValue, callRuntime, finishRuntimeCall, ptr } from "./lib-abi.js"; -import { f64Lit } from "./common.js"; export function emitTestLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const args = e.args.map((arg) => host.emitExpr(arg)), @@ -30,9 +29,9 @@ export function emitTestLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVal const promise = callRuntime(host, "scr_test_sub", "ptr", [ ...a.slice(0, 4), ptr(), - { type: "double", name: f64Lit(0) }, - a[4]!, + ...a.slice(4), ]); + host.emitPendingCheck(); callRuntime(host, "scr_promise_release", "void", [ptr(promise)]); return { name: "", type: e.type }; } @@ -53,6 +52,20 @@ export function emitTestLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVal return finishRuntimeCall(host, e, "scr_test_ctx_plan", a); case "test.ctxCount": return finishRuntimeCall(host, e, "scr_test_ctx_count", a); + case "test.mockTrack": + host.moveTemp(args[0]!); + return finishRuntimeCall(host, e, "scr_test_mock_track", a); + case "test.mockRestoreAll": + return finishRuntimeCall(host, e, "scr_test_mock_restore_all", a); + case "test.mockReset": + return finishRuntimeCall(host, e, "scr_test_mock_reset", a); + case "test.ctxMockTrack": + host.moveTemp(args[1]!); + return finishRuntimeCall(host, e, "scr_test_ctx_mock_track", a); + case "test.ctxMockRestoreAll": + return finishRuntimeCall(host, e, "scr_test_ctx_mock_restore_all", a); + case "test.ctxMockReset": + return finishRuntimeCall(host, e, "scr_test_ctx_mock_reset", a); default: throw new InternalCompilerError(`unhandled test call ${e.fn}`); } diff --git a/packages/compiler/src/frontend/lowering/lower-mock.ts b/packages/compiler/src/frontend/lowering/lower-mock.ts index 53488d6b44..b49cd045b7 100644 --- a/packages/compiler/src/frontend/lowering/lower-mock.ts +++ b/packages/compiler/src/frontend/lowering/lower-mock.ts @@ -1,5 +1,6 @@ /* node:test's mock functions (Node 24.22, lib/internal/test_runner/mock): - * mock.fn and t.mock.fn, and the fn.mock context's calls, callCount, + * mock.fn, mock.method and their t.mock twins, the trackers' restoreAll + * and reset, and the fn.mock context's calls, callCount, * mockImplementation, mockImplementationOnce, resetCalls and restore. * * mock.fn(original, implementation?) lowers to the hybrid record @@ -8,7 +9,14 @@ * one-time implementation registered for this call index, or the current * one, calls it, and appends { arguments, error, result, this } to the * calls log before returning or rethrowing. The context's mutating - * methods are lifted helpers of the same type. */ + * methods are lifted helpers of the same type. + * + * mock.method(object, name, implementation?) mocks a function-valued field + * of a record: the field gets a second wrapper over the same context, one + * that records the object as `this`, and the context's %unmock closure + * writes the original back on restore(). Each mock registers a closure + * that calls its restore() with its tracker (scr_test.c): the module's + * `mock`, or the test's t.mock, which is reset when the test ends. */ import * as ts from "../ts7/adapter.js"; import { BOOL, @@ -25,7 +33,9 @@ import { type SrcLoc, } from "../../ir/ir.js"; import { countedFor, dynUndefinedExpr, numLit, varRef } from "../../ir/build.js"; +import { mockType as mockHybridType } from "../mock-shapes.js"; import { locOf } from "../program.js"; +import { resultIsDiscarded } from "./call-position.js"; import type { Lowerer } from "./lowerer.js"; type FuncType = IrType & { kind: "func" }; @@ -36,10 +46,13 @@ interface MockContextFields { calls: IrType; once: IrType; onceEntry: IrType; + unmock: IrType; result: IrType; args: IrType; } +const RESTORE_FN: IrType = { kind: "func", params: [], ret: VOID }; + /** The context record's parts, or null when `type` is not a mock context * (mock-shapes.ts). */ function mockContextFields(lowerer: Lowerer, type: IrType): MockContextFields | null { @@ -49,10 +62,12 @@ function mockContextFields(lowerer: Lowerer, type: IrType): MockContextFields | const impl = field("%impl"); const once = field("%once"); const calls = field("calls"); + const unmock = field("%unmock"); if ( impl?.kind !== "func" || once?.kind !== "array" || calls?.kind !== "array" || + unmock?.kind !== "array" || field("%original") === undefined ) return null; @@ -61,7 +76,7 @@ function mockContextFields(lowerer: Lowerer, type: IrType): MockContextFields | const result = callShape?.fields.find((f) => f.name === "result")?.type; const args = callShape?.fields.find((f) => f.name === "arguments")?.type; if (!result || !args) return null; - return { fn: impl, call, calls, once, onceEntry: once.elem, result, args }; + return { fn: impl, call, calls, once, onceEntry: once.elem, unmock, result, args }; } export function isMockContextType(lowerer: Lowerer, type: IrType): boolean { @@ -102,14 +117,22 @@ const local = (id: string, name: string, type: IrType, mutable = false): IrLocal mutable, }); -/** Lifts one function per (context type, role), cached on the lowerer. */ +/** A captured local of a lifted closure. */ +const boxedLocal = (id: string, name: string, type: IrType): IrLocal => ({ + ...local(id, name, type), + boxed: true, +}); + +/** Lifts one function per (context type, role), cached on the lowerer. + * `owner` overrides the context type's shape as the cache key. */ function liftOnce( lowerer: Lowerer, ctxType: IrType, role: string, build: (name: string) => void, + owner: string = (ctxType as IrType & { kind: "record" }).shapeId, ): string { - const key = `mock.${role}:${(ctxType as IrType & { kind: "record" }).shapeId}`; + const key = `mock.${role}:${owner}`; const existing = lowerer.assertHelpers.get(key); if (existing) return existing; const name = `%mock.${role}.${lowerer.assertHelpers.size}`; @@ -118,9 +141,15 @@ function liftOnce( return name; } -/** `%mock.wrap.N(ctx)`: the closure that records each call into ctx. */ -function mockWrapperFactory(lowerer: Lowerer, ctxType: IrType, loc: SrcLoc): string { - return liftOnce(lowerer, ctxType, "wrap", (name) => { +/** `%mock.wrap.N(ctx)`: the closure that records each call into ctx, with + * `this` undefined. `%mock.wrapMethod.N(ctx, this)` records `this`. */ +function mockWrapperFactory( + lowerer: Lowerer, + ctxType: IrType, + loc: SrcLoc, + withThis = false, +): string { + return liftOnce(lowerer, ctxType, withThis ? "wrapMethod" : "wrap", (name) => { const { fn, call, calls, once, onceEntry, result, args } = mockContextFields(lowerer, ctxType)!; const impl = `${name}.impl`; const ctx = () => varRef("ctx.0", ctxType, loc); @@ -129,12 +158,14 @@ function mockWrapperFactory(lowerer: Lowerer, ctxType: IrType, loc: SrcLoc): str const argumentsValue = (): IrExpr => fn.rest ? paramRefs()[0]! - : { - kind: "recordLit", - fields: paramRefs().map((value, i) => ({ name: String(i), value })), - type: args, - loc, - }; + : args.kind === "array" + ? { kind: "arrayLit", elems: [], type: args, loc } + : { + kind: "recordLit", + fields: paramRefs().map((value, i) => ({ name: String(i), value })), + type: args, + loc, + }; const record = (resultValue: IrExpr, error: IrExpr): IrStmt => ({ kind: "exprStmt", expr: { @@ -148,7 +179,10 @@ function mockWrapperFactory(lowerer: Lowerer, ctxType: IrType, loc: SrcLoc): str { name: "arguments", value: argumentsValue() }, { name: "error", value: error }, { name: "result", value: resultValue }, - { name: "this", value: dynUndefinedExpr(loc) }, + { + name: "this", + value: withThis ? varRef("this.0", DYN, loc) : dynUndefinedExpr(loc), + }, ], type: call, loc, @@ -254,10 +288,14 @@ function mockWrapperFactory(lowerer: Lowerer, ctxType: IrType, loc: SrcLoc): str name: impl, params, returnType: fn.ret, - captures: [{ localId: "ctx.0", name: "ctx", type: ctxType }], + captures: [ + { localId: "ctx.0", name: "ctx", type: ctxType }, + ...(withThis ? [{ localId: "this.0", name: "this", type: DYN }] : []), + ], locals: [ ...params.map((p) => local(p.localId, p.name, p.type)), - { ...local("ctx.0", "ctx", ctxType), boxed: true }, + boxedLocal("ctx.0", "ctx", ctxType), + ...(withThis ? [boxedLocal("this.0", "this", DYN)] : []), local("n.0", "index", F64), local("f.0", "implementation", fn, true), local("i.0", "entry", F64, true), @@ -279,13 +317,25 @@ function mockWrapperFactory(lowerer: Lowerer, ctxType: IrType, loc: SrcLoc): str }); lowerer.liftedFns.push({ name, - params: [{ localId: "ctx.0", name: "ctx", type: ctxType }], + params: [ + { localId: "ctx.0", name: "ctx", type: ctxType }, + ...(withThis ? [{ localId: "this.0", name: "this", type: DYN }] : []), + ], returnType: fn, - locals: [{ ...local("ctx.0", "ctx", ctxType), boxed: true }], + locals: [ + boxedLocal("ctx.0", "ctx", ctxType), + ...(withThis ? [boxedLocal("this.0", "this", DYN)] : []), + ], body: [ { kind: "return", - value: { kind: "closure", fnName: impl, captures: ["ctx.0"], type: fn, loc }, + value: { + kind: "closure", + fnName: impl, + captures: withThis ? ["ctx.0", "this.0"] : ["ctx.0"], + type: fn, + loc, + }, loc, }, ], @@ -294,6 +344,160 @@ function mockWrapperFactory(lowerer: Lowerer, ctxType: IrType, loc: SrcLoc): str }); } +/** `%mock.restore.N(ctx)`: fn.mock.restore(). A method mock writes the + * original back to its object and keeps its implementation; a mock.fn mock + * goes back to its original implementation. */ +function mockRestoreHelper(lowerer: Lowerer, ctxType: IrType, loc: SrcLoc): string { + const { fn, unmock } = mockContextFields(lowerer, ctxType)!; + return mockHelper( + lowerer, + ctxType, + "restore", + [], + (ctx) => { + const unmockArray = () => recordGet(ctx(), "%unmock", unmock, loc); + return [ + { + kind: "if", + cond: { + kind: "bin", + op: "===", + left: length(unmockArray(), loc), + right: numLit(0, loc), + type: BOOL, + loc, + }, + then: [recordSet(ctx(), "%impl", recordGet(ctx(), "%original", fn, loc), loc)], + else_: [ + { + kind: "exprStmt", + expr: { + kind: "callValue", + callee: { + kind: "arrayGet", + arr: unmockArray(), + index: numLit(0, loc), + type: RESTORE_FN, + loc, + }, + args: [], + type: VOID, + loc, + }, + loc, + }, + ], + loc, + }, + ]; + }, + loc, + ); +} + +/** `%mock.tracked.N(ctx)`: the closure a tracker keeps, which calls the + * mock's restore(). */ +function mockTrackedFactory(lowerer: Lowerer, ctxType: IrType, loc: SrcLoc): string { + const restore = mockRestoreHelper(lowerer, ctxType, loc); + return liftOnce(lowerer, ctxType, "tracked", (name) => { + const impl = `${name}.impl`; + lowerer.liftedFns.push({ + name: impl, + params: [], + returnType: VOID, + captures: [{ localId: "ctx.0", name: "ctx", type: ctxType }], + locals: [boxedLocal("ctx.0", "ctx", ctxType)], + body: [ + { + kind: "exprStmt", + expr: { + kind: "call", + callee: restore, + args: [varRef("ctx.0", ctxType, loc)], + type: VOID, + loc, + }, + loc, + }, + { kind: "return", value: null, loc }, + ], + loc, + }); + lowerer.liftedFns.push({ + name, + params: [{ localId: "ctx.0", name: "ctx", type: ctxType }], + returnType: RESTORE_FN, + locals: [boxedLocal("ctx.0", "ctx", ctxType)], + body: [ + { + kind: "return", + value: { kind: "closure", fnName: impl, captures: ["ctx.0"], type: RESTORE_FN, loc }, + loc, + }, + ], + loc, + }); + }); +} + +/** `%mock.unmock.N(object, original)`: the closure that writes `original` + * back to the object's field. */ +function mockUnmockFactory( + lowerer: Lowerer, + ctxType: IrType, + objType: IrType & { kind: "record" }, + field: string, + loc: SrcLoc, +): string { + const { fn } = mockContextFields(lowerer, ctxType)!; + return liftOnce( + lowerer, + ctxType, + "unmock", + (name) => { + const impl = `${name}.impl`; + const captures = [ + { localId: "obj.0", name: "object", type: objType as IrType }, + { localId: "orig.0", name: "original", type: fn as IrType }, + ]; + const boxedLocals = captures.map((c) => boxedLocal(c.localId, c.name, c.type)); + lowerer.liftedFns.push({ + name: impl, + params: [], + returnType: VOID, + captures, + locals: boxedLocals, + body: [ + recordSet(varRef("obj.0", objType, loc), field, varRef("orig.0", fn, loc), loc), + { kind: "return", value: null, loc }, + ], + loc, + }); + lowerer.liftedFns.push({ + name, + params: captures, + returnType: RESTORE_FN, + locals: boxedLocals, + body: [ + { + kind: "return", + value: { + kind: "closure", + fnName: impl, + captures: ["obj.0", "orig.0"], + type: RESTORE_FN, + loc, + }, + loc, + }, + ], + loc, + }); + }, + `${objType.shapeId}.${JSON.stringify(field)}`, + ); +} + /** A VOID helper over the context: `%mock..N(ctx, ...extra)`. */ function mockHelper( lowerer: Lowerer, @@ -323,8 +527,114 @@ function mockHelper( }); } +/** The lowered TestContext of a t.mock call, or null for the module's + * `mock`. */ +export type MockTracker = IrExpr | null; + +/** Registers a mock's restore with its tracker. */ +function trackMock( + lowerer: Lowerer, + tracker: MockTracker, + ctxType: IrType, + ctx: IrExpr, + loc: SrcLoc, +): IrStmt { + const restore: IrExpr = { + kind: "call", + callee: mockTrackedFactory(lowerer, ctxType, loc), + args: [ctx], + type: RESTORE_FN, + loc, + }; + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: tracker ? "test.ctxMockTrack" : "test.mockTrack", + args: tracker ? [tracker, restore] : [restore], + type: VOID, + loc, + }, + loc, + }; +} + +/** The context record literal of a new mock. */ +function mockContextLiteral( + ctxType: IrType, + fields: MockContextFields, + implementation: IrExpr, + original: IrExpr, + unmock: IrExpr[], + loc: SrcLoc, +): IrExpr { + return { + kind: "recordLit", + fields: [ + { name: "%impl", value: implementation }, + { name: "%once", value: { kind: "arrayLit", elems: [], type: fields.once, loc } }, + { name: "%original", value: original }, + { name: "%unmock", value: { kind: "arrayLit", elems: unmock, type: fields.unmock, loc } }, + { name: "calls", value: { kind: "arrayLit", elems: [], type: fields.calls, loc } }, + ], + type: ctxType, + loc, + }; +} + +/** The Mock record over a context: { %call: wrapper, mock: context }. */ +function mockRecord( + lowerer: Lowerer, + mockType: IrType, + ctxType: IrType, + ctx: IrExpr, + loc: SrcLoc, +): IrExpr { + const { fn } = mockContextFields(lowerer, ctxType)!; + return { + kind: "recordLit", + fields: [ + { + name: "%call", + value: { + kind: "call", + callee: mockWrapperFactory(lowerer, ctxType, loc), + args: [ctx], + type: fn, + loc, + }, + }, + { name: "mock", value: ctx }, + ], + type: mockType, + loc, + }; +} + +/** A union return type needs an undefined arm: the calls log records + * undefined for calls that throw. */ +function requireUndefinedResultArm( + lowerer: Lowerer, + fields: MockContextFields, + call: ts.CallExpression, + what: string, +): void { + if (fields.fn.ret.kind === "union" && typeKey(fields.result) !== typeKey(fields.fn.ret)) { + lowerer.noLowering( + `${what} of a function returning '${lowerer.fmt(fields.fn.ret)}'`, + call, + "a union return type needs an undefined arm here (the calls log records undefined for calls that throw)", + ); + } +} + /** mock.fn(original, implementation?) and t.mock.fn(...). */ -export function lowerMockFn(lowerer: Lowerer, call: ts.CallExpression, what: string): IrExpr { +export function lowerMockFn( + lowerer: Lowerer, + call: ts.CallExpression, + what: string, + tracker: MockTracker, +): IrExpr { const loc = locOf(call); const mockType = lowerer.mapTypeOf(lowerer.typeOf(call)); const shape = mockType?.kind === "record" ? lowerer.shapes.get(mockType.shapeId) : undefined; @@ -340,17 +650,12 @@ export function lowerMockFn(lowerer: Lowerer, call: ts.CallExpression, what: str lowerer.noLowering( `${what} of this function type`, call, - "mock functions lower for functions with fixed parameters, or a single rest array parameter", - ); - } - const { fn, calls, once, result } = mockContextFields(lowerer, ctxType!)!; - if (fn.ret.kind === "union" && typeKey(result) !== typeKey(fn.ret)) { - lowerer.noLowering( - `${what} of a function returning '${lowerer.fmt(fn.ret)}'`, - call, - "a union return type needs an undefined arm here (the calls log records undefined for calls that throw)", + "mock functions lower for functions with fixed parameters, or a single rest array parameter, and an implementation of the same type", ); } + const fields = mockContextFields(lowerer, ctxType!)!; + const { fn } = fields; + requireUndefinedResultArm(lowerer, fields, call, what); const args = call.arguments; if (args.length === 0 || args.length > 2 || args.some(ts.isSpreadElement)) { lowerer.noLowering( @@ -372,49 +677,191 @@ export function lowerMockFn(lowerer: Lowerer, call: ts.CallExpression, what: str ? lowerer.lowerExprExpecting(args[1]!, fn) : varRef(originalLocal.id, fn, loc); const ctxLocal = lowerer.declareHiddenLocal("%mockContext", ctxType!); - stmts.push({ - kind: "varDecl", - localId: ctxLocal.id, - init: { - kind: "recordLit", - fields: [ - { name: "%impl", value: implementation }, - { name: "%once", value: { kind: "arrayLit", elems: [], type: once, loc } }, - { name: "%original", value: varRef(originalLocal.id, fn, loc) }, - { name: "calls", value: { kind: "arrayLit", elems: [], type: calls, loc } }, - ], - type: ctxType!, + const ctx = () => varRef(ctxLocal.id, ctxType!, loc); + stmts.push( + { + kind: "varDecl", + localId: ctxLocal.id, + init: mockContextLiteral( + ctxType!, + fields, + implementation, + varRef(originalLocal.id, fn, loc), + [], + loc, + ), loc, }, - loc, - }); - const factory = mockWrapperFactory(lowerer, ctxType!, loc); + trackMock(lowerer, tracker, ctxType!, ctx(), loc), + ); return { kind: "seqExpr", stmts, - result: { - kind: "recordLit", - fields: [ - { - name: "%call", - value: { - kind: "call", - callee: factory, - args: [varRef(ctxLocal.id, ctxType!, loc)], - type: fn, - loc, - }, - }, - { name: "mock", value: varRef(ctxLocal.id, ctxType!, loc) }, - ], - type: mockType!, + result: mockRecord(lowerer, mockType!, ctxType!, ctx(), loc), + type: mockType!, + loc, + }; +} + +const METHOD_HINT = + "mock.method lowers for objects whose type maps to a record (object literals and interface-typed objects) and a function-valued field named by a string literal"; + +/** mock.method(object, methodName, implementation?) and t.mock.method. The + * field's function type F is the mock's type; an implementation must have + * the same type, unless the call's result is discarded. */ +export function lowerMockMethod( + lowerer: Lowerer, + call: ts.CallExpression, + what: string, + tracker: MockTracker, +): IrExpr { + const loc = locOf(call); + const args = call.arguments; + if (args.length < 2 || args.length > 3 || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + `${what} with ${args.length} arguments`, + call, + "the supported forms are mock.method(object, methodName) and mock.method(object, methodName, implementation); mock options have no lowering", + ); + } + const nameArg = args[1]!; + if (!ts.isStringLiteralLike(nameArg)) { + lowerer.noLowering(`${what} with a computed method name`, nameArg, METHOD_HINT); + } + const field = (nameArg as ts.StringLiteralLike).text; + if ( + args.length === 3 && + lowerer.checker.getCallSignatures(lowerer.typeOf(args[2]!)).length === 0 + ) { + lowerer.noLowering( + `${what} with an options object`, + args[2]!, + "mock options (times, getter and setter) have no lowering", + ); + } + const objType = lowerer.mapTypeOf(lowerer.typeOf(args[0]!)); + const objShape = objType?.kind === "record" ? lowerer.shapes.get(objType.shapeId) : undefined; + const fn = objShape?.fields.find((f) => f.name === field)?.type; + if (!objType || !objShape || fn?.kind !== "func") { + const typeName = lowerer.typeOf(args[0]!).getSymbol()?.name; + lowerer.noLowering( + typeName && !typeName.startsWith("__") + ? `${what} on '${typeName}'` + : `${what} on this object`, + args[0]!, + METHOD_HINT, + ); + } + const mockType = mockHybridType(fn as FuncType, lowerer.typeCtx); + const ctxType = + mockType?.kind === "record" + ? lowerer.shapes.get(mockType.shapeId)?.fields.find((f) => f.name === "mock")?.type + : undefined; + if (!mockType || !ctxType || !isMockContextType(lowerer, ctxType)) { + lowerer.noLowering( + `${what} of a '${lowerer.fmt(fn!)}' method`, + call, + "mock functions lower for functions with fixed parameters, or a single rest array parameter", + ); + } + if (!resultIsDiscarded(call)) { + const resultType = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (!resultType || typeKey(resultType) !== typeKey(mockType!)) { + lowerer.noLowering( + `${what} with an implementation of another type`, + args[2] ?? call, + "give the implementation the method's parameter and return types, or discard the result", + ); + } + } + const fields = mockContextFields(lowerer, ctxType!)!; + requireUndefinedResultArm(lowerer, fields, call, what); + const stmts: IrStmt[] = []; + const objLocal = lowerer.declareHiddenLocal("%mockObject", objType!); + const obj = () => varRef(objLocal.id, objType!, loc); + stmts.push({ kind: "varDecl", localId: objLocal.id, init: lowerer.lowerExpr(args[0]!), loc }); + const originalLocal = lowerer.declareHiddenLocal("%mockOriginal", fn!); + const original = () => varRef(originalLocal.id, fn!, loc); + stmts.push({ + kind: "varDecl", + localId: originalLocal.id, + init: recordGet(obj(), field, fn!, loc), + loc, + }); + const implementation = args.length === 3 ? lowerer.lowerExprExpecting(args[2]!, fn!) : original(); + const unmock: IrExpr = { + kind: "call", + callee: mockUnmockFactory( + lowerer, + ctxType!, + objType as IrType & { kind: "record" }, + field, + loc, + ), + args: [obj(), original()], + type: RESTORE_FN, + loc, + }; + const ctxLocal = lowerer.declareHiddenLocal("%mockContext", ctxType!); + const ctx = () => varRef(ctxLocal.id, ctxType!, loc); + stmts.push( + { + kind: "varDecl", + localId: ctxLocal.id, + init: mockContextLiteral(ctxType!, fields, implementation, original(), [unmock], loc), loc, }, + trackMock(lowerer, tracker, ctxType!, ctx(), loc), + // The object's field: the wrapper that records the object as `this`. + recordSet( + obj(), + field, + { + kind: "call", + callee: mockWrapperFactory(lowerer, ctxType!, loc, true), + args: [ctx(), lowerer.coerceToExpected(obj(), DYN)], + type: fn!, + loc, + }, + loc, + ), + ); + return { + kind: "seqExpr", + stmts, + result: mockRecord(lowerer, mockType!, ctxType!, ctx(), loc), type: mockType!, loc, }; } +/** The trackers' restoreAll() and reset(): `mock.restoreAll()`, + * `t.mock.reset()`. */ +export function lowerMockTrackerCall( + lowerer: Lowerer, + call: ts.CallExpression, + member: "restoreAll" | "reset", + tracker: MockTracker, +): IrExpr { + if (call.arguments.length > 0) { + lowerer.noLowering(`MockTracker.${member} with ${call.arguments.length} arguments`, call); + } + const fn = tracker + ? member === "reset" + ? "test.ctxMockReset" + : "test.ctxMockRestoreAll" + : member === "reset" + ? "test.mockReset" + : "test.mockRestoreAll"; + return { + kind: "libCall", + fn, + args: tracker ? [tracker] : [], + type: VOID, + loc: locOf(call), + }; +} + /** `fn.mock.calls`: a copy of the log, as Node's getter returns. Null for * other reads. */ export function lowerMockContextProperty( @@ -585,18 +1032,9 @@ export function lowerMockContextCall( ); return helperCall(helper, []); } - case "restore": { + case "restore": arity(0); - const helper = mockHelper( - lowerer, - ctxType, - "restore", - [], - (ctx) => [recordSet(ctx(), "%impl", recordGet(ctx(), "%original", fn, loc), loc)], - loc, - ); - return helperCall(helper, []); - } + return helperCall(mockRestoreHelper(lowerer, ctxType, loc), []); default: return lowerer.noLowering( `MockFunctionContext.${member}`, diff --git a/packages/compiler/src/frontend/lowering/lower-test.ts b/packages/compiler/src/frontend/lowering/lower-test.ts index f7a7b76ee6..a9a7217b05 100644 --- a/packages/compiler/src/frontend/lowering/lower-test.ts +++ b/packages/compiler/src/frontend/lowering/lower-test.ts @@ -18,6 +18,7 @@ import type { Lowerer } from "./lowerer.js"; import { locOf } from "../program.js"; import { type IrExpr, + type IrStmt, type IrType, type SrcLoc, F64, @@ -26,7 +27,39 @@ import { isUnitType, } from "../../ir/ir.js"; import { numLit, strLit } from "../../ir/build.js"; -import { lowerMockContextCall, lowerMockContextProperty, lowerMockFn } from "./lower-mock.js"; +import { + lowerMockContextCall, + lowerMockContextProperty, + lowerMockFn, + lowerMockMethod, + lowerMockTrackerCall, + type MockTracker, +} from "./lower-mock.js"; + +const MOCK_HINT = + "mock.fn, mock.method, mock.restoreAll and mock.reset are the supported mock members"; + +/** The mock tracker members that lower, for the module's `mock` (tracker + * null) and t.mock. Null for other members. */ +function lowerMockTrackerMember( + lowerer: Lowerer, + call: ts.CallExpression, + member: string, + what: string, + tracker: () => MockTracker, +): IrExpr | null { + switch (member) { + case "fn": + return lowerMockFn(lowerer, call, `${what}.fn`, tracker()); + case "method": + return lowerMockMethod(lowerer, call, `${what}.method`, tracker()); + case "restoreAll": + case "reset": + return lowerMockTrackerCall(lowerer, call, member, tracker()); + default: + return null; + } +} const TEST_FN_HINT = "test bodies take () or (t: TestContext) and return void or Promise"; const TESTCTX_SURFACE_HINT = @@ -61,68 +94,126 @@ interface TestOptions { mode: number; msg: IrExpr | null; only: boolean; + /** The { plan } count, -1 without one. */ + plan: number; + /** The { timeout } in ms, null without one. */ + timeout: IrExpr | null; + /** The non-literal option values, evaluated in source order before the + * registration call reads them. */ + pre: IrStmt[]; +} + +const noOptions = (): TestOptions => ({ + mode: 0, + msg: null, + only: false, + plan: -1, + timeout: null, + pre: [], +}); + +/** Evaluates a non-literal option value into a hidden local, so option + * values run in source order. */ +function optionValue(lowerer: Lowerer, out: TestOptions, value: IrExpr, loc: SrcLoc): IrExpr { + if (value.kind === "strLit" || value.kind === "numLit") return value; + const slot = lowerer.declareHiddenLocal("%testOption", value.type); + out.pre.push({ kind: "varDecl", localId: slot.id, init: value, loc }); + return { kind: "varRef", localId: slot.id, type: value.type, loc }; } /** Parses a test/describe options argument — an OBJECT LITERAL whose * skip/todo values are boolean literals or string messages and whose - * only is the boolean literal. Everything else fences with the option - * name (concurrency/timeout/plan included — bounded surface, honest - * fence). */ -function lowerTestOptions(lowerer: Lowerer, node: ts.Expression, what: string): TestOptions { + * only is the boolean literal. Tests also take plan, a non-negative + * integer literal, and timeout, a number. Everything else fences with the + * option name (concurrency included — bounded surface, honest fence). */ +function lowerTestOptions( + lowerer: Lowerer, + node: ts.Expression, + what: string, + suite: boolean, +): TestOptions { if (!ts.isObjectLiteralExpression(node)) { lowerer.noLowering( `${what} with a non-literal options argument`, node, - "pass the options as an object literal: { skip?, todo?, only? }", + suite + ? "pass the options as an object literal: { skip?, todo?, only? }" + : "pass the options as an object literal: { skip?, todo?, only?, plan?, timeout? }", ); } - const out: TestOptions = { mode: 0, msg: null, only: false }; + const out = noOptions(); for (const prop of node.properties) { - if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) { + if ( + !(ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) && + !ts.isShorthandPropertyAssignment(prop) + ) { lowerer.noLowering( - `${what} options with computed keys, spreads, or shorthand entries`, + `${what} options with computed keys or spreads`, prop, - "each option must be a plain `name: value` entry with a literal key", + "each option must be a `name: value` or shorthand entry with a literal key", ); } - const name = prop.name.text; + const key = prop.name as ts.Identifier; + const name = key.text; + // A shorthand entry's value is the binding of the same name. + const init: ts.Expression = ts.isPropertyAssignment(prop) ? prop.initializer : key; if (name === "skip" || name === "todo") { const mode = name === "skip" ? 1 : 2; - const t = lowerer.typeOf(prop.initializer); + const t = lowerer.typeOf(init); if (t.isStringLiteralType() || lowerer.mapTypeOf(t)?.kind === "string") { out.mode = mode; - out.msg = lowerer.lowerExprExpecting(prop.initializer, STRING); - } else if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { + out.msg = optionValue(lowerer, out, lowerer.lowerExprExpecting(init, STRING), locOf(init)); + } else if (init.kind === ts.SyntaxKind.TrueKeyword) { out.mode = mode; - } else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) { + } else if (init.kind === ts.SyntaxKind.FalseKeyword) { // explicit false: run normally } else { lowerer.noLowering( `${what} with a non-literal ${name} value`, - prop.initializer, + init, `${name} takes the literal true/false or a string message here`, ); } } else if (name === "only") { - if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) out.only = true; - else if (prop.initializer.kind !== ts.SyntaxKind.FalseKeyword) { + if (init.kind === ts.SyntaxKind.TrueKeyword) out.only = true; + else if (init.kind !== ts.SyntaxKind.FalseKeyword) { lowerer.noLowering( `${what} with a non-literal only value`, - prop.initializer, + init, "only takes the literal true/false here", ); } + } else if (name === "plan" && !suite) { + const value = ts.isNumericLiteral(init) ? Number(init.text) : NaN; + if (!Number.isInteger(value) || value > 2147483647) { + lowerer.noLowering( + `${what} with a non-literal plan value`, + init, + "plan takes a non-negative integer literal here", + ); + } + out.plan = value; + } else if (name === "timeout" && !suite) { + out.timeout = optionValue(lowerer, out, lowerer.lowerExprExpecting(init, F64), locOf(init)); } else { lowerer.noLowering( `${what} option '${name}'`, prop, - "skip, todo, and only are the supported options", + suite + ? "skip, todo, and only are the supported suite options" + : "skip, todo, only, plan, and timeout are the supported options", ); } } return out; } +/** The registration call, after the option values it reads. */ +function withOptionValues(call: IrExpr, opts: TestOptions, loc: SrcLoc): IrExpr { + if (opts.pre.length === 0) return call; + return { kind: "seqExpr", stmts: opts.pre, result: call, type: call.type, loc }; +} + /** Lowers a test/hook body, pinning the shape: 0 params or the one * TestContext param, returning void (sync) or Promise (async — * the runner awaits through the emitted spawn wrapper). Returns the @@ -187,50 +278,77 @@ function lowerRegistration( "the supported forms are (name), (name, fn), and (name, options, fn)", ); } - const name = lowerer.lowerExprExpecting(args[0]!, STRING); - let opts: TestOptions = { mode: 0, msg: null, only: false }; + let name = lowerer.lowerExprExpecting(args[0]!, STRING); + let opts = noOptions(); let fnNode: ts.Expression | undefined; if (args.length === 2) fnNode = args[1]; if (args.length === 3) { - opts = lowerTestOptions(lowerer, args[1]!, what); + opts = lowerTestOptions(lowerer, args[1]!, what, false); fnNode = args[2]; + // The name runs before the option values. + if (opts.pre.length > 0 && name.kind !== "strLit") { + const slot = lowerer.declareHiddenLocal("%testName", STRING); + opts.pre.unshift({ kind: "varDecl", localId: slot.id, init: name, loc }); + name = { kind: "varRef", localId: slot.id, type: STRING, loc }; + } } if (methodMode === 1 || methodMode === 2) opts.mode = methodMode; if (methodMode === 3) opts.only = true; const at = strLit(atStringOf(expr), loc); const mode = numLit(opts.mode, loc); const msg = opts.msg ?? strLit("", loc); + // Flag 8: a { timeout } is given. + const optionFlags = (opts.only ? 4 : 0) | (opts.timeout ? 8 : 0); + const planTimeout = [numLit(opts.plan, loc), opts.timeout ?? numLit(0, loc)]; if (!fnNode) { - const emptyFlags = numLit(opts.only ? 4 : 0, loc); - return sub - ? { kind: "libCall", fn: "test.subEmpty", args: [sub, name, mode, msg, at], type: VOID, loc } - : { - kind: "libCall", - fn: "test.registerEmpty", - args: [name, mode, msg, emptyFlags, at], - type: VOID, - loc, - }; + const emptyFlags = numLit(optionFlags, loc); + return withOptionValues( + sub + ? { + kind: "libCall", + fn: "test.subEmpty", + args: [sub, name, mode, msg, emptyFlags, at, ...planTimeout], + type: VOID, + loc, + } + : { + kind: "libCall", + fn: "test.registerEmpty", + args: [name, mode, msg, emptyFlags, at, ...planTimeout], + type: VOID, + loc, + }, + opts, + loc, + ); } const { cb, flags } = lowerBodyArg(lowerer, fnNode, what, true); - const flagsLit = numLit(flags | (opts.only ? 4 : 0), loc); + const flagsLit = numLit(flags | optionFlags, loc); if (sub) { const promiseVoid: IrType = { kind: "promise", inner: VOID }; - return { - kind: "libCall", - fn: "test.sub", - args: [sub, name, mode, msg, cb, flagsLit, at], - type: promiseVoid, + return withOptionValues( + { + kind: "libCall", + fn: "test.sub", + args: [sub, name, mode, msg, cb, flagsLit, at, ...planTimeout], + type: promiseVoid, + loc, + }, + opts, loc, - }; + ); } - return { - kind: "libCall", - fn: "test.register", - args: [name, mode, msg, cb, flagsLit, at], - type: VOID, + return withOptionValues( + { + kind: "libCall", + fn: "test.register", + args: [name, mode, msg, cb, flagsLit, at, ...planTimeout], + type: VOID, + loc, + }, + opts, loc, - }; + ); } /** describe/suite (and describe.skip/todo/only): the body is a SYNC @@ -251,12 +369,17 @@ function lowerSuite( "the supported forms are (name, fn) and (name, options, fn)", ); } - const name = lowerer.lowerExprExpecting(args[0]!, STRING); - let opts: TestOptions = { mode: 0, msg: null, only: false }; + let name = lowerer.lowerExprExpecting(args[0]!, STRING); + let opts = noOptions(); let fnNode = args[1]!; if (args.length === 3) { - opts = lowerTestOptions(lowerer, args[1]!, what); + opts = lowerTestOptions(lowerer, args[1]!, what, true); fnNode = args[2]!; + if (opts.pre.length > 0 && name.kind !== "strLit") { + const slot = lowerer.declareHiddenLocal("%testName", STRING); + opts.pre.unshift({ kind: "varDecl", localId: slot.id, init: name, loc }); + name = { kind: "varRef", localId: slot.id, type: STRING, loc }; + } } if (methodMode === 1 || methodMode === 2) opts.mode = methodMode; const cb = lowerer.lowerExpr(fnNode); @@ -279,13 +402,17 @@ function lowerSuite( const msg = opts.msg ?? strLit("", loc); // describe.only and { only: true } mark the suite (flag 4, as for tests). const flags = numLit(methodMode === 3 || opts.only ? 4 : 0, loc); - return { - kind: "libCall", - fn: "test.suite", - args: [name, mode, msg, cb, at, flags], - type: VOID, + return withOptionValues( + { + kind: "libCall", + fn: "test.suite", + args: [name, mode, msg, cb, at, flags], + type: VOID, + loc, + }, + opts, loc, - }; + ); } /** before/after/beforeEach/afterEach — hooks on the enclosing suite @@ -452,12 +579,13 @@ export function lowerTestMethodCall( member === "skip" ? 1 : member === "todo" ? 2 : 3, ); } - if (bi.member === "mock" && member === "fn") return lowerMockFn(lowerer, call, "mock.fn"); if (bi.member === "mock") { + const served = lowerMockTrackerMember(lowerer, call, member, "mock", () => null); + if (served) return served; lowerer.noLowering( `test.mock.${member}`, call, - "mock.fn and t.mock.fn are the supported mock members", + MOCK_HINT, lowerer.checker.getSymbolAtLocation(access.name), ); } @@ -469,16 +597,19 @@ export function lowerTestMethodCall( ); } } - // t.mock.fn(...): the test's mock tracker. + // t.mock.fn(...) and the other tracker members: the test's mock tracker. if ( - member === "fn" && ts.isPropertyAccessExpression(access.expression) && !access.expression.questionDotToken && access.expression.name.text === "mock" && lowerer.mapTypeOf(lowerer.typeOf(access.expression.expression))?.kind === "testCtx" && lowerer.isStdlibMember(access.expression) ) { - return lowerMockFn(lowerer, call, "t.mock.fn"); + const testCtx = access.expression.expression; + const served = lowerMockTrackerMember(lowerer, call, member, "t.mock", () => + lowerer.lowerExpr(testCtx), + ); + if (served) return served; } // t.assert.strictEqual(...) — the callee's receiver is `t.assert` on a // TestContext: Node's t.assert methods ARE the assert functions (bound diff --git a/packages/compiler/src/frontend/mock-shapes.ts b/packages/compiler/src/frontend/mock-shapes.ts index bf0b2d63be..1baf2cd046 100644 --- a/packages/compiler/src/frontend/mock-shapes.ts +++ b/packages/compiler/src/frontend/mock-shapes.ts @@ -7,13 +7,16 @@ * %impl F the current implementation * %original F the implementation restore() brings back * %once { at, fn }[] mockImplementationOnce, by call index + * %unmock (() => void)[] for mock.method, the closure that + * writes the original back to the + * object; empty for mock.fn * * A MockFunctionCall is { arguments, error, result, this }: the * arguments as a tuple of F's parameters, the result as F's return type or * undefined (a checked-dynamic value for functions returning void or * undefined, since a record field cannot have a unit type), and error and * this as checked-dynamic values. */ -import { DYN, F64, UNDEFINED_T, typeKey, type IrType } from "../ir/ir.js"; +import { DYN, F64, NULL_T, UNDEFINED_T, VOID, typeKey, type IrType } from "../ir/ir.js"; import type { TypeMapperCtx } from "./type-mapper.js"; /** The mock records intern into the type mapper's registries. */ @@ -32,12 +35,18 @@ function orUndefined(t: IrType, reg: MockShapeRegistries): IrType { /** The arguments type of a call record: a tuple of the parameters, or the * rest array itself for a function whose only parameter is a rest array. - * Null for other rest shapes. */ + * Null for other rest shapes. A function without parameters records the + * empty tuple, which maps to an array of the unit-only union, as + * type-mapper.ts maps `[]`. */ function mockArgumentsType(fn: IrType & { kind: "func" }, reg: MockShapeRegistries): IrType | null { if (fn.rest) { if (fn.params.length !== 1 || fn.params[0]!.kind !== "array") return null; return fn.params[0]!; } + if (fn.params.length === 0) { + const arms = [NULL_T, UNDEFINED_T].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + return { kind: "array", elem: { kind: "union", unionId: reg.unions.intern(arms) } }; + } const fields = fn.params.map((type, i) => ({ name: String(i), type })); return { kind: "record", shapeId: reg.shapes.intern(fields, true) }; } @@ -77,7 +86,23 @@ export function mockContextType( { name: "%impl", type: fn }, { name: "%once", type: { kind: "array", elem: onceEntry } as IrType }, { name: "%original", type: fn }, + { + name: "%unmock", + type: { kind: "array", elem: { kind: "func", params: [], ret: VOID } } as IrType, + }, { name: "calls", type: { kind: "array", elem: call } as IrType }, ]; return { kind: "record", shapeId: reg.shapes.intern(fields, false, undefined, ["calls"]) }; } + +/** Mock: the hybrid record { %call: F, mock: MockFunctionContext }, + * interned as type-mapper.ts's function-with-properties mapping interns it. */ +export function mockType(fn: IrType & { kind: "func" }, reg: MockShapeRegistries): IrType | null { + const ctx = mockContextType(fn, reg); + if (ctx === null) return null; + const fields = [ + { name: "%call", type: fn }, + { name: "mock", type: ctx }, + ]; + return { kind: "record", shapeId: reg.shapes.intern(fields, false, undefined, ["mock"]) }; +} diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 7d667976c9..4159dbead4 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -3695,6 +3695,13 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) return DYN; } + // A union of such hybrids that all map to ONE record is that record: + // node:test's Mock distributes to (F & { mock }) | + // (Implementation & { mock }), and the two function types can map alike. + if (widened.isUnionType()) { + const hybrid = mapUniformHybridUnion(widened, ctx); + if (hybrid) return hybrid; + } const callSigs = checker.getCallSignatures(widened); if (callSigs.length === 1) { const sig = callSigs[0]!; @@ -4829,6 +4836,21 @@ function mapHybridCallableIntersection(widened: ts.Type, ctx: TypeMapperCtx): Ir return { kind: "record", shapeId: shapes.intern(fields, false, undefined, declaredOrder) }; } +/** The record every part of a union of function-with-properties + * intersections maps to, or null when a part does not map to the same one. */ +function mapUniformHybridUnion(widened: ts.Type, ctx: TypeMapperCtx): IrType | null { + const parts = ts.constituentTypes(widened); + if (!parts.every((p) => p.isIntersectionType())) return null; + let first: IrType | null = null; + for (const part of parts) { + if (ctx.checker.getConstructSignatures(part).length > 0) return null; + const mapped = mapHybridCallableIntersection(part, ctx); + if (!mapped || (first && typeKey(mapped) !== typeKey(first))) return null; + first ??= mapped; + } + return first; +} + /** Optional standard error metadata refines an existing Error identity; * it does not allocate a structural copy or extend its native layout. */ function mapErrorMetadataIntersection(type: ts.Type, ctx: TypeMapperCtx): IrType | null { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index c4347f0e60..b17a345874 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -10,6 +10,11 @@ import type { IrLibFn } from "./builtin-signatures.js"; export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ // Node's argument, cipher-name, IV, tag-length and key-length errors. "crypto.cipherNew", + // node:test's { timeout } range check. + "test.register", + "test.registerEmpty", + "test.sub", + "test.subEmpty", "console.write", "console.label", "console.countReset", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 70a01f7961..2f1fd2dbab 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1389,18 +1389,26 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * consumes. ctxSkip/ctxTodo mark the running test; ctxDiagnostic * queues an ℹ line; ctxName reads t.name. Every registration keeps * the loop-run emitted (usesTimers) so the runner fiber drains. */ - "test.register": { argTypes: [STRING, F64, STRING, null, F64, STRING], result: VOID }, - "test.registerEmpty": { argTypes: [STRING, F64, STRING, F64, STRING], result: VOID }, + // The trailing pair is the { plan, timeout } options (plan -1 without + // one; timeout read under flag 8). An out-of-range timeout throws. + "test.register": { + argTypes: [STRING, F64, STRING, null, F64, STRING, F64, F64], + result: VOID, + }, + "test.registerEmpty": { argTypes: [STRING, F64, STRING, F64, STRING, F64, F64], result: VOID }, "test.suite": { argTypes: [STRING, F64, STRING, { kind: "func", params: [], ret: VOID }, STRING, F64], result: VOID, }, "test.hook": { argTypes: [F64, null, F64], result: VOID }, "test.sub": { - argTypes: [TESTCTX_T, STRING, F64, STRING, null, F64, STRING], + argTypes: [TESTCTX_T, STRING, F64, STRING, null, F64, STRING, F64, F64], result: { kind: "promise", inner: VOID }, }, - "test.subEmpty": { argTypes: [TESTCTX_T, STRING, F64, STRING, STRING], result: VOID }, + "test.subEmpty": { + argTypes: [TESTCTX_T, STRING, F64, STRING, F64, STRING, F64, F64], + result: VOID, + }, "test.ctxSkip": { argTypes: [TESTCTX_T, STRING], result: VOID }, "test.ctxTodo": { argTypes: [TESTCTX_T, STRING], result: VOID }, "test.ctxDiagnostic": { argTypes: [TESTCTX_T, STRING], result: VOID }, @@ -1412,6 +1420,17 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ // t.plan(count), and the count of one t.assert call. "test.ctxPlan": { argTypes: [TESTCTX_T, F64], result: VOID }, "test.ctxCount": { argTypes: [TESTCTX_T], result: VOID }, + // The mock trackers: the module's `mock` and t.mock keep each mock's + // restore closure (moved) for restoreAll and reset. + "test.mockTrack": { argTypes: [{ kind: "func", params: [], ret: VOID }], result: VOID }, + "test.mockRestoreAll": { argTypes: [], result: VOID }, + "test.mockReset": { argTypes: [], result: VOID }, + "test.ctxMockTrack": { + argTypes: [TESTCTX_T, { kind: "func", params: [], ret: VOID }], + result: VOID, + }, + "test.ctxMockRestoreAll": { argTypes: [TESTCTX_T], result: VOID }, + "test.ctxMockReset": { argTypes: [TESTCTX_T], result: VOID }, // node:http (scr_http.c over scr_net.c). The handler and the data // listeners are program-dependent closures (null slots, checked in the // libCall case); reqHeader's result is the interned string|undefined diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5810-mock-method-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5810-mock-method-records.ts.json new file mode 100644 index 0000000000..819fa11b5e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5810-mock-method-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5810-mock-method-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5811-mock-tracker-restore.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5811-mock-tracker-restore.ts.json new file mode 100644 index 0000000000..5827dd4c32 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5811-mock-tracker-restore.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5811-mock-tracker-restore.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/test-option-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/test-option-fences.ts.json new file mode 100644 index 0000000000..09d662b4cc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/test-option-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/test-option-fences.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index c671046e41..e1d1de480d 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1728,6 +1728,7 @@ ScrStr *scr_promise_payload_str(ScrPromise *p) { static bool scr_await_settled(ScrPromise *p); /* defined with the await family */ int scr_promise_payload_kind(const ScrPromise *p) { return (int)p->payload_kind; } bool scr_promise_is_rejected(const ScrPromise *p) { return p->state == SCR_PROM_REJECTED; } +bool scr_promise_is_pending(const ScrPromise *p) { return p->state == SCR_PROM_PENDING; } bool scr_promise_payload_is_dyn(const ScrPromise *p) { return p->retain_fn == scr_dyn_retain_v; } double scr_promise_payload_num(const ScrPromise *p) { return p->f64; } bool scr_promise_payload_flag(const ScrPromise *p) { return p->b; } diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index 25de65cfe3..b671e5c84b 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -314,6 +314,7 @@ void *scr_promise_payload_ref(ScrPromise *p); /* +1 via the stored retain */ /* Thin payload views for the gated dyn-async TU (scr_async_dyn.c). */ int scr_promise_payload_kind(const ScrPromise *p); /* ScrExcKind */ bool scr_promise_is_rejected(const ScrPromise *p); +bool scr_promise_is_pending(const ScrPromise *p); bool scr_promise_payload_is_dyn(const ScrPromise *p); double scr_promise_payload_num(const ScrPromise *p); bool scr_promise_payload_flag(const ScrPromise *p); diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index 6f9fbbbcdb..425ead652e 100644 --- a/packages/runtime/src/scr_test.c +++ b/packages/runtime/src/scr_test.c @@ -55,7 +55,7 @@ static void scr_test_oom(void) { /* mode values (the frontend's literals) */ enum { SCR_TEST_RUN = 0, SCR_TEST_SKIP = 1, SCR_TEST_TODO = 2 }; /* flags bitmask */ -enum { SCR_TEST_F_ASYNC = 1, SCR_TEST_F_CTX = 2, SCR_TEST_F_ONLY = 4 }; +enum { SCR_TEST_F_ASYNC = 1, SCR_TEST_F_CTX = 2, SCR_TEST_F_ONLY = 4, SCR_TEST_F_TIMEOUT = 8 }; /* hook kinds */ enum { SCR_HOOK_BEFORE = 0, SCR_HOOK_AFTER = 1, SCR_HOOK_BEFORE_EACH = 2, SCR_HOOK_AFTER_EACH = 3 }; /* outcome states */ @@ -67,6 +67,7 @@ enum { SCR_TS_SKIPPED, SCR_TS_TODO_PASS, SCR_TS_TODO_FAIL, + SCR_TS_CANCELLED, /* { timeout } elapsed before the body settled */ }; typedef struct { @@ -101,6 +102,9 @@ struct ScrTestCtx { ScrCaught *before_err; /* owned; a throwing t.before, which fails the test and later subtests */ int plan; /* t.plan count, -1 without a plan */ int plan_count; /* t.assert calls and subtests counted against the plan */ + ScrTestHooks mocks; /* t.mock: the restore closure of each mock, in creation order */ + double timeout; /* { timeout } in ms, INFINITY without one */ + bool timed_out; /* the async body was still pending when the timeout elapsed */ }; static SCR_TL ScrTestCtx *scr_test_root = NULL; @@ -110,9 +114,12 @@ static SCR_TL ScrTestCtx *scr_test_reg_parent = NULL; static SCR_TL bool scr_test_runner_spawned = false; static SCR_TL bool scr_test_run_done = false; static SCR_TL long scr_test_ct_tests = 0, scr_test_ct_suites = 0, scr_test_ct_pass = 0, - scr_test_ct_fail = 0, scr_test_ct_skipped = 0, scr_test_ct_todo = 0; + scr_test_ct_fail = 0, scr_test_ct_cancelled = 0, scr_test_ct_skipped = 0, + scr_test_ct_todo = 0; static SCR_TL double scr_test_t0 = 0; static SCR_TL bool scr_test_suite_hook_failed = false; +/* The module's `mock` tracker: the restore closure of each mock it made. */ +static SCR_TL ScrTestHooks scr_test_mocks = {0}; #ifdef SCR_RC_AUDIT static SCR_TL long scr_testctx_live = 0; @@ -144,6 +151,7 @@ void scr_testctx_release(ScrTestCtx *t) { for (size_t i = 0; i < t->nchildren; i++) scr_testctx_release(t->children[i]); free(t->children); for (int k = 0; k < 4; k++) scr_test_hooks_free(&t->hooks[k]); + scr_test_hooks_free(&t->mocks); #ifdef SCR_RC_AUDIT scr_testctx_live--; #endif @@ -169,6 +177,7 @@ static ScrTestCtx *scr_test_node_new(ScrStr *name /*borrowed*/, int mode, t->flags = flags; t->is_suite = is_suite; t->plan = -1; + t->timeout = INFINITY; #ifdef SCR_RC_AUDIT scr_testctx_live++; #endif @@ -224,7 +233,8 @@ static const char *scr_test_symbol(int state) { case SCR_TS_PASS: case SCR_TS_TODO_PASS: return "\xE2\x9C\x94"; /* ✔ */ case SCR_TS_FAIL: - case SCR_TS_CHILDFAIL: return "\xE2\x9C\x96"; /* ✖ */ + case SCR_TS_CHILDFAIL: + case SCR_TS_CANCELLED: return "\xE2\x9C\x96"; /* ✖ */ case SCR_TS_SKIPPED: return "\xEF\xB9\xA3"; /* ﹣ */ case SCR_TS_TODO_FAIL: return "\xE2\x9A\xA0"; /* ⚠ */ default: return "?"; @@ -277,18 +287,45 @@ static void scr_test_report_result(ScrTestCtx *t) { /* ── running ─────────────────────────────────────────────────────────── */ +/* An async test body with { timeout }: awaits the body's promise or a + * timer, whichever settles first. When the timer wins, the test fails with + * Node's "test timed out after ms" string and is cancelled; the + * body keeps running unobserved. */ +static void scr_test_await_timeout(ScrPromise *p, ScrTestCtx *t) { + ScrPromise *timer = scr_promise_new(); + double handle = scr_set_timeout_handle(scr_make_resolve(timer, 3 /* void */), t->timeout); + ScrPromise *race = scr_promise_new(); + scr_promise_race_join(race, p, scr_promise_adapt_copy); + scr_promise_race_join(race, timer, scr_promise_adapt_copy); + scr_await_void(race); + scr_clear_interval(handle); + scr_promise_release(race); + scr_promise_release(timer); + if (!scr_exc_pending() && scr_promise_is_pending(p)) { + char num[32]; + size_t n = scr_f64_to_str(t->timeout, num); + char buf[80]; + int len = snprintf(buf, sizeof buf, "test timed out after %.*sms", (int)n, num); + t->timed_out = true; + scr_throw_str(scr_str_new(buf, (size_t)len)); + } +} + /* Call one stored closure by its lowering-pinned shape; async shapes are * awaited (the runner is a fiber). `ctx` is passed +1 when the body takes * the TestContext. Returns with the fiber's exception cell holding any - * failure. */ -static void scr_test_call(ScrClosure *cb, int flags, ScrTestCtx *ctx) { + * failure. `timed` is the test whose { timeout } bounds an async body. */ +static void scr_test_call(ScrClosure *cb, int flags, ScrTestCtx *ctx, ScrTestCtx *timed) { bool takes_ctx = (flags & SCR_TEST_F_CTX) != 0; if (flags & SCR_TEST_F_ASYNC) { ScrPromise *p = takes_ctx ? ((ScrPromise * (*)(ScrClosure *, ScrTestCtx *)) cb->fn)(cb, scr_testctx_retain(ctx)) : ((ScrPromise * (*)(ScrClosure *)) cb->fn)(cb); - if (!scr_exc_pending() && p) scr_await_void(p); + if (!scr_exc_pending() && p) { + if (timed && isfinite(timed->timeout)) scr_test_await_timeout(p, timed); + else scr_await_void(p); + } scr_promise_release(p); } else if (takes_ctx) { ((void (*)(ScrClosure *, ScrTestCtx *))cb->fn)(cb, scr_testctx_retain(ctx)); @@ -297,12 +334,22 @@ static void scr_test_call(ScrClosure *cb, int flags, ScrTestCtx *ctx) { } } +/* A mock tracker's restoreAll: each mock's restore, in creation order. + * reset also forgets the mocks, so later restoreAll calls skip them. */ +static void scr_test_mocks_restore(ScrTestHooks *mocks, bool forget) { + for (size_t i = 0; i < mocks->n; i++) { + ScrClosure *cb = mocks->v[i].cb; + ((void (*)(ScrClosure *))cb->fn)(cb); + } + if (forget) scr_test_hooks_free(mocks); +} + /* Run `which` hooks up (beforeEach: outermost suite first) or down * (afterEach: innermost first) the ancestor chain. A hook throw fails the * test with the hook's error (Node cancels harder — SEMANTICS.md). */ static void scr_test_run_hooks_of(ScrTestCtx *owner, int which) { for (size_t i = 0; i < owner->hooks[which].n && !scr_exc_pending(); i++) { - scr_test_call(owner->hooks[which].v[i].cb, owner->hooks[which].v[i].flags, NULL); + scr_test_call(owner->hooks[which].v[i].cb, owner->hooks[which].v[i].flags, NULL, NULL); } } @@ -354,7 +401,7 @@ static void scr_test_run_children(ScrTestCtx *t) { static bool scr_test_subtree_failed(const ScrTestCtx *t) { for (size_t i = 0; i < t->nchildren; i++) { int s = t->children[i]->state; - if (s == SCR_TS_FAIL || s == SCR_TS_CHILDFAIL) return true; + if (s == SCR_TS_FAIL || s == SCR_TS_CHILDFAIL || s == SCR_TS_CANCELLED) return true; } return false; } @@ -403,7 +450,7 @@ static void scr_test_run_node(ScrTestCtx *t) { t->err = scr_exc_take(); failed = true; } else if (t->cb) { - scr_test_call(t->cb, t->flags, t); + scr_test_call(t->cb, t->flags, t, t); if (scr_exc_pending()) { t->err = scr_exc_take(); failed = true; @@ -446,6 +493,8 @@ static void scr_test_run_node(ScrTestCtx *t) { scr_exc_clear(); } } + /* Then t.mock is reset: its mocks are restored. */ + scr_test_mocks_restore(&t->mocks, true); scr_test_reg_parent = prev_parent; t->dur_ms = scr_now_ms() - start; if (t->mode == SCR_TEST_SKIP) { @@ -454,7 +503,7 @@ static void scr_test_run_node(ScrTestCtx *t) { } else if (t->mode == SCR_TEST_TODO) { t->state = failed ? SCR_TS_TODO_FAIL : SCR_TS_TODO_PASS; } else if (failed) { - t->state = SCR_TS_FAIL; + t->state = t->timed_out ? SCR_TS_CANCELLED : SCR_TS_FAIL; } else { t->state = scr_test_subtree_failed(t) ? SCR_TS_CHILDFAIL : SCR_TS_PASS; } @@ -475,6 +524,7 @@ static void scr_test_count(const ScrTestCtx *t) { case SCR_TS_PASS: scr_test_ct_pass++; break; case SCR_TS_FAIL: case SCR_TS_CHILDFAIL: scr_test_ct_fail++; break; + case SCR_TS_CANCELLED: scr_test_ct_cancelled++; break; case SCR_TS_SKIPPED: scr_test_ct_skipped++; break; case SCR_TS_TODO_PASS: case SCR_TS_TODO_FAIL: scr_test_ct_todo++; break; @@ -559,7 +609,10 @@ static void scr_test_print_failures(ScrTestCtx *t, bool *any) { for (size_t i = 0; i < t->nchildren; i++) { ScrTestCtx *c = t->children[i]; scr_test_print_failures(c, any); - if (!c->is_suite && (c->state == SCR_TS_FAIL || c->state == SCR_TS_TODO_FAIL) && c->err) { + if (!c->is_suite && + (c->state == SCR_TS_FAIL || c->state == SCR_TS_TODO_FAIL || + c->state == SCR_TS_CANCELLED) && + c->err) { if (!*any) { *any = true; fputs("\n\xE2\x9C\x96 failing tests:\n", stdout); @@ -585,7 +638,7 @@ static void scr_test_runner_entry(ScrFiber *self, void *argpack) { scr_test_info_line("suites", (double)scr_test_ct_suites); scr_test_info_line("pass", (double)scr_test_ct_pass); scr_test_info_line("fail", (double)scr_test_ct_fail); - scr_test_info_line("cancelled", 0); + scr_test_info_line("cancelled", (double)scr_test_ct_cancelled); scr_test_info_line("skipped", (double)scr_test_ct_skipped); scr_test_info_line("todo", (double)scr_test_ct_todo); scr_test_info_line("duration_ms", round((scr_now_ms() - scr_test_t0) * 1e6) / 1e6); @@ -608,9 +661,35 @@ static void scr_test_ensure_runner(void) { /* ── the registration surface (libCall targets) ──────────────────────── */ +/* The { plan, timeout } options of a new test. Node checks the timeout's + * range when the test is created and throws ERR_OUT_OF_RANGE. */ +static bool scr_test_options_ok(double flags, double timeout) { + if (!((int)flags & SCR_TEST_F_TIMEOUT) || timeout == INFINITY || + (timeout >= 0 && timeout <= 2147483647)) + return true; + char recv[48]; + size_t n = scr_num_received(timeout, recv); + char msg[160]; + int len = snprintf(msg, sizeof msg, + "The value of \"options.timeout\" is out of range. It must be >= 0 && <= " + "2147483647. Received %.*s", + (int)n, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; +} + +static void scr_test_options_apply(ScrTestCtx *t, double flags, double plan, double timeout) { + if (plan >= 0) t->plan = (int)plan; + if ((int)flags & SCR_TEST_F_TIMEOUT) t->timeout = timeout; +} + void scr_test_register(ScrStr *name /*borrowed*/, double mode, ScrStr *msg /*borrowed*/, ScrClosure *cb /*moves, may be NULL*/, double flags, - ScrStr *at /*borrowed*/) { + ScrStr *at /*borrowed*/, double plan, double timeout) { + if (!scr_test_options_ok(flags, timeout)) { + scr_closure_release(cb); + return; + } scr_test_ensure_root(); scr_test_ensure_runner(); if (scr_test_run_done) { @@ -620,6 +699,7 @@ void scr_test_register(ScrStr *name /*borrowed*/, double mode, ScrStr *msg /*bor return; } ScrTestCtx *t = scr_test_node_new(name, (int)mode, msg, cb, (int)flags, at, false); + scr_test_options_apply(t, flags, plan, timeout); scr_test_child_add(scr_test_reg_parent, t); } @@ -665,8 +745,13 @@ void scr_test_hook(double which, ScrClosure *cb /*moves*/, double flags) { * consumes. */ ScrPromise *scr_test_sub(ScrTestCtx *t, ScrStr *name /*borrowed*/, double mode, ScrStr *msg /*borrowed*/, ScrClosure *cb /*moves, may be NULL*/, - double flags, ScrStr *at /*borrowed*/) { + double flags, ScrStr *at /*borrowed*/, double plan, double timeout) { + if (!scr_test_options_ok(flags, timeout)) { + scr_closure_release(cb); + return NULL; + } ScrTestCtx *sub = scr_test_node_new(name, (int)mode, msg, cb, (int)flags, at, false); + scr_test_options_apply(sub, flags, plan, timeout); scr_test_child_add(t, sub); t->plan_count++; if (t->before_err) { @@ -705,7 +790,7 @@ void scr_test_ctx_todo(ScrTestCtx *t, ScrStr *msg /*borrowed*/) { * each descendant subtest, and after runs once the test finishes. */ void scr_test_ctx_hook(ScrTestCtx *t, double which, ScrClosure *cb /*moves*/, double flags) { if ((int)which == SCR_HOOK_BEFORE) { - scr_test_call(cb, (int)flags, NULL); + scr_test_call(cb, (int)flags, NULL, NULL); scr_closure_release(cb); if (scr_exc_pending() && !t->before_err) t->before_err = scr_exc_take(); else if (scr_exc_pending()) scr_exc_clear(); @@ -722,6 +807,28 @@ void scr_test_ctx_hook(ScrTestCtx *t, double which, ScrClosure *cb /*moves*/, do h->n++; } +static void scr_test_mocks_add(ScrTestHooks *h, ScrClosure *restore /*moves*/) { + if (h->n == h->cap) { + h->cap = h->cap ? h->cap * 2 : 2; + h->v = realloc(h->v, h->cap * sizeof *h->v); + if (!h->v) scr_test_oom(); + } + h->v[h->n].cb = restore; + h->v[h->n].flags = 0; + h->n++; +} + +void scr_test_mock_track(ScrClosure *restore /*moves*/) { + scr_test_mocks_add(&scr_test_mocks, restore); +} +void scr_test_mock_restore_all(void) { scr_test_mocks_restore(&scr_test_mocks, false); } +void scr_test_mock_reset(void) { scr_test_mocks_restore(&scr_test_mocks, true); } +void scr_test_ctx_mock_track(ScrTestCtx *t, ScrClosure *restore /*moves*/) { + scr_test_mocks_add(&t->mocks, restore); +} +void scr_test_ctx_mock_restore_all(ScrTestCtx *t) { scr_test_mocks_restore(&t->mocks, false); } +void scr_test_ctx_mock_reset(ScrTestCtx *t) { scr_test_mocks_restore(&t->mocks, true); } + void scr_test_ctx_plan(ScrTestCtx *t, double count) { t->plan = (int)count; } void scr_test_ctx_count(ScrTestCtx *t) { t->plan_count++; } @@ -766,6 +873,8 @@ ScrStr *scr_test_ctx_full_name(ScrTestCtx *t) { } int scr_test_exit_code(void) { + /* The program is done: the module tracker's restore closures go. */ + if (scr_exit_frees()) scr_test_hooks_free(&scr_test_mocks); /* The runner never finished: a test awaited a promise nobody settles * and the loop exhausted (Node cancels the test and exits 1 — the * summary it prints there is not reproduced; SEMANTICS.md). The run @@ -775,5 +884,6 @@ int scr_test_exit_code(void) { fputs("scriptc: node:test runner never finished (a test awaited a promise nobody settles)\n", stderr); return 1; } - return (scr_test_ct_fail > 0 || scr_test_suite_hook_failed) ? 1 : 0; + return (scr_test_ct_fail > 0 || scr_test_ct_cancelled > 0 || scr_test_suite_hook_failed) ? 1 + : 0; } diff --git a/packages/runtime/src/scr_test.h b/packages/runtime/src/scr_test.h index 7d64145f4e..096009e727 100644 --- a/packages/runtime/src/scr_test.h +++ b/packages/runtime/src/scr_test.h @@ -16,14 +16,18 @@ * durations are real (nondeterministic — harnesses normalize). * ScrStr arguments are BORROWED; closures MOVE. `mode`: 0 run, 1 skip, * 2 todo. `flags`: 1 = async body, 2 = body takes the TestContext, - * 4 = { only: true }. `at` is "file:line:col" of the registration call - * (the failing-section "test at" line), "" when unknown. */ + * 4 = { only: true }, 8 = a { timeout } is given. `at` is "file:line:col" + * of the registration call (the failing-section "test at" line), "" when + * unknown. `plan` is the { plan } count, -1 without one; `timeout` is the + * { timeout } in ms, read with flag 8. An out-of-range timeout throws + * Node's ERR_OUT_OF_RANGE and registers nothing. */ typedef struct ScrTestCtx ScrTestCtx; ScrTestCtx *scr_testctx_retain(ScrTestCtx *t); void scr_testctx_release(ScrTestCtx *t); void *scr_testctx_retain_v(void *t); void scr_testctx_release_v(void *t); -void scr_test_register(ScrStr *name, double mode, ScrStr *msg, ScrClosure *cb, double flags, ScrStr *at); +void scr_test_register(ScrStr *name, double mode, ScrStr *msg, ScrClosure *cb, double flags, ScrStr *at, + double plan, double timeout); void scr_test_suite(ScrStr *name, double mode, ScrStr *msg, ScrClosure *cb, ScrStr *at, double flags); /* before/after/beforeEach/afterEach on the CURRENT registration parent @@ -31,7 +35,8 @@ void scr_test_suite(ScrStr *name, double mode, ScrStr *msg, ScrClosure *cb, ScrS void scr_test_hook(double which, ScrClosure *cb, double flags); /* t.test(...): registers under `t`, runs the subtest INLINE on the * runner fiber, and returns the settled promise the `await` consumes (+1). */ -ScrPromise *scr_test_sub(ScrTestCtx *t, ScrStr *name, double mode, ScrStr *msg, ScrClosure *cb, double flags, ScrStr *at); +ScrPromise *scr_test_sub(ScrTestCtx *t, ScrStr *name, double mode, ScrStr *msg, ScrClosure *cb, double flags, ScrStr *at, + double plan, double timeout); void scr_test_ctx_skip(ScrTestCtx *t, ScrStr *msg); void scr_test_ctx_todo(ScrTestCtx *t, ScrStr *msg); void scr_test_ctx_diagnostic(ScrTestCtx *t, ScrStr *msg); @@ -40,6 +45,16 @@ void scr_test_ctx_plan(ScrTestCtx *t, double count); void scr_test_ctx_count(ScrTestCtx *t); ScrStr *scr_test_ctx_full_name(ScrTestCtx *t); ScrStr *scr_test_ctx_name(ScrTestCtx *t); /* +1 */ +/* Mock trackers: the module's `mock` and each test's t.mock. track keeps + * the closure that restores one mock; restore_all calls them in creation + * order; reset calls them and forgets them. A test's t.mock is reset when + * the test ends, after its t.after hooks. */ +void scr_test_mock_track(ScrClosure *restore); +void scr_test_mock_restore_all(void); +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); /* Main's epilogue (emitted when moduleUsesNodeTest): 1 when any non-todo * test failed, Node's exit contract. */ int scr_test_exit_code(void); diff --git a/tests/corpus/5810-mock-method-records.ts b/tests/corpus/5810-mock-method-records.ts new file mode 100644 index 0000000000..0d38ecd554 --- /dev/null +++ b/tests/corpus/5810-mock-method-records.ts @@ -0,0 +1,40 @@ +// mock.method on records outside a test: the field becomes the mock, calls +// through the object record it as `this`, calls of the returned mock record +// undefined, and restore() writes the original back while the mock keeps +// its implementation. A mock.fn implementation of the same type as the +// original keeps the Mock type. +import { mock } from "node:test"; + +interface Clock { + now(): number; + zone: string; +} + +const clock: Clock = { now: () => 1000, zone: "UTC" }; +const original = clock.now; +const now = mock.method(clock, "now", () => 2000); +console.log(clock.now(), clock.now(), now.mock.callCount()); +console.log(now.mock.calls[0]!.this === clock, now.mock.calls[0]!.result); +console.log(now(), now.mock.calls[2]!.this === undefined); +now.mock.mockImplementationOnce(() => 3000); +console.log(clock.now(), clock.now()); +now.mock.restore(); +console.log(clock.now(), clock.now === original, now(), now.mock.callCount()); + +const math = { + scale: 3, + times: (a: number, b: number) => a * b, +}; +const times = mock.method(math, "times"); +console.log(math.times(4, 5), times.mock.calls[0]!.arguments, math.scale); +times.mock.mockImplementation((a, b) => a + b); +console.log(math.times(4, 5), times.mock.calls.map((c) => c.result)); +times.mock.resetCalls(); +console.log(times.mock.callCount(), math.times(1, 1)); + +const half = (n: number) => n / 2; +const third = (n: number) => n / 3; +const divide = mock.fn(half, third); +console.log(divide(9), divide.mock.callCount()); +divide.mock.restore(); +console.log(divide(9)); diff --git a/tests/corpus/5811-mock-tracker-restore.ts b/tests/corpus/5811-mock-tracker-restore.ts new file mode 100644 index 0000000000..22f255d837 --- /dev/null +++ b/tests/corpus/5811-mock-tracker-restore.ts @@ -0,0 +1,41 @@ +// The module's mock tracker outside a test: mock.restoreAll restores every +// mock it made, mock.fn mocks included, in creation order, and keeps them +// tracked; mock.reset restores them and forgets them, keeping their calls. +import { mock } from "node:test"; + +const log: string[] = []; +const sink = { + write: (line: string): void => { + log.push(`real ${line}`); + }, + level: 1, +}; +const fetcher = { get: (id: number) => `item ${id}` }; + +const write = mock.method(sink, "write", (line: string): void => { + log.push(`mock ${line}`); +}); +const get = mock.method(fetcher, "get", (id: number) => `stub ${id}`); +const second = mock.method(fetcher, "get", (id: number) => `second stub ${id}`); +const twice = mock.fn( + (n: number) => n * 2, + (n: number) => n * 20, +); + +sink.write("a"); +console.log(fetcher.get(1), twice(2)); +mock.restoreAll(); +sink.write("b"); +// The second fetcher mock restores last and writes back its original, the +// first mock. +console.log(fetcher.get(2), twice(2), get.mock.callCount(), second.mock.callCount()); +mock.restoreAll(); +console.log(fetcher.get(3), get.mock.callCount()); +sink.write = write; +mock.reset(); +sink.write("c"); +console.log(log, write.mock.callCount(), twice.mock.callCount()); +sink.write = write; +mock.restoreAll(); +sink.write("d"); +console.log(log.length, log[log.length - 1], write.mock.callCount()); diff --git a/tests/diagnostics/mock-fences.ts b/tests/diagnostics/mock-fences.ts index 54f17bd7c5..4425dfa5fb 100644 --- a/tests/diagnostics/mock-fences.ts +++ b/tests/diagnostics/mock-fences.ts @@ -1,9 +1,11 @@ -// node:test mocks lower for mock.fn(original[, implementation]) and -// t.mock.fn. The rest of the mock surface is refused, each with a named -// diagnostic: mock.fn() without an original, mock.method, the tracker's -// restoreAll and reset, t.mock.method, and functions whose union return -// type has no undefined arm (the calls log records undefined for calls that -// throw). +// node:test mocks lower for mock.fn(original[, implementation]), +// mock.method(object, methodName[, implementation]) on records, their +// t.mock twins, and the trackers' restoreAll and reset. The rest of the +// mock surface is refused, each with a named diagnostic: mock.fn() without +// an original; mock.method on a class instance or a builtin object, with +// an options object, or with an implementation of another type when the +// result is used; and functions whose union return type has no undefined +// arm (the calls log records undefined for calls that throw). import { mock, test } from "node:test"; const add = mock.fn((a: number, b: number) => a + b); // lowers @@ -13,12 +15,24 @@ const empty = mock.fn(); // no original function empty(); const obj = { value: () => 1 }; -mock.method(obj, "value"); // method mocks -mock.restoreAll(); -mock.reset(); +mock.method(obj, "value"); // lowers +mock.restoreAll(); // lowers +mock.reset(); // lowers + +class Counter { + count(): number { + return 1; + } +} +mock.method(new Counter(), "count"); // class instance +mock.method(console, "log"); // builtin object +mock.method(obj, "value", { times: 1 }); // options +const other = mock.method(obj, "value", () => "one"); // implementation of another type +other(); test("tracker methods on t.mock", (t) => { - t.mock.method(obj, "value"); + t.mock.method(obj, "value"); // lowers + t.mock.method(obj, "value", { times: 2 }); // options }); const pick = mock.fn((flag: boolean): number | string => (flag ? 1 : "one")); // union without undefined diff --git a/tests/diagnostics/test-option-fences.ts b/tests/diagnostics/test-option-fences.ts new file mode 100644 index 0000000000..d4ae49d9d1 --- /dev/null +++ b/tests/diagnostics/test-option-fences.ts @@ -0,0 +1,18 @@ +// node:test options lower for skip, todo and only on tests and suites, and +// plan and timeout on tests. Each other option is refused with a named +// diagnostic: concurrency, a suite's timeout and plan, and a plan that is +// not a non-negative integer literal. +import { describe, test } from "node:test"; + +test("lowers", { plan: 1, timeout: 50 }, (t) => { + t.assert.ok(true); +}); + +test("concurrency", { concurrency: 2 }, () => {}); + +describe("suite timeout", { timeout: 100 }, () => {}); + +describe("suite plan", { plan: 1 }, () => {}); + +const count = 2; +test("computed plan", { plan: count }, () => {}); diff --git a/tests/fixtures/node-test/cases/mock-method.ts b/tests/fixtures/node-test/cases/mock-method.ts new file mode 100644 index 0000000000..2a9a9aefe6 --- /dev/null +++ b/tests/fixtures/node-test/cases/mock-method.ts @@ -0,0 +1,153 @@ +// mock.method and t.mock.method on records: the object's field becomes the +// mock, calls through it record the object as `this`, restore() writes the +// original back while the mock keeps its implementation, and a second mock +// of the same field restores the first. mock.restoreAll restores every mock +// the tracker made, mock.fn mocks included, in creation order; mock.reset +// restores them and forgets them, keeping their calls. t.mock restores its +// mocks when the test ends, after its t.after hooks. +import assert from "node:assert"; +import { mock, test } from "node:test"; + +interface Store { + get(key: string): string; + size: number; +} + +const seen: string[] = []; + +test("mocks a method", () => { + const counter = { step: 2, next: (n: number) => n + 2 }; + const original = counter.next; + const next = mock.method(counter, "next"); + assert.strictEqual(counter.next(1), 3); + assert.strictEqual(next.mock.callCount(), 1); + const [first] = next.mock.calls; + assert.deepStrictEqual(first!.arguments, [1] as [number]); + assert.ok(first!.result === 3); + assert.ok(first!.this === counter); + assert.strictEqual(next(5), 7); + assert.ok(next.mock.calls[1]!.this === undefined); + next.mock.mockImplementation((n) => n * 10); + assert.strictEqual(counter.next(2), 20); + next.mock.restore(); + assert.ok(counter.next === original); + assert.strictEqual(counter.next(2), 4); + assert.strictEqual(next(3), 30); + assert.strictEqual(next.mock.callCount(), 4); +}); + +test("an implementation and an interface-typed object", () => { + const store: Store = { get: (key) => `value of ${key}`, size: 1 }; + const get = mock.method(store, "get", (key: string) => `mocked ${key}`); + assert.strictEqual(store.get("a"), "mocked a"); + get.mock.mockImplementationOnce((key) => `once ${key}`); + assert.strictEqual(store.get("b"), "once b"); + assert.strictEqual(store.get("c"), "mocked c"); + assert.deepStrictEqual( + get.mock.calls.map((c) => c.arguments[0]), + ["a", "b", "c"], + ); + get.mock.restore(); + assert.strictEqual(store.get("d"), "value of d"); +}); + +test("a second mock of the same method", () => { + const box = { read: () => 1 }; + const outer = mock.method(box, "read", () => 2); + const inner = mock.method(box, "read", () => 3); + assert.strictEqual(box.read(), 3); + assert.strictEqual(outer.mock.callCount(), 0); + outer.mock.restore(); + assert.strictEqual(box.read(), 1); + inner.mock.restore(); + assert.strictEqual(box.read(), 2); + assert.strictEqual(outer.mock.callCount(), 1); + assert.strictEqual(inner.mock.callCount(), 1); +}); + +test("a throwing implementation", () => { + const parser = { parse: (text: string): number => Number(text) }; + const failure = new Error("bad input"); + const parse = mock.method(parser, "parse", (text: string): number => { + if (text === "") throw failure; + return text.length; + }); + assert.throws(() => parser.parse(""), failure); + assert.strictEqual(parser.parse("abc"), 3); + assert.ok(parse.mock.calls[0]!.error === failure); + assert.ok(parse.mock.calls[0]!.result === undefined); + assert.ok(parse.mock.calls[1]!.result === 3); +}); + +test("mock.restoreAll and mock.reset", () => { + const box = { read: () => 1, write: (n: number): void => {} }; + const read = mock.method(box, "read", () => 2); + mock.method(box, "write", (n: number): void => { + seen.push(`write ${n}`); + }); + const double = mock.fn( + (n: number) => n * 2, + (n: number) => n * 3, + ); + box.write(1); + assert.strictEqual(box.read(), 2); + assert.strictEqual(double(2), 6); + mock.restoreAll(); + box.write(2); + assert.strictEqual(box.read(), 1); + assert.strictEqual(double(2), 4); + assert.strictEqual(read.mock.callCount(), 1); + box.read = read; + mock.restoreAll(); + assert.strictEqual(box.read(), 1); + const again = mock.method(box, "read", () => 5); + assert.strictEqual(box.read(), 5); + mock.reset(); + assert.strictEqual(box.read(), 1); + assert.strictEqual(again.mock.callCount(), 1); + box.read = again; + mock.restoreAll(); + assert.strictEqual(box.read(), 5); + assert.deepStrictEqual(seen, ["write 1"]); +}); + +const shared = { now: () => 0 }; + +test("t.mock restores when the test ends", (t) => { + t.mock.method(shared, "now", () => 42); + const later = t.mock.fn( + (n: number) => n, + (n: number) => -n, + ); + t.after(() => { + seen.push(`after hook ${shared.now()}`); + }); + assert.strictEqual(shared.now(), 42); + assert.strictEqual(later(1), -1); +}); + +test("the next test sees the original", () => { + assert.strictEqual(shared.now(), 0); + assert.deepStrictEqual(seen, ["write 1", "after hook 42"]); +}); + +test("t.mock.restoreAll and t.mock.reset", (t) => { + const box = { read: () => 1 }; + const read = t.mock.method(box, "read", () => 2); + t.mock.restoreAll(); + assert.strictEqual(box.read(), 1); + box.read = read; + t.mock.reset(); + assert.strictEqual(box.read(), 1); + box.read = read; + t.mock.restoreAll(); + assert.strictEqual(box.read(), 2); +}); + +test("subtests have their own tracker", async (t) => { + await t.test("inner", (t) => { + t.mock.method(shared, "now", () => 7); + assert.strictEqual(shared.now(), 7); + }); + assert.strictEqual(shared.now(), 0); +}); diff --git a/tests/fixtures/node-test/cases/test-options.ts b/tests/fixtures/node-test/cases/test-options.ts new file mode 100644 index 0000000000..a00baddf17 --- /dev/null +++ b/tests/fixtures/node-test/cases/test-options.ts @@ -0,0 +1,89 @@ +// @exit: 1 +// The plan and timeout test options. { plan } counts the test's t.assert +// calls and subtests, as t.plan does. { timeout } bounds an async body: when +// the timeout elapses first, the test is cancelled with Node's "test timed +// out after ms" message and the run continues; a sync body runs to +// completion. Option values run in source order, and a timeout outside +// 0..2147483647 throws Node's ERR_OUT_OF_RANGE when the test is created. +import { it, test } from "node:test"; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const order: string[] = []; +const note = (label: string, value: number): number => { + order.push(label); + return value; +}; +const noteText = (label: string): string => { + order.push(label); + return `${label} message`; +}; + +test("plan", { plan: 2 }, (t) => { + t.assert.ok(true); + t.assert.strictEqual(1, 1); +}); + +test("plan mismatch", { plan: 2 }, (t) => { + t.assert.ok(true); +}); + +it("plan with a subtest", { plan: 1 }, async (t) => { + await t.test("inner", () => {}); +}); + +test("fast async body", { timeout: 1000 }, async () => { + await delay(1); +}); + +test("sync body past its timeout", { timeout: 1 }, () => { + const end = Date.now() + 20; + while (Date.now() < end) {} +}); + +test("timed out", { timeout: 20 }, async () => { + await delay(200); +}); + +test("never settles", { timeout: 10 * 2 }, async () => { + await new Promise(() => {}); +}); + +test("rejects after timing out", { timeout: 10 }, async () => { + await delay(40); + throw new Error("late"); +}); + +test("subtest timeout", async (t) => { + await t.test("slow inner", { timeout: 15 }, async () => { + await delay(200); + }); +}); + +test("infinite timeout", { timeout: Infinity }, async () => { + await delay(1); +}); + +test( + `named ${note("name", 1)}`, + { timeout: note("timeout", 500), todo: noteText("todo") }, + async () => {}, +); + +const rangeErrors: string[] = []; +for (const timeout of [-1, 2 ** 40]) { + try { + test("out of range", { timeout }, async () => {}); + } catch (error) { + rangeErrors.push(`${(error as Error).name} ${(error as Error).message}`); + } +} + +test("out-of-range timeouts throw", () => { + const range = 'The value of "options.timeout" is out of range. It must be >= 0 && <= 2147483647.'; + const expected = [`RangeError ${range} Received -1`, `RangeError ${range} Received 1_099_511_627_776`]; + if (rangeErrors.join("\n") !== expected.join("\n")) throw new Error(rangeErrors.join("\n")); +}); + +test("options ran in order", () => { + if (order.join(",") !== "name,timeout,todo") throw new Error(order.join(",")); +}); diff --git a/tests/harness/__snapshots__/mock-fences.ts.txt b/tests/harness/__snapshots__/mock-fences.ts.txt index 5ccfc87ff2..6b4defcbb2 100644 --- a/tests/harness/__snapshots__/mock-fences.ts.txt +++ b/tests/harness/__snapshots__/mock-fences.ts.txt @@ -1,53 +1,62 @@ -mock-fences.ts:12:15 - error SC2020: 'mock.fn of this function type' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:14:15 - error SC2020: 'mock.fn of this function type' is part of the standard library types but has no scriptc lowering yet - 11 | - 12 | const empty = mock.fn(); // no original function + 13 | + 14 | const empty = mock.fn(); // no original function | ^~~~~~~~~ - 13 | empty(); + 15 | empty(); - hint: mock functions lower for functions with fixed parameters, or a single rest array parameter + hint: mock functions lower for functions with fixed parameters, or a single rest array parameter, and an implementation of the same type -mock-fences.ts:16:1 - error SC2020: 'test.mock.method' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:27:13 - error SC2020: 'mock.method on 'Counter'' is part of the standard library types but has no scriptc lowering yet - 15 | const obj = { value: () => 1 }; - 16 | mock.method(obj, "value"); // method mocks - | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 17 | mock.restoreAll(); + 26 | } + 27 | mock.method(new Counter(), "count"); // class instance + | ^~~~~~~~~~~~~ + 28 | mock.method(console, "log"); // builtin object - hint: mock.fn and t.mock.fn are the supported mock members + hint: mock.method lowers for objects whose type maps to a record (object literals and interface-typed objects) and a function-valued field named by a string literal -mock-fences.ts:17:1 - error SC2020: 'test.mock.restoreAll' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:28:13 - error SC2020: 'mock.method on this object' is part of the standard library types but has no scriptc lowering yet - 16 | mock.method(obj, "value"); // method mocks - 17 | mock.restoreAll(); - | ^~~~~~~~~~~~~~~~~ - 18 | mock.reset(); + 27 | mock.method(new Counter(), "count"); // class instance + 28 | mock.method(console, "log"); // builtin object + | ^~~~~~~ + 29 | mock.method(obj, "value", { times: 1 }); // options - hint: mock.fn and t.mock.fn are the supported mock members + hint: mock.method lowers for objects whose type maps to a record (object literals and interface-typed objects) and a function-valued field named by a string literal -mock-fences.ts:18:1 - error SC2020: 'test.mock.reset' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:29:27 - error SC2020: 'mock.method with an options object' is part of the standard library types but has no scriptc lowering yet - 17 | mock.restoreAll(); - 18 | mock.reset(); - | ^~~~~~~~~~~~ - 19 | + 28 | mock.method(console, "log"); // builtin object + 29 | mock.method(obj, "value", { times: 1 }); // options + | ^~~~~~~~~~~~ + 30 | const other = mock.method(obj, "value", () => "one"); // implementation of another type - hint: mock.fn and t.mock.fn are the supported mock members + hint: mock options (times, getter and setter) have no lowering -mock-fences.ts:21:3 - error SC2020: 'MockTracker.method' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:30:41 - error SC2020: 'mock.method with an implementation of another type' is part of the standard library types but has no scriptc lowering yet - 20 | test("tracker methods on t.mock", (t) => { - 21 | t.mock.method(obj, "value"); - | ^~~~~~~~~~~~~ - 22 | }); + 29 | mock.method(obj, "value", { times: 1 }); // options + 30 | const other = mock.method(obj, "value", () => "one"); // implementation of another type + | ^~~~~~~~~~~ + 31 | other(); - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + hint: give the implementation the method's parameter and return types, or discard the result -mock-fences.ts:24:14 - error SC2020: 'mock.fn of a function returning 'number | string'' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:35:31 - error SC2020: 't.mock.method with an options object' is part of the standard library types but has no scriptc lowering yet - 23 | - 24 | const pick = mock.fn((flag: boolean): number | string => (flag ? 1 : "one")); // union without undefined + 34 | t.mock.method(obj, "value"); // lowers + 35 | t.mock.method(obj, "value", { times: 2 }); // options + | ^~~~~~~~~~~~ + 36 | }); + + hint: mock options (times, getter and setter) have no lowering + +mock-fences.ts:38:14 - error SC2020: 'mock.fn of a function returning 'number | string'' is part of the standard library types but has no scriptc lowering yet + + 37 | + 38 | const pick = mock.fn((flag: boolean): number | string => (flag ? 1 : "one")); // union without undefined | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 25 | pick(true); + 39 | pick(true); hint: a union return type needs an undefined arm here (the calls log records undefined for calls that throw) \ No newline at end of file diff --git a/tests/harness/__snapshots__/test-option-fences.ts.txt b/tests/harness/__snapshots__/test-option-fences.ts.txt new file mode 100644 index 0000000000..7e872bf19b --- /dev/null +++ b/tests/harness/__snapshots__/test-option-fences.ts.txt @@ -0,0 +1,35 @@ +test-option-fences.ts:11:23 - error SC2020: 'test(...) option 'concurrency'' is part of the standard library types but has no scriptc lowering yet + + 10 | + 11 | test("concurrency", { concurrency: 2 }, () => {}); + | ^~~~~~~~~~~~~~ + 12 | + + hint: skip, todo, only, plan, and timeout are the supported options + +test-option-fences.ts:13:29 - error SC2020: 'describe(...) option 'timeout'' is part of the standard library types but has no scriptc lowering yet + + 12 | + 13 | describe("suite timeout", { timeout: 100 }, () => {}); + | ^~~~~~~~~~~~ + 14 | + + hint: skip, todo, and only are the supported suite options + +test-option-fences.ts:15:26 - error SC2020: 'describe(...) option 'plan'' is part of the standard library types but has no scriptc lowering yet + + 14 | + 15 | describe("suite plan", { plan: 1 }, () => {}); + | ^~~~~~~ + 16 | + + hint: skip, todo, and only are the supported suite options + +test-option-fences.ts:18:31 - error SC2020: 'test(...) with a non-literal plan value' is part of the standard library types but has no scriptc lowering yet + + 17 | const count = 2; + 18 | test("computed plan", { plan: count }, () => {}); + | ^~~~~ + 19 | + + hint: plan takes a non-negative integer literal here \ No newline at end of file