diff --git a/packages/compiler/src/frontend/lowering/lower-timers.ts b/packages/compiler/src/frontend/lowering/lower-timers.ts index 445b49d3ae..23514c1dc9 100644 --- a/packages/compiler/src/frontend/lowering/lower-timers.ts +++ b/packages/compiler/src/frontend/lowering/lower-timers.ts @@ -341,14 +341,18 @@ function isRealTimerCallee(lowerer: Lowerer, callee: ts.Expression): boolean { } /** Marks a lowered timer call `realTimer` when its callee is a binding - * mock.timers does not replace. */ + * mock.timers does not replace. A call whose delay is stored first returns a + * `seqExpr` around the call, so the mark goes on the call inside it. */ function markRealTimerCall( lowerer: Lowerer, expr: ts.CallExpression, lowered: IrExpr | null, ): IrExpr | null { - if (lowered?.kind !== "libCall" || !MOCKABLE_TIMER_FNS.has(lowered.fn)) return lowered; - return isRealTimerCallee(lowerer, expr.expression) ? { ...lowered, realTimer: true } : lowered; + const call = lowered?.kind === "seqExpr" ? lowered.result : lowered; + if (call?.kind !== "libCall" || !MOCKABLE_TIMER_FNS.has(call.fn)) return lowered; + if (!isRealTimerCallee(lowerer, expr.expression)) return lowered; + const marked: IrExpr = { ...call, realTimer: true }; + return lowered?.kind === "seqExpr" ? { ...lowered, result: marked } : marked; } /** One timer call by MEMBER NAME — the shared lowering behind the ambient diff --git a/packages/compiler/test/timer-delay-lowering.test.ts b/packages/compiler/test/timer-delay-lowering.test.ts index deff90e25d..08b76b2eb4 100644 --- a/packages/compiler/test/timer-delay-lowering.test.ts +++ b/packages/compiler/test/timer-delay-lowering.test.ts @@ -5,8 +5,10 @@ import { expect, test } from "vitest"; import { compile, deserializeModule } from "../src/index.js"; import type { IrExpr } from "../src/ir/ir.js"; -/** The delay argument of every setTimeout/setInterval libCall in the module. */ -async function timerDelays(source: string): Promise { +type TimerCall = Extract; + +/** Every setTimeout/setInterval libCall in the module. */ +async function timerCalls(source: string): Promise { const dir = await mkdtemp(join(tmpdir(), "scriptc-timer-delays-")); try { const entry = join(dir, "main.ts"); @@ -16,7 +18,7 @@ async function timerDelays(source: string): Promise { if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const mod = deserializeModule(await readFile(outPath, "utf8")); - const delays: IrExpr[] = []; + const calls: TimerCall[] = []; const visit = (value: unknown): void => { if (Array.isArray(value)) { for (const item of value) visit(item); @@ -29,16 +31,21 @@ async function timerDelays(source: string): Promise { typeof node.fn === "string" && /^timers\.set(Timeout|TimeoutHandle|Interval)$/.test(node.fn) ) - delays.push(node.args![1]!); + calls.push(node as TimerCall); for (const child of Object.values(value)) visit(child); }; visit(mod.functions); - return delays; + return calls; } finally { await rm(dir, { recursive: true, force: true }); } } +/** The delay argument of every setTimeout/setInterval libCall in the module. */ +async function timerDelays(source: string): Promise { + return (await timerCalls(source)).map((call) => call.args[1]!); +} + test("number delays pass to the timer unchanged", async () => { const delays = await timerDelays( `function arm(ms: number): void { @@ -94,3 +101,23 @@ test("a number | undefined delay converts with ToNumber", async () => { }); } }); + +test("an imported timer call stays real through an untyped delay", async () => { + const calls = await timerCalls( + `import { setTimeout as realSetTimeout, setInterval as realSetInterval } from "node:timers"; + function arm(delay: any): void { + realSetTimeout(() => console.log("a"), delay); + realSetInterval(() => console.log("b"), delay); + setTimeout(() => console.log("c"), delay); + } + arm(1); + export {}; + `, + ); + // An untyped delay waits in a hidden local, so the call sits inside a + // seqExpr; the callee still decides whether mock.timers routes it. + expect(calls.map((call) => call.realTimer)).toEqual([true, true, undefined]); + expect(calls[0]).toMatchObject({ fn: "timers.setTimeout" }); + expect(calls[1]).toMatchObject({ fn: "timers.setInterval" }); + expect(calls[2]).toMatchObject({ fn: "timers.setTimeout" }); +}); diff --git a/tests/corpus/timers-delay-warnings-mocked.ts b/tests/corpus/timers-delay-warnings-mocked.ts index 75aba9b201..b7c610b3d2 100644 --- a/tests/corpus/timers-delay-warnings-mocked.ts +++ b/tests/corpus/timers-delay-warnings-mocked.ts @@ -2,7 +2,8 @@ // While mock.timers mocks setTimeout and setInterval, the mocked functions // build no Timeout, so a negative or overflowing delay warns nothing; // after reset the real functions warn again, and a node:timers import is -// never mocked. +// never mocked: it schedules a real timer, including through an untyped +// delay, whose conversion runs before the call. import { mock } from "node:test"; import { setTimeout as realSetTimeout } from "node:timers"; @@ -15,7 +16,13 @@ const interval = setInterval(() => { clearInterval(interval); }, 2 ** 31); realSetTimeout(() => console.log("imported overflow"), 2 ** 32); +function scheduleImported(delay: any): void { + realSetTimeout(() => console.log("imported untyped delay"), delay); +} +scheduleImported(1); +setTimeout(() => console.log("mocked delay"), 1); mock.timers.tick(1); +console.log("after tick"); mock.timers.reset(); setTimeout(() => console.log("real negative"), -5);