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