diff --git a/docs/development/testing.md b/docs/development/testing.md index 00fe51255d..9ec1f45918 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -324,6 +324,8 @@ Waits follow the same policy; Vitest's one-second `expect.poll` and test may run, less the reporting reserve: projects configure `expect.poll` that way, and `waitFor` from `tests/support/testWait.ts` replaces `vi.waitFor`. Fixture waits such as `awaitAcknowledgement` default to the same deadline. +`expect.poll` keeps its project's budget in a `long-running` test, while +`waitFor` follows the deadline the tag applies. `rea/project-wait-deadline` rejects `vi.waitFor` and explicit poll timeouts. When promptness is the behavior under test, such as cancellation that must preempt a slower production deadline, a wait timeout is not its oracle: order the condition before releasing the gate that would diff --git a/scripts/lint/rules/project-wait-deadline.mjs b/scripts/lint/rules/project-wait-deadline.mjs index 235d694edb..9e781f14b6 100644 --- a/scripts/lint/rules/project-wait-deadline.mjs +++ b/scripts/lint/rules/project-wait-deadline.mjs @@ -47,15 +47,26 @@ const isVitestMethod = (sourceCode, callee, receiver, method) => { ); }; -/** A wait timeout written at the call: a number, or an options `timeout`. */ +const keyName = (property) => { + if (!property.computed) return property.key.name ?? property.key.value; + return property.key.type === "Literal" ? property.key.value : undefined; +}; + +/** + * A wait timeout the call may set: a number or other non-literal options, an + * options `timeout` (including a literal computed key), or a spread whose + * properties the rule cannot see. + */ const explicitTimeout = (options) => { if (options === undefined) return undefined; if (options.type !== "ObjectExpression") return options; return options.properties.find( (property) => - property.type === "Property" && - !property.computed && - (property.key.name ?? property.key.value) === "timeout", + property.type === "SpreadElement" || + (property.type === "Property" && + (property.computed && property.key.type !== "Literal" + ? true + : keyName(property) === "timeout")), ); }; diff --git a/tests/boundary/cli/doctorCancellation.test.ts b/tests/boundary/cli/doctorCancellation.test.ts index 9c8c9bda17..deeef4c9e3 100644 --- a/tests/boundary/cli/doctorCancellation.test.ts +++ b/tests/boundary/cli/doctorCancellation.test.ts @@ -46,7 +46,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for( executable, `#!${process.execPath} if (!process.argv.slice(2).includes(${JSON.stringify(probeArgument)})) process.exit(7); -require("node:fs").writeFileSync(${JSON.stringify(ready)}, JSON.stringify({pid: process.pid, runId: process.env.REA_PROCESS_RUN_ID})); +require("node:fs").writeFileSync(${JSON.stringify(ready)}, JSON.stringify({pid: process.pid, runId: process.env.REA_PROCESS_RUN_ID, at: Date.now()})); process.on("SIGTERM", () => {}); setInterval(() => { if (require("node:fs").existsSync(${JSON.stringify(release)})) process.exit(0); }, 25).unref(); setTimeout(() => process.exit(99), 20000); @@ -104,12 +104,14 @@ setTimeout(() => process.exit(99), 20000); .strictObject({ pid: z.number().int().positive(), runId: z.string().uuid(), + at: z.number(), }) .parse(readyRecord) : z .strictObject({ pid: z.number().int().positive(), runId: z.string().min(1), + at: z.number(), }) .parse(readyRecord); if (provider === "jadx") { @@ -119,12 +121,11 @@ setTimeout(() => process.exit(99), 20000); runId: observed.runId, }; } - const signalled = performance.now(); child.kill(signal); await expect.poll(() => child.exitCode).not.toBeNull(); // Without cancellation, doctor would end when the probe's own deadline - // (JadxJavaProbe and KunaInstallation: 10 s) stopped the probe. - expect(performance.now() - signalled).toBeLessThan(PROBE_DEADLINE_MS); + // (JadxJavaProbe and KunaInstallation: 10 s from its start) stopped it. + expect(Date.now() - observed.at).toBeLessThan(PROBE_DEADLINE_MS); const result = await closed; expect(JSON.parse(stdout)).toMatchObject({ code: "cancelled" }); if (ownership !== undefined) { diff --git a/tests/evaluation/projectWaitDeadlineRule.test.ts b/tests/evaluation/projectWaitDeadlineRule.test.ts index fb8f85bd06..dd849ef266 100644 --- a/tests/evaluation/projectWaitDeadlineRule.test.ts +++ b/tests/evaluation/projectWaitDeadlineRule.test.ts @@ -17,6 +17,7 @@ tester.run("project-wait-deadline", rule, { `${vitest} await expect.poll(() => ready).toBe(true);`, `${vitest} await expect.poll(() => ready, { interval: 10 }).toBe(true);`, `${vitest} await expect.poll(() => ready, { message: "ready" }).toBe(true);`, + `${vitest} await expect.poll(() => ready, { ["interval"]: 10 }).toBe(true);`, `import { waitFor } from "../support/testWait.js"; await waitFor(() => ready, { interval: 10 });`, // Same names that are not Vitest's: a fixture method, or a shadowed local. `${vitest} await fixture.vi.waitFor(() => ready, { timeout: 5_000 });`, @@ -40,6 +41,9 @@ tester.run("project-wait-deadline", rule, { `${vitest} await expect.poll(() => ready, options).toBe(true);`, `import { expect as assert } from "vitest"; await assert.poll(() => ready, { timeout: 5 }).toBe(true);`, `import * as vitest from "vitest"; await vitest.expect.poll(() => ready, { "timeout": 5 }).toBe(true);`, + `${vitest} await expect.poll(() => ready, { ["timeout"]: 5 }).toBe(true);`, + `${vitest} await expect.poll(() => ready, { ...{ timeout: 5 } }).toBe(true);`, + `${vitest} await expect.poll(() => ready, { [key]: 5 }).toBe(true);`, ].map((code) => ({ code, filename,