From 07e4b700acd30a063bcdf05ea8fde6e4537ce7cd Mon Sep 17 00:00:00 2001 From: gensokyo-dev Date: Sat, 14 Nov 2026 09:34:19 +0000 Subject: [PATCH] test: let the deadline policy own test waits Vitest's expect.poll and vi.waitFor stop after one second by default, a host-speed guess that the project's deadline policy does not see. Under hosted-CI load, kunaProjectConcurrency failed that way twice on unrelated PRs. Another 256 waits relied on the default, and 97 passed literals picked to fit some host. Waits now take as long as their test may run, less the reporting reserve that children already keep. Each Vitest project sets expect.poll's default, and the new waitFor in tests/support/testWait.ts replaces vi.waitFor. Fixture readiness waits (awaitAcknowledgement and the provider process waits) default to the same deadline, and their host-speed literal arguments are removed. One remainingTestDeadline owns the arithmetic for both children and waits. Ten waits whose short timeout was the real check now assert it directly (Kuna version probe, Cutter discovery, doctor and Inspector cancellation, apktool fixture teardown, JADX disconnect): settlement before the production deadline that would otherwise also satisfy the wait, or a deadline pinned beyond every test deadline. The rea/project-wait-deadline lint rule rejects vi.waitFor and explicit expect.poll timeouts. The testing guide and the CI tests audit skill now document the policy. Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- .agents/skills/rea-ci-tests-audit/SKILL.md | 5 +- .oxlintrc.json | 6 + docs/development/testing.md | 13 ++ scripts/lint/index.mjs | 2 + .../lint/rules/project-wait-deadline.d.mts | 4 + scripts/lint/rules/project-wait-deadline.mjs | 92 +++++++++++++ src/adb/AdbProvider.test.ts | 7 +- .../ApktoolVersionProbe.lifecycle.test.ts | 4 +- src/application/LinuxHopperPython.test.ts | 12 +- src/artifacts/ZipArtifactReader.test.ts | 25 ++-- .../apple/AppleAssetCatalogAnalysis.test.ts | 7 +- .../PlaywrightElectronActiveProvider.test.ts | 3 +- src/browser/PlaywrightScenarioEvents.test.ts | 5 +- src/browser/PlaywrightScenarioRequest.test.ts | 3 +- src/browser/PlaywrightScenarioSession.test.ts | 3 +- src/desktop/AgentEnvSolveHelper.test.ts | 2 +- .../ScreenJudgeProcess.integration.test.ts | 14 +- src/desktop/ScreenJudgeProcess.test.ts | 3 +- .../capture/ProcessSamplingShutdown.test.ts | 3 +- src/server/http/nodeHttpBridge.test.ts | 15 ++- src/server/http/serveStreamableHttp.test.ts | 21 +-- .../providers/hopperStartupRecovery.test.ts | 7 +- .../tools/inspectorFileUrlIdentity.test.ts | 2 +- .../tools/inspectorLifecycle.test.ts | 12 +- .../tools/wabtLauncherIdentity.test.ts | 6 +- .../android/androidTargetSnapshot.test.ts | 2 +- .../android/jadxConfiguration.test.ts | 12 +- .../browser/callerCancellationReason.test.ts | 9 +- .../cdpBrowserProvider.browserSocket.test.ts | 51 +++---- .../browser/cdpCleanupCancellation.test.ts | 15 ++- tests/boundary/browser/cdpConnection.test.ts | 21 +-- .../browser/cdpEndpointResponseBounds.test.ts | 10 +- .../browser/cdpTargetDeadlines.test.ts | 126 +++++++----------- .../browser/cdpTargetSessionOwner.test.ts | 15 ++- .../browser/cdpWebRuntimeTermination.test.ts | 5 +- .../browser/historicalCaptureDecoder.test.ts | 10 +- tests/boundary/cli/cliJsonStdinCli.test.ts | 8 +- .../boundary/cli/cliWorkerCompletion.test.ts | 7 +- tests/boundary/cli/cutterCommands.test.ts | 4 +- .../boundary/cli/debuggerOneShotExit.test.ts | 32 ++--- tests/boundary/cli/doctorCancellation.test.ts | 22 +-- tests/boundary/cli/electronCli.test.ts | 18 ++- .../filesystem/lintRuleConfiguration.test.ts | 10 ++ .../boundary/mcp/buildCaptureRetryMcp.test.ts | 7 +- tests/boundary/mcp/buildComputerMcp.test.ts | 9 +- tests/boundary/mcp/buildWitnessMcp.test.ts | 7 +- .../mcp/cutterCancellationMcp.test.ts | 10 +- .../mcp/evidenceExportCancellation.test.ts | 2 +- tests/boundary/mcp/fridaLifecycleMcp.test.ts | 5 +- .../boundary/mcp/ghidraFileOffsetMcp.test.ts | 7 +- .../mcp/ghidraRequestCeilingMcp.test.ts | 5 +- .../mcp/ghidraStartupCancellationMcp.test.ts | 5 +- .../lateAbortApplicationEvidenceMcp.test.ts | 9 +- tests/boundary/mcp/mcpDoctor.test.ts | 48 +++---- .../mcp/optionalProviderShutdown.test.ts | 7 +- tests/boundary/mcp/wasmArtifactMcp.test.ts | 3 +- tests/boundary/pcap/pcapProvider.test.ts | 7 +- .../process/androidAnalysisMcp.test.ts | 11 +- .../process/androidMappingCleanup.test.ts | 11 +- .../darwinProcessRunTokenReader.test.ts | 9 +- .../process/desktopBuildOwnedCommand.test.ts | 12 +- .../boundary/process/jadxIntegration.test.ts | 4 +- .../packagedWasmVerifierOwnership.test.ts | 2 +- ...processCaptureCancellationAdapters.test.ts | 4 +- .../process/processCaptureProgress.test.ts | 12 +- tests/boundary/process/processCli.test.ts | 1 - .../process/processFixtureLifecycle.test.ts | 14 +- .../process/testGroupCancellation.test.ts | 1 - .../process/verifierTerminalOwner.test.ts | 2 +- .../RizinDebugSessionInferiorStdin.test.ts | 9 +- .../providers/RizinInferiorExit.test.ts | 8 +- .../adb/adbCommandFinalization.test.ts | 3 +- .../providers/apktoolDecodeDeadline.test.ts | 4 +- .../providers/apktoolProcessFixture.test.ts | 11 +- .../providers/ghidra/ghidraClient.test.ts | 15 ++- .../providers/ghidra/ghidraLauncher.test.ts | 5 +- .../hopper/hopperBridgeRequestLimit.test.ts | 10 +- .../hopper/hopperClient.deadlines.test.ts | 21 ++- .../hopper/hopperClient.diagnostics.test.ts | 5 +- .../providers/hopper/hopperClient.fixture.ts | 26 ++-- .../hopperClient.handshakeRecovery.test.ts | 15 +-- .../hopper/hopperClient.health.test.ts | 13 +- .../hopper/hopperClient.progress.test.ts | 7 +- .../hopper/hopperClient.protocol.test.ts | 7 +- .../hopperClient.startupWaiters.test.ts | 3 +- .../hopper/linuxPrivateDisplay.test.ts | 3 +- .../kuna/kunaCommandCancellation.test.ts | 20 ++- .../kunaDirectoryIndependentRetryMcp.test.ts | 14 +- .../providers/kuna/kunaDoctor.test.ts | 15 ++- .../providers/kuna/kunaInstallation.test.ts | 12 +- .../kuna/kunaInstallationCancellation.test.ts | 12 +- .../kuna/kunaProjectConcurrency.test.ts | 4 +- .../providers/kuna/kunaProvider.test.ts | 7 +- .../kuna/kunaReaderCancellation.test.ts | 11 +- .../providers/kuna/kunaReferences.test.ts | 6 +- .../providers/kuna/kunaSessionFixture.ts | 3 - .../kuna/kunaSpanCloseOwnership.test.ts | 7 +- .../providers/kuna/kunaStrings.test.ts | 9 +- .../native/lldbLauncherBoundary.test.ts | 19 +-- .../providers/native/nativeProvider.test.ts | 2 +- .../providers/startupCloseWaiters.test.ts | 3 +- .../projectWaitDeadlineRule.test.ts | 49 +++++++ tests/evaluation/testDeadlines.test.ts | 27 ++++ tests/fixtures/android/jadx.ts | 19 ++- tests/fixtures/lifecycleAcknowledgement.ts | 9 +- tests/fixtures/outputTreeCommit.ts | 3 +- tests/fixtures/providerProcess.ts | 5 +- .../browserScenarioCleanup.test.ts | 3 +- .../darwinCompilerCleanup.test.ts | 16 +-- .../environmentFinalizationCli.test.ts | 7 +- .../mainStartupCancellation.test.ts | 8 +- tests/process-global/pwntoolsCleanup.test.ts | 2 +- .../process-global/runnerCancellation.test.ts | 9 +- .../windowsOwnedCommand.test.ts | 11 +- tests/support/process/processFixture.ts | 38 +----- tests/support/runningTestDeadline.ts | 15 +++ tests/support/testDeadlines.ts | 24 ++++ tests/support/testWait.ts | 34 +++++ vitest.config.ts | 11 ++ 119 files changed, 818 insertions(+), 648 deletions(-) create mode 100644 scripts/lint/rules/project-wait-deadline.d.mts create mode 100644 scripts/lint/rules/project-wait-deadline.mjs create mode 100644 tests/evaluation/projectWaitDeadlineRule.test.ts create mode 100644 tests/support/runningTestDeadline.ts create mode 100644 tests/support/testWait.ts diff --git a/.agents/skills/rea-ci-tests-audit/SKILL.md b/.agents/skills/rea-ci-tests-audit/SKILL.md index 246bb6dcae..8d87800305 100644 --- a/.agents/skills/rea-ci-tests-audit/SKILL.md +++ b/.agents/skills/rea-ci-tests-audit/SKILL.md @@ -97,7 +97,10 @@ Run the repository's applicable checks and inspect complete current-head CI results before an authorized merge. Preserve required coverage and discovery gates. Do not suppress a failure, add blanket retries, raise timeouts, or delete distinct assertions solely to make CI green. Changes to these controls need -their own demonstrated reason and retained failure detection. +their own demonstrated reason and retained failure detection. A slow-host +failure at a literal test, child, or wait deadline belongs to the +[test deadline policy](../../../docs/development/testing.md#developer-commands): +remove the literal rather than raise it, and keep promptness oracles explicit. Report each confirmed issue with exact revision, reproduction, consequence, proposed correction, and validation limits. Report performance with workload diff --git a/.oxlintrc.json b/.oxlintrc.json index 33c616f4d7..9a3e7a801e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -172,6 +172,12 @@ "vitest/no-focused-tests": "error" } }, + { + "files": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.fixture.ts"], + "rules": { + "rea/project-wait-deadline": "error" + } + }, { "files": [ "scripts/lib/http3-capture-assertions.test.mjs", diff --git a/docs/development/testing.md b/docs/development/testing.md index d165518f79..00fe51255d 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -186,6 +186,9 @@ the caller's reason. Keep focused boundary and lifecycle tests for those facts. operation names, schemas or types; display-only selection remains allowed. `rea/no-fixture-root-cleanup` follows `const` fixture-root aliases into teardown deletion; mutable bindings and mixed root arrays remain outside its scope. +`rea/project-wait-deadline` resolves Vitest's `vi` and `expect` bindings, +including aliases and namespace imports; a shadowed local or a fixture method +with the same name is not reported. `rea/no-shared-module-mock` resolves Vitest import bindings before rejecting module mocks in projects without per-file isolation. The test-project definitions @@ -316,6 +319,16 @@ child fails with its captured output. Pass `timeoutMs` only when the child's dea behavior under test. Derive an explicit deadline only when a test must outlive an operation deadline whose diagnostics it checks. +Waits follow the same policy; Vitest's one-second `expect.poll` and +`vi.waitFor` defaults assume a host's speed. A wait may take as long as its +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. +`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 +otherwise satisfy it, or compare elapsed time with the deadline it must beat. + Keep capacity fixtures intact when diagnosing a test failure. For byte equality of large buffers, use `Buffer.equals` or `Buffer.compare`; a generic structural diff can allocate far more than the production operation. diff --git a/scripts/lint/index.mjs b/scripts/lint/index.mjs index 72075f0698..82e12c33d0 100644 --- a/scripts/lint/index.mjs +++ b/scripts/lint/index.mjs @@ -7,6 +7,7 @@ import noPositionalToolContract from "./rules/no-positional-tool-contract.mjs"; import noSharedModuleMock from "./rules/no-shared-module-mock.mjs"; import noLocaleCompare from "./rules/no-locale-compare.mjs"; import noBabelVisitorKeys from "./rules/no-babel-visitor-keys.mjs"; +import projectWaitDeadline from "./rules/project-wait-deadline.mjs"; export default { meta: { name: "rea" }, @@ -19,5 +20,6 @@ export default { "no-shared-module-mock": noSharedModuleMock, "no-locale-compare": noLocaleCompare, "no-babel-visitor-keys": noBabelVisitorKeys, + "project-wait-deadline": projectWaitDeadline, }, }; diff --git a/scripts/lint/rules/project-wait-deadline.d.mts b/scripts/lint/rules/project-wait-deadline.d.mts new file mode 100644 index 0000000000..86d61729e2 --- /dev/null +++ b/scripts/lint/rules/project-wait-deadline.d.mts @@ -0,0 +1,4 @@ +import type { RuleTester } from "oxlint/plugins-dev"; + +declare const rule: Parameters[1]; +export default rule; diff --git a/scripts/lint/rules/project-wait-deadline.mjs b/scripts/lint/rules/project-wait-deadline.mjs new file mode 100644 index 0000000000..235d694edb --- /dev/null +++ b/scripts/lint/rules/project-wait-deadline.mjs @@ -0,0 +1,92 @@ +import { findBinding } from "../bindings.mjs"; + +const unwrap = (node) => + node?.type === "ChainExpression" ? node.expression : node; + +const propertyName = (member) => { + if (!member?.computed && member?.property?.type === "Identifier") + return member.property.name; + if (member?.computed && member.property?.type === "Literal") + return member.property.value; + return undefined; +}; + +/** The `vitest` import specifier a local identifier is bound to, if any. */ +const vitestImport = (sourceCode, identifier) => { + const definition = findBinding(sourceCode, identifier)?.defs[0]; + if (definition?.type !== "ImportBinding") return undefined; + const declaration = definition.parent ?? definition.node.parent; + if (declaration?.source?.value !== "vitest") return undefined; + return definition.node; +}; + +/** Whether `node` is Vitest's `name` export: `name` or `vitest.name`. */ +const isVitestExport = (sourceCode, node, name) => { + const target = unwrap(node); + if (target?.type === "Identifier") { + const specifier = vitestImport(sourceCode, target); + return ( + specifier?.type === "ImportSpecifier" && + (specifier.imported.name ?? specifier.imported.value) === name + ); + } + return ( + target?.type === "MemberExpression" && + propertyName(target) === name && + target.object?.type === "Identifier" && + vitestImport(sourceCode, target.object)?.type === "ImportNamespaceSpecifier" + ); +}; + +const isVitestMethod = (sourceCode, callee, receiver, method) => { + const member = unwrap(callee); + return ( + member?.type === "MemberExpression" && + propertyName(member) === method && + isVitestExport(sourceCode, member.object, receiver) + ); +}; + +/** A wait timeout written at the call: a number, or an options `timeout`. */ +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", + ); +}; + +export default { + meta: { + type: "problem", + docs: { + description: + "Keep test wait deadlines owned by the project test deadline policy.", + }, + schema: [], + messages: { + viWaitFor: + "vi.waitFor defaults to a one-second host-speed guess; use waitFor from tests/support/testWait.ts, which the test deadline policy bounds.", + pollTimeout: + "The project owns expect.poll's deadline; remove this timeout. When promptness is the behavior under test, assert elapsed time or order the condition before a release gate instead.", + }, + }, + create(context) { + const sourceCode = context.sourceCode; + return { + CallExpression(node) { + if (isVitestMethod(sourceCode, node.callee, "vi", "waitFor")) { + context.report({ node, messageId: "viWaitFor" }); + return; + } + if (!isVitestMethod(sourceCode, node.callee, "expect", "poll")) return; + const timeout = explicitTimeout(node.arguments[1]); + if (timeout !== undefined) + context.report({ node: timeout, messageId: "pollTimeout" }); + }, + }; + }, +}; diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 5d950e0490..eaa64105b8 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -72,6 +72,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { parseEvidence } from "../domain/evidence.js"; import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js"; import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; +import { waitFor } from "../../tests/support/testWait.js"; const it = test.skipIf(process.platform === "win32"); @@ -166,13 +167,11 @@ fi`, let closing: Promise | undefined; try { await awaitAcknowledgement( - vi.waitFor( - async () => expect(await lstat(join(root, "started"))).toBeDefined(), - { timeout: 10_000 }, + waitFor(async () => + expect(await lstat(join(root, "started"))).toBeDefined(), ), inspection, "ADB version probe", - 10_000, ); let closed = false; closing = provider.close().then(() => { diff --git a/src/apktool/ApktoolVersionProbe.lifecycle.test.ts b/src/apktool/ApktoolVersionProbe.lifecycle.test.ts index f88f37bce7..7bea2db37b 100644 --- a/src/apktool/ApktoolVersionProbe.lifecycle.test.ts +++ b/src/apktool/ApktoolVersionProbe.lifecycle.test.ts @@ -36,7 +36,7 @@ it.skipIf(process.platform === "win32")( ({ kind }) => kind === "running", ); }, - { timeout: 10_000, interval: 25 }, + { interval: 25 }, ) .toBe(true); controller.abort(); @@ -50,7 +50,7 @@ it.skipIf(process.platform === "win32")( (await Promise.all(launched.map(observePosixProcess))).every( ({ kind }) => kind !== "running", ), - { timeout: 5_000, interval: 25 }, + { interval: 25 }, ) .toBe(true); } finally { diff --git a/src/application/LinuxHopperPython.test.ts b/src/application/LinuxHopperPython.test.ts index e6a0011560..d8c199910b 100644 --- a/src/application/LinuxHopperPython.test.ts +++ b/src/application/LinuxHopperPython.test.ts @@ -10,6 +10,7 @@ import { execFileOutput, } from "../process/ExecFileOutput.js"; import { inspectLinuxHopperPythonRuntime } from "./LinuxHopperPython.js"; +import { waitFor } from "../../tests/support/testWait.js"; const response = (value: unknown) => { const stdout = JSON.stringify(value); @@ -157,8 +158,7 @@ setInterval(() => {}, 1000); execFileOutput(process.execPath, ["-e", program], options), ); try { - await vi.waitFor(() => access(stopping), { - timeout: 5_000, + await waitFor(() => access(stopping), { interval: 10, }); controller.abort({ request: "cancel during failed command cleanup" }); @@ -209,10 +209,9 @@ while True: return result; }); try { - await vi.waitFor(() => access(ready), { timeout: 5_000, interval: 10 }); + await waitFor(() => access(ready), { interval: 10 }); controller.abort(reason); - await vi.waitFor(() => access(stopping), { - timeout: 3_000, + await waitFor(() => access(stopping), { interval: 10, }); // The child cannot exit until released; early settlement loses ownership. @@ -224,8 +223,7 @@ while True: controller.abort(reason); await execution; } - await vi.waitFor(() => access(stopped), { - timeout: 5_000, + await waitFor(() => access(stopped), { interval: 10, }); } diff --git a/src/artifacts/ZipArtifactReader.test.ts b/src/artifacts/ZipArtifactReader.test.ts index aeefc7def7..1380d84ffc 100644 --- a/src/artifacts/ZipArtifactReader.test.ts +++ b/src/artifacts/ZipArtifactReader.test.ts @@ -32,6 +32,7 @@ import { hashReadable } from "./ArtifactHash.js"; import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; import { analysisErrorUserMessage } from "../domain/analysisErrorPresentation.js"; +import { waitFor } from "../../tests/support/testWait.js"; const MINIMUM_ARTIFACT_EXPANDED_BYTES = 256 * 1024 * 1024; @@ -398,27 +399,21 @@ describe("ZIP Zstandard member streams", () => { expect(decoders).toHaveLength(1); // A fresh decoder can succeed while an abandoned producer is stuck. // Verify the first actual getData and native close before starting it. - await vi.waitFor( - () => { - expect(producers[0]?.settled).toBe(true); - expect(decoders[0]?.closed).toBe(true); - expect(decoders[0]?.destroyed).toBe(true); - }, - { timeout: 5_000 }, - ); + await waitFor(() => { + expect(producers[0]?.settled).toBe(true); + expect(decoders[0]?.closed).toBe(true); + expect(decoders[0]?.destroyed).toBe(true); + }); const chunks: Buffer[] = []; for await (const chunk of await reader.open(entry)) chunks.push(Buffer.from(chunk)); expect(Buffer.concat(chunks).equals(content)).toBe(true); expect(producers).toHaveLength(2); expect(decoders).toHaveLength(2); - await vi.waitFor( - () => { - expect(producers.every(({ settled }) => settled)).toBe(true); - expect(decoders.every(({ closed }) => closed)).toBe(true); - }, - { timeout: 5_000 }, - ); + await waitFor(() => { + expect(producers.every(({ settled }) => settled)).toBe(true); + expect(decoders.every(({ closed }) => closed)).toBe(true); + }); } finally { controller.abort(); await reader.close(); diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index d1643aaac3..6815cf6edf 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -15,6 +15,7 @@ import { analyzeAppleAssetCatalogs, collectInterfaceBuilderResourceKeys, } from "./AppleAssetCatalogAnalysis.js"; +import { waitFor } from "../../../tests/support/testWait.js"; describe("Apple asset catalog traversal coverage", () => { it.for([false, true])( @@ -412,7 +413,7 @@ describe("Apple asset catalog and Interface Builder concurrency", () => { settled = true; }); analysis.catch(() => undefined); - await vi.waitFor(() => expect(catalogStarted).toBe(true)); + await waitFor(() => expect(catalogStarted).toBe(true)); await new Promise((resolve) => setImmediate(resolve)); expect(settled).toBe(false); finishCatalog("[]"); @@ -452,7 +453,7 @@ describe("Apple asset catalog and Interface Builder concurrency", () => { signal: controller.signal, analyzeInterfaceBuilder: async () => emptyGraph, }).catch((cause: unknown) => cause); - await vi.waitFor(() => expect(access(ready)).resolves.toBeUndefined()); + await waitFor(() => expect(access(ready)).resolves.toBeUndefined()); controller.abort("stop asset inspection"); expect(await settled).toMatchObject({ reason: "cancelled" }); await expect(access(exited)).resolves.toBeUndefined(); @@ -746,7 +747,7 @@ it.skipIf(process.platform === "win32")( settled = true; }); try { - await vi.waitFor(() => expect(access(exited)).resolves.toBeUndefined()); + await waitFor(() => expect(access(exited)).resolves.toBeUndefined()); expect(settled).toBe(false); controller.abort("late cancellation"); expect(settled).toBe(false); diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 90e4a07f5a..2805d548a2 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -32,6 +32,7 @@ import { PlaywrightElectronActiveProvider, safeElectronEnvironment, } from "./PlaywrightElectronActiveProvider.js"; +import { waitFor } from "../../tests/support/testWait.js"; afterEach(() => { vi.restoreAllMocks(); @@ -1051,7 +1052,7 @@ it("reports an expired final state grace period as the caller's cancellation", a }; const capture = captureWithController(controller, application, []); - await vi.waitFor(() => expect(evaluate).toHaveBeenCalled()); + await waitFor(() => expect(evaluate).toHaveBeenCalled()); await vi.advanceTimersByTimeAsync(5_000); const result = await capture; diff --git a/src/browser/PlaywrightScenarioEvents.test.ts b/src/browser/PlaywrightScenarioEvents.test.ts index 659b6e1498..217896c76a 100644 --- a/src/browser/PlaywrightScenarioEvents.test.ts +++ b/src/browser/PlaywrightScenarioEvents.test.ts @@ -4,6 +4,7 @@ import type { BrowserContext, Page, Request, Response } from "playwright-core"; import { browserScenarioSchema } from "../domain/browserScenario.js"; import { resolveScenarioSecrets } from "../../tests/fixtures/browserScenarioSecrets.js"; import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js"; +import { waitFor } from "../../tests/support/testWait.js"; describe("declared secrets in scenario events", () => { it("redacts encoded declared secrets from text evidence and fingerprints", () => { @@ -544,7 +545,7 @@ const sourceTransactionCapture = async ( }; listeners.get("request")!(request); if (!options.metadataOnly) - await vi.waitFor(() => + await waitFor(() => expect(capture.result().items).toContainEqual( expect.objectContaining({ kind: "network-content", phase: "request" }), ), @@ -592,7 +593,7 @@ const sourceTransactionCapture = async ( }); listeners.get("requestfinished")!(followup); if (!options.metadataOnly) - await vi.waitFor(() => + await waitFor(() => expect( capture .result() diff --git a/src/browser/PlaywrightScenarioRequest.test.ts b/src/browser/PlaywrightScenarioRequest.test.ts index 3f683f183f..3fb7105855 100644 --- a/src/browser/PlaywrightScenarioRequest.test.ts +++ b/src/browser/PlaywrightScenarioRequest.test.ts @@ -6,6 +6,7 @@ import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js"; import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; import { responsePointerValue } from "./BrowserScenarioResponseValues.js"; import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js"; +import { waitFor } from "../../tests/support/testWait.js"; const ORIGIN = "https://app.example.test"; @@ -1614,7 +1615,7 @@ describe("ambiguous saved response observations", () => { ]); const running = perform(page, parsed.actions[0]!, secrets); const rejected = expect(running).rejects.toThrow("ambiguous"); - await vi.waitFor(() => expect(reads.count).toBe(1)); + await waitFor(() => expect(reads.count).toBe(1)); for (const listener of listeners) listener({ method: () => "POST", url: () => `${ORIGIN}/api/items` }); release('{"id":"wrong-object"}'); diff --git a/src/browser/PlaywrightScenarioSession.test.ts b/src/browser/PlaywrightScenarioSession.test.ts index 8b66b90819..027c20ba86 100644 --- a/src/browser/PlaywrightScenarioSession.test.ts +++ b/src/browser/PlaywrightScenarioSession.test.ts @@ -121,6 +121,7 @@ vi.mock("./PlaywrightScenarioEvents.js", () => ({ import { browserScenarioSchema } from "../domain/browserScenario.js"; import { PlaywrightScenarioSession } from "./PlaywrightScenarioSession.js"; import { PlaywrightScenarioBrowserCleanupOwner } from "./PlaywrightScenarioBrowser.js"; +import { waitFor } from "../../tests/support/testWait.js"; const scenario = browserScenarioSchema.parse({ browser: { mode: "launch", executable_path: process.execPath }, @@ -1304,7 +1305,7 @@ it("can retry cleanup of a browser that opened after request cancellation", asyn controller.abort(); await cancelled; resolveOpening?.(lateOpened); - await vi.waitFor(() => expect(closeBrowser).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(closeBrowser).toHaveBeenCalledTimes(1)); expect(retained).toHaveLength(1); await expect(retained[0]?.release()).resolves.toBeUndefined(); expect(closeBrowser).toHaveBeenCalledTimes(2); diff --git a/src/desktop/AgentEnvSolveHelper.test.ts b/src/desktop/AgentEnvSolveHelper.test.ts index f9645279b5..95ccbdb293 100644 --- a/src/desktop/AgentEnvSolveHelper.test.ts +++ b/src/desktop/AgentEnvSolveHelper.test.ts @@ -70,7 +70,7 @@ describe("runSolveHelper", () => { // agentenv keeps a sandbox's folder until its teardown is confirmed, so the // emptied private root shows nothing is left up: only the cancellation remains. try { - await awaitAcknowledgement(started, solved, "solve helper start", 10_000); + await awaitAcknowledgement(started, solved, "solve helper start"); stop.abort(new Error("cancelled by the test")); const failure: unknown = await solved.then( () => undefined, diff --git a/src/desktop/ScreenJudgeProcess.integration.test.ts b/src/desktop/ScreenJudgeProcess.integration.test.ts index c9aa808262..215a64c659 100644 --- a/src/desktop/ScreenJudgeProcess.integration.test.ts +++ b/src/desktop/ScreenJudgeProcess.integration.test.ts @@ -1,9 +1,10 @@ import { execFileSync } from "node:child_process"; import { chmod, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { ScreenJudgeProcess } from "./ScreenJudgeProcess.js"; +import { waitFor } from "../../tests/support/testWait.js"; const check = { id: "x", kind: "screen_judge", question: "ready?" } as const; @@ -68,13 +69,10 @@ it.skipIf(process.platform === "win32")( void running.catch(() => undefined); let pid = 0; try { - await vi.waitFor( - async () => { - pid = Number(await readFile(marker, "utf8")); - expect(pid).toBeGreaterThan(0); - }, - { timeout: 10000 }, - ); + await waitFor(async () => { + pid = Number(await readFile(marker, "utf8")); + expect(pid).toBeGreaterThan(0); + }); controller.abort(new Error("cancelled by caller")); await expect(running).rejects.toThrow(); let state = ""; diff --git a/src/desktop/ScreenJudgeProcess.test.ts b/src/desktop/ScreenJudgeProcess.test.ts index 1e88ca86fa..99d49d6ada 100644 --- a/src/desktop/ScreenJudgeProcess.test.ts +++ b/src/desktop/ScreenJudgeProcess.test.ts @@ -14,6 +14,7 @@ import type { import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ScreenJudgeProcess } from "./ScreenJudgeProcess.js"; +import { waitFor } from "../../tests/support/testWait.js"; vi.mock("./AgentEnvComputerHelper.js", async (importOriginal) => ({ ...(await importOriginal()), @@ -261,7 +262,7 @@ it("waits for final process cleanup after cancellation and keeps its owner", asy settled = true; }); // Wait for the mocked command to receive control, not just for the judge to start. - await vi.waitFor(() => + await waitFor(() => expect(vi.mocked(runOwnedCommand).mock.calls.at(-1)?.[2]?.signal).toBe( controller.signal, ), diff --git a/src/process/capture/ProcessSamplingShutdown.test.ts b/src/process/capture/ProcessSamplingShutdown.test.ts index 34c4ac0a00..1e298bc5a1 100644 --- a/src/process/capture/ProcessSamplingShutdown.test.ts +++ b/src/process/capture/ProcessSamplingShutdown.test.ts @@ -12,6 +12,7 @@ import { } from "./ProcessCaptureError.js"; import { ProcessCaptureResourceScope } from "./ProcessCaptureLifecycle.js"; import { startProcessSampler } from "./ProcessSampling.js"; +import { waitFor } from "../../../tests/support/testWait.js"; vi.mock("./LinuxProcessSampling.js", async (original) => ({ ...(await original()), @@ -135,7 +136,7 @@ it.each(["plain", "all-cancel", "mixed-fd", "opaque-peer"] as const)( Object.defineProperty(process, "platform", platform); } }); - await vi.waitFor(() => expect(environment).toHaveBeenCalledOnce()); + await waitFor(() => expect(environment).toHaveBeenCalledOnce()); const outcome = stop(); if (kind === "opaque-peer") { const failure: unknown = await outcome.catch((cause: unknown) => cause); diff --git a/src/server/http/nodeHttpBridge.test.ts b/src/server/http/nodeHttpBridge.test.ts index bef934ebe2..68bdfa7c02 100644 --- a/src/server/http/nodeHttpBridge.test.ts +++ b/src/server/http/nodeHttpBridge.test.ts @@ -2,13 +2,14 @@ import { once } from "node:events"; import { createServer, type Server } from "node:http"; import { connect } from "node:net"; import type { AddressInfo } from "node:net"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, expect, it } from "vitest"; import { toWebRequest, writeWebResponse, type WebResponseOptions, } from "./nodeHttpBridge.js"; +import { waitFor } from "../../../tests/support/testWait.js"; const servers: Server[] = []; const encoder = new TextEncoder(); @@ -66,7 +67,7 @@ it("ends a HEAD reply after the headers and cancels a body the handler attached" const response = await fetch(`${origin}/events`, { method: "HEAD" }); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("text/event-stream"); - await vi.waitFor(() => { + await waitFor(() => { expect(cancelled).toBe(true); }); expect(pulls).toBeLessThanOrEqual(1); @@ -93,7 +94,7 @@ it("finishes a HEAD reply without waiting on a teed body whose sibling stays liv method: "HEAD", }); expect(response.status).toBe(200); - await vi.waitFor(() => { + await waitFor(() => { expect(finished).toBe(true); }); }); @@ -126,11 +127,11 @@ it("cancels the body when an asynchronous route resolves after its client left", const socket = connect(port, "127.0.0.1"); await once(socket, "connect"); socket.write("GET /late HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); - await vi.waitFor(() => { + await waitFor(() => { expect(received).toBe(true); }); socket.destroy(); - await vi.waitFor(() => { + await waitFor(() => { expect(settled).toBe(true); expect(cancelled).toBe(true); }); @@ -165,7 +166,7 @@ it("returns when a client leaves a teed streaming body whose sibling stays live" const first = await response.body.getReader().read(); expect(decoder.decode(first.value)).toBe("data: first\n\n"); leave.abort(); - await vi.waitFor(() => { + await waitFor(() => { expect(settled).toBe(true); }); }); @@ -266,7 +267,7 @@ it("aborts the web Request and cancels its response body when the client leaves" if (reader === undefined) throw new Error("Response body missing"); await reader.read(); controller.abort(); - await vi.waitFor(() => { + await waitFor(() => { expect(aborted).toBe(true); expect(cancelled).toBe(true); }); diff --git a/src/server/http/serveStreamableHttp.test.ts b/src/server/http/serveStreamableHttp.test.ts index f47df71ffe..c605f7950b 100644 --- a/src/server/http/serveStreamableHttp.test.ts +++ b/src/server/http/serveStreamableHttp.test.ts @@ -25,6 +25,7 @@ import { StreamableHttpListenError } from "./streamableHttpListenError.js"; import { mcpAllowedHostsTextSchema } from "../../config/mcpHttpEndpoint.js"; import { parseMcpHttpArguments } from "../../main/mcpHttpArguments.js"; import { RequestTrackingMcpServer } from "../RequestTrackingMcpServer.js"; +import { waitFor } from "../../../tests/support/testWait.js"; const servers: StreamableHttpServer[] = []; @@ -1318,7 +1319,7 @@ it.each(["chunk", "headers"])( .catch(() => undefined); let closing: Promise | undefined; try { - await vi.waitFor(() => { + await waitFor(() => { expect(cancelled).toBe(1); }); expect((await fetch(new URL("/health", served.url))).status).toBe(503); @@ -1403,7 +1404,7 @@ it("retains failed response cleanup and blocks provider teardown and further adm const response = await fetch(new URL("/failed", served.url)); try { await response.body?.cancel(); - await vi.waitFor(async () => { + await waitFor(async () => { expect((await fetch(new URL("/health", served.url))).status).toBe(503); }); await expect(served.prepareShutdown()).rejects.toMatchObject({ @@ -1459,7 +1460,7 @@ it("retains plain-response admission until asynchronous cancellation settles", a expect((await fetch(new URL("/health", served.url))).status).toBe(503); expect(health).not.toHaveBeenCalled(); cleanup.release(); - await vi.waitFor(async () => { + await waitFor(async () => { expect((await fetch(new URL("/health", served.url))).status).toBe(200); }); } finally { @@ -1562,10 +1563,10 @@ it.each(["pending handler", "open response"])( await first.promise; socket.write(request("/second")); socket.write(request("/health").repeat(32)); - await vi.waitFor(() => { + await waitFor(() => { expect(socket.destroyed).toBe(true); }); - await vi.waitFor(() => { + await waitFor(() => { expect(finalized).toBe(mode === "pending handler" ? 1 : 0); }); expect(secondCalled).toBe(0); @@ -1687,12 +1688,12 @@ it("retains admission for an unread upload attached to a long response", async ( upload.once("error", () => undefined); upload.write("prefix"); try { - await vi.waitFor(() => { + await waitFor(() => { expect(received).toBe(true); }); expect((await fetch(new URL("/health", served.url))).status).toBe(503); upload.destroy(); - await vi.waitFor(async () => { + await waitFor(async () => { expect((await fetch(new URL("/health", served.url))).status).toBe(200); }); } finally { @@ -1737,7 +1738,7 @@ it("bounds HTTP input dispatch globally and releases admission when an upload le } // A plain route observes the same budget while both sessionless MCP probes // wait for the rest of their bodies. Idle SSE connections consume no permit. - await vi.waitFor(async () => { + await waitFor(async () => { expect((await fetch(new URL("/health", served.url))).status).toBe(503); }); const refused = await rawHttpRequest(new URL(served.url), { @@ -1752,7 +1753,7 @@ it("bounds HTTP input dispatch globally and releases admission when an upload le expect(refused.headers.connection).toBe("close"); uploads[0]?.destroy(); await pending[0]; - await vi.waitFor(async () => { + await waitFor(async () => { expect((await fetch(new URL("/health", served.url))).status).toBe(200); }); expect(await echo(first.client, "kept during overload")).toEqual([ @@ -1862,7 +1863,7 @@ it.each(["DELETE", "shutdown"])( upload.write('{"jsonrpc":"2.0","id":2,"method":"tools/call",'); }); try { - await vi.waitFor(() => { + await waitFor(() => { expect( entered.mock.calls.some( ([request]) => diff --git a/tests/acceptance/providers/hopperStartupRecovery.test.ts b/tests/acceptance/providers/hopperStartupRecovery.test.ts index 0aae52cec5..63a258f690 100644 --- a/tests/acceptance/providers/hopperStartupRecovery.test.ts +++ b/tests/acceptance/providers/hopperStartupRecovery.test.ts @@ -8,13 +8,14 @@ import { dirname, resolve } from "node:path"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { expect, onTestFinished, vi } from "vitest"; +import { expect, onTestFinished } from "vitest"; import { z } from "zod"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; +import { waitFor } from "../../support/testWait.js"; const statusSchema = z.object({ result: z.object({ open: z.boolean() }) }); const test = workspaceCliTest.skipIf(process.platform !== "darwin"); @@ -365,7 +366,7 @@ test.for(["handoff", "not-found"] as const)( [runId, "different-token"], ] as const) { const wrong = lateBridge(identity, credential); - await vi.waitFor(() => access(socket)); + await waitFor(() => access(socket)); expect(parseMcpToolError(await call("close_binary")).error.code).toBe( "cleanup_incomplete", ); @@ -375,7 +376,7 @@ test.for(["handoff", "not-found"] as const)( await rm(socket, { force: true }); } const owned = await closeWithLateBridge(mcp, () => lateBridge(runId)); - await vi.waitFor(() => expect(owned.exitCode).toBe(0)); + await waitFor(() => expect(owned.exitCode).toBe(0)); await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" }); await writeFile(mode, "bridge"); expect((await call("open_binary", { path: target })).isError).not.toBe( diff --git a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts index 619866d86e..430da5c64b 100644 --- a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts +++ b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts @@ -53,7 +53,7 @@ it("preserves file URL scheme spelling and script identity after real Inspector stderr += chunk.toString(); }); const address = /ws:\/\/127\.0\.0\.1:\d+\/[^\s]+/u; - await expect.poll(() => stderr, { timeout: 5000 }).toMatch(address); + await expect.poll(() => stderr).toMatch(address); const socket = address.exec(stderr)?.[0]; if (socket === undefined) throw new Error("Node omitted its Inspector URL"); const endpoint = `http://127.0.0.1:${new URL(socket).port}`; diff --git a/tests/acceptance/tools/inspectorLifecycle.test.ts b/tests/acceptance/tools/inspectorLifecycle.test.ts index cebeef7db7..6447bd004a 100644 --- a/tests/acceptance/tools/inspectorLifecycle.test.ts +++ b/tests/acceptance/tools/inspectorLifecycle.test.ts @@ -29,7 +29,7 @@ it("observes a real Node Inspector and releases attachments after cancellation a stderr += chunk.toString(); }); const address = /ws:\/\/127\.0\.0\.1:\d+\/[^\s]+/u; - await expect.poll(() => stderr, { timeout: 5000 }).toMatch(address); + await expect.poll(() => stderr).toMatch(address); const socket = address.exec(stderr)?.[0]; if (socket === undefined) throw new Error("Node omitted its Inspector URL"); const endpoint = `http://127.0.0.1:${new URL(socket).port}`; @@ -91,17 +91,21 @@ it("observes a real Node Inspector and releases attachments after cancellation a const detachments = () => (stderr.match(/Debugger ending on/gu) ?? []).length; const before = attachments(); const controller = new AbortController(); + const observationMs = 10_000; + const started = performance.now(); const cancelled = expect( call( "observe_javascript_runtime", - { ...request, observation_ms: 10_000 }, + { ...request, observation_ms: observationMs }, { signal: controller.signal }, ), ).rejects.toThrow("cancel live observation"); - await expect.poll(attachments, { timeout: 5000 }).toBe(before + 1); + await expect.poll(attachments).toBe(before + 1); controller.abort(new Error("cancel live observation")); await cancelled; - await expect.poll(detachments, { timeout: 5000 }).toBe(attachments()); + await expect.poll(detachments).toBe(attachments()); + // An ignored cancellation still detaches, but only when the window ends. + expect(performance.now() - started).toBeLessThan(observationMs); expect(child.exitCode).toBeNull(); expect(child.signalCode).toBeNull(); expect((await call("observe_javascript_runtime", request)).isError).not.toBe( diff --git a/tests/acceptance/tools/wabtLauncherIdentity.test.ts b/tests/acceptance/tools/wabtLauncherIdentity.test.ts index 57a26e24f0..32fe186fbb 100644 --- a/tests/acceptance/tools/wabtLauncherIdentity.test.ts +++ b/tests/acceptance/tools/wabtLauncherIdentity.test.ts @@ -181,10 +181,8 @@ cliTest.skipIf(process.platform === "win32")( if (decoder === undefined) throw new Error("No decoder process was observed"); await expect - .poll( - () => - observeProcessStartIdentity(decoder.pid, undefined, decoder.run_id), - { timeout: 6000 }, + .poll(() => + observeProcessStartIdentity(decoder.pid, undefined, decoder.run_id), ) .toBeUndefined(); } diff --git a/tests/boundary/android/androidTargetSnapshot.test.ts b/tests/boundary/android/androidTargetSnapshot.test.ts index 1280fbfa31..feba8c1755 100644 --- a/tests/boundary/android/androidTargetSnapshot.test.ts +++ b/tests/boundary/android/androidTargetSnapshot.test.ts @@ -182,7 +182,7 @@ it.skipIf(process.platform === "win32")( ({ size }) => size > 0, () => false, ), - { timeout: 10_000, interval: 10 }, + { interval: 10 }, ) .toBe(true); controller.abort(); diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index 0ec203b95c..e166a90963 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -358,13 +358,11 @@ it("cancels an in-flight Java readiness probe", async () => { ); try { await expect - .poll( - () => - readFile(marker, "utf8").then( - () => true, - () => false, - ), - { timeout: 10_000 }, + .poll(() => + readFile(marker, "utf8").then( + () => true, + () => false, + ), ) .toBe(true); controller.abort(); diff --git a/tests/boundary/browser/callerCancellationReason.test.ts b/tests/boundary/browser/callerCancellationReason.test.ts index 773c21bb15..76539c8cbc 100644 --- a/tests/boundary/browser/callerCancellationReason.test.ts +++ b/tests/boundary/browser/callerCancellationReason.test.ts @@ -1,8 +1,9 @@ -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { WebRuntimeService } from "../../../src/application/WebRuntimeService.js"; import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; +import { waitFor } from "../../support/testWait.js"; it.each([ new Error("operator stopped observation"), @@ -41,10 +42,10 @@ it.each([ (command) => command.method === "Profiler.stopPreciseCoverage", ), ).toHaveLength(2); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); const after = await service.observe(input); if (!after.ok) throw after.error; - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { await provider.close(); await browser.close(); @@ -85,7 +86,7 @@ it("preserves the caller reason when a navigation observation is cancelled at ar expect(result.error._tag).toBe("AnalysisCancelledError"); expect(result.error.cause).toBe(reason); expect(result.error.cleanupIncomplete).toBe(false); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { await provider.close(); await browser.close(); diff --git a/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts b/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts index cb487a8011..182565b9fd 100644 --- a/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts @@ -19,6 +19,7 @@ import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreensh import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { trackBrowser } from "./cdpBrowserProvider.support.js"; +import { waitFor } from "../../support/testWait.js"; describe("CdpBrowserProvider: browser socket without HTTP discovery", () => { it("reuses one admitted browser socket across sequential operations and closes on provider shutdown", async () => { @@ -97,7 +98,7 @@ describe("CdpBrowserProvider: browser socket without HTTP discovery", () => { } finally { await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); }); @@ -117,7 +118,7 @@ describe("CdpBrowserProvider: browser socket eviction and per-call scoping", () expect(browser.activeConnectionCount()).toBe(1); browser.closeActiveConnections(); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); const second = await provider.inspectPage( inspectWebPageInputSchema.parse({ @@ -133,7 +134,7 @@ describe("CdpBrowserProvider: browser socket eviction and per-call scoping", () } finally { await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("closes a shared browser socket when Target.detachFromTarget fails so no target session leaks", async () => { @@ -154,7 +155,7 @@ describe("CdpBrowserProvider: browser socket eviction and per-call scoping", () }), ); if (!inspected.ok) throw inspected.error; - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); expect(browser.openedConnectionCount()).toBe(1); const listed = await provider.listTargets( @@ -168,7 +169,7 @@ describe("CdpBrowserProvider: browser socket eviction and per-call scoping", () } finally { await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("preserves per-operation command_timeout_ms and operation identity on a reused browser socket", async () => { @@ -208,7 +209,7 @@ describe("CdpBrowserProvider: browser socket eviction and per-call scoping", () } finally { await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); }); @@ -275,7 +276,7 @@ it("retains browser-socket siblings and reports invalid attachment as unknown, n } finally { await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); describe("CdpBrowserProvider: browser socket failures and cleanup", () => { @@ -315,7 +316,7 @@ describe("CdpBrowserProvider: browser socket failures and cleanup", () => { } finally { await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("rejects malformed browser-socket discovery and closes the socket", async () => { @@ -340,7 +341,7 @@ describe("CdpBrowserProvider: browser socket failures and cleanup", () => { reason: "invalid_endpoint_response", }, }); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("reports a refused browser socket as unreachable", async () => { @@ -379,7 +380,7 @@ describe("CdpBrowserProvider: browser socket failures and cleanup", () => { }), { signal: controller.signal }, ); - await vi.waitFor(() => + await waitFor(() => expect(browser.commands.map(({ method }) => method)).toContain( "Target.getTargets", ), @@ -393,7 +394,7 @@ describe("CdpBrowserProvider: browser socket failures and cleanup", () => { expect(browser.commands.map(({ method }) => method)).not.toContain( "Target.attachToTarget", ); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); }); @@ -429,7 +430,7 @@ describe("CdpBrowserProvider: browser socket discovery deadline", () => { }); trackBrowser(browser); await expectDiscoveryTimeout(browser.browserWebSocketUrl); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("times out a WebSocket handshake that never completes", async () => { @@ -451,7 +452,7 @@ describe("CdpBrowserProvider: browser socket discovery deadline", () => { await expectDiscoveryTimeout( `ws://127.0.0.1:${String(address.port)}/devtools/browser/stalled`, ); - await vi.waitFor(() => expect(sockets.size).toBe(0)); + await waitFor(() => expect(sockets.size).toBe(0)); } finally { for (const socket of sockets) socket.destroy(); await new Promise((resolve) => server.close(() => resolve())); @@ -510,7 +511,7 @@ describe("CdpBrowserProvider: shared browser socket lifecycle regressions", () = if (!inspected.ok) throw inspected.error; expect(closeSpy).toHaveBeenCalledTimes(1); expect(browser.openedConnectionCount()).toBe(1); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); const session = await observing; if (!session.ok) throw session.error; @@ -586,7 +587,7 @@ describe("CdpBrowserProvider: shared browser socket lifecycle regressions", () = denyClose = false; await provider.close(); expect(inspected.error.cleanupIncomplete).toBe(false); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { denyClose = false; controller.abort(new Error("test teardown")); @@ -625,7 +626,7 @@ describe("CdpTargetSessionOwner: callback failures and idle protocol eviction", await owner.retryPending(); expect(cleanup).toHaveBeenCalledTimes(1); expect(failure.cleanupIncomplete).toBe(false); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { await owner.close(); } @@ -654,7 +655,7 @@ describe("CdpTargetSessionOwner: callback failures and idle protocol eviction", expect(browser.activeConnectionCount()).toBe(1); browser.emitRawMessage("{not-json"); - await vi.waitFor(() => expect(closeSpy).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(closeSpy).toHaveBeenCalledTimes(1)); const blocked = await provider.listTargets( listBrowserTargetsInputSchema.parse({ @@ -676,7 +677,7 @@ describe("CdpTargetSessionOwner: callback failures and idle protocol eviction", expect(browser.activeConnectionCount()).toBe(1); browser.emitRawMessage("{not-json-idle-second"); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { closeSpy.mockRestore(); await provider.close(); @@ -777,7 +778,7 @@ it("blocks new admissions while forced shared-socket cleanup fails with an activ closeSpy.mockRestore(); await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("keeps an admitted shared browser socket alive for concurrent peers when a caller aborts or hits discovery_timeout_ms", async () => { @@ -884,7 +885,7 @@ it("keeps an admitted shared browser socket alive for concurrent peers when a ca await Promise.allSettled([observing, cancelling]); await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); describe("CdpBrowserProvider: coalesced cold-start browser sockets", () => { @@ -946,7 +947,7 @@ describe("CdpBrowserProvider: coalesced cold-start browser sockets", () => { await batch?.catch(() => undefined); await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("keeps an in-flight cold-start handshake alive for a longer-deadline peer when one waiter times out", async () => { @@ -1003,7 +1004,7 @@ describe("CdpBrowserProvider: coalesced cold-start browser sockets", () => { await Promise.allSettled([shortCall, longCall]); await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); }); @@ -1167,7 +1168,7 @@ it("isolates concurrent target sessions on one shared browser socket when a shor await Promise.allSettled([observing, inspecting, capturing]); await provider.close(); } - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("reuses one browser socket across equivalent normalized spellings while keeping 127.0.0.1 and [::1] distinct", async () => { @@ -1232,7 +1233,7 @@ it("reuses one browser socket across equivalent normalized spellings while keepi connectSpy.mockRestore(); await provider.close(); } - await vi.waitFor(() => { + await waitFor(() => { expect(ipv6Browser.activeConnectionCount()).toBe(0); expect(ipv4Browser.activeConnectionCount()).toBe(0); }); @@ -1328,7 +1329,7 @@ it("keeps distinct /devtools/browser/ paths on separate browser sockets whil connectSpy.mockRestore(); await provider.close(); } - await vi.waitFor(() => { + await waitFor(() => { expect(browserA.activeConnectionCount()).toBe(0); expect(browserB.activeConnectionCount()).toBe(0); expect(browserCaseA.activeConnectionCount()).toBe(0); diff --git a/tests/boundary/browser/cdpCleanupCancellation.test.ts b/tests/boundary/browser/cdpCleanupCancellation.test.ts index 8c59661d8d..390d664745 100644 --- a/tests/boundary/browser/cdpCleanupCancellation.test.ts +++ b/tests/boundary/browser/cdpCleanupCancellation.test.ts @@ -16,6 +16,7 @@ import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalys import { inspectElectronPageInputSchema } from "../../../src/domain/javascript/electronObservation.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { waitFor } from "../../support/testWait.js"; it.each(["Page.disable", "Target.detachFromTarget"])( "finishes caller cancellation even when %s has no response", @@ -53,7 +54,7 @@ it.each(["Page.disable", "Target.detachFromTarget"])( expect(methods).toContain("Target.detachFromTarget"); expect(methods).not.toContain("Target.closeTarget"); expect(methods).not.toContain("Browser.close"); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }, ); @@ -72,7 +73,7 @@ it("releases cleanup when cancellation arrives after capture completes", async ( }), { signal: controller.signal }, ); - await vi.waitFor(() => { + await waitFor(() => { expect( browser.commands.some(({ method }) => method === "Page.disable"), ).toBe(true); @@ -89,7 +90,7 @@ it("releases cleanup when cancellation arrives after capture completes", async ( expect(browser.commands.map(({ method }) => method)).toContain( "Target.detachFromTarget", ); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); const methods = browser.commands.map(({ method }) => method); expect(methods).not.toContain("Target.closeTarget"); expect(methods).not.toContain("Browser.close"); @@ -132,7 +133,7 @@ it("retains the page inspection when bundle cleanup is cancelled", async () => { }, }, }); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("closes an Electron inspection session when cleanup is cancelled", async () => { @@ -183,7 +184,7 @@ it("closes an Electron inspection session when cleanup is cancelled", async () = expect(methods).toContain("Target.detachFromTarget"); expect(methods).not.toContain("Target.closeTarget"); expect(methods).not.toContain("Browser.close"); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); expect((await fetch(`${browser.endpoint}/json/version`)).ok).toBe(true); }); @@ -234,7 +235,7 @@ it("classifies cancellation that arrives during final Electron detach", async () expect(methods).toContain("Target.detachFromTarget"); expect(methods).not.toContain("Target.closeTarget"); expect(methods).not.toContain("Browser.close"); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); expect((await fetch(`${browser.endpoint}/json/version`)).ok).toBe(true); }); @@ -327,7 +328,7 @@ it.each( expect(retry.cleanupIncomplete).toBe(true); denied = false; await provider.close(); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); expect(result.error.cleanupIncomplete).toBe(false); expect(retry.cleanupIncomplete).toBe(false); expect(result.error.partialObservation).toBe(observation); diff --git a/tests/boundary/browser/cdpConnection.test.ts b/tests/boundary/browser/cdpConnection.test.ts index ed26fbbe51..1a7c70a10c 100644 --- a/tests/boundary/browser/cdpConnection.test.ts +++ b/tests/boundary/browser/cdpConnection.test.ts @@ -17,6 +17,7 @@ import { startFakeCdpBrowser, type FakeCdpBrowser, } from "../../fixtures/fakeCdpBrowser.js"; +import { waitFor } from "../../support/testWait.js"; const INVALID_REPLIES = [ ["missing result and error", (id: number) => ({ id })], @@ -127,7 +128,7 @@ describe("CDP connection response correlation", () => { const first = connection.send("Page.enable", { selected: "first" }); const second = connection.send("Page.enable", { selected: "second" }); const replies = Promise.all([first, second]); - await vi.waitFor(() => expect(browser.commands).toHaveLength(5)); + await waitFor(() => expect(browser.commands).toHaveLength(5)); const firstCommand = browser.commands[3]; const secondCommand = browser.commands[4]; if (firstCommand === undefined || secondCommand === undefined) @@ -231,7 +232,7 @@ describe("CDP command cancellation causes", () => { undefined, controller.signal, ); - await vi.waitFor(() => expect(browser.commands).toHaveLength(1)); + await waitFor(() => expect(browser.commands).toHaveLength(1)); const reason = new Error("cancel the pending CDP command"); controller.abort(reason); const failure = await pending.then( @@ -263,7 +264,7 @@ describe("CDP command cancellation causes", () => { undefined, controller.signal, ); - await vi.waitFor(() => expect(browser.commands).toHaveLength(1)); + await waitFor(() => expect(browser.commands).toHaveLength(1)); controller.abort(); const failure = await pending.then( () => undefined, @@ -303,7 +304,7 @@ describe("CDP handshake cancellation", () => { controller.signal, ); try { - await vi.waitFor(() => expect(sockets.size).toBe(1)); + await waitFor(() => expect(sockets.size).toBe(1)); controller.abort(reason); const failure = await connecting.then( () => undefined, @@ -313,7 +314,7 @@ describe("CDP handshake cancellation", () => { if (!(failure instanceof AnalysisCancelledError)) throw new Error("Expected handshake cancellation"); expect(failure.cause).toBe(reason); - await vi.waitFor(() => expect(sockets.size).toBe(0)); + await waitFor(() => expect(sockets.size).toBe(0)); } finally { if (sockets.size > 0) for (const socket of sockets) socket.destroy(); await new Promise((resolve, reject) => @@ -341,7 +342,7 @@ describe("CDP transport failure precedence", () => { () => undefined, (cause: unknown) => cause, ); - await vi.waitFor(() => expect(browser.commands).toHaveLength(1)); + await waitFor(() => expect(browser.commands).toHaveLength(1)); const failure = await pending; expect(failure).toMatchObject({ _tag: "BrowserObservationError", @@ -368,7 +369,7 @@ describe("CDP connection command responses", () => { ); try { const control = connection.send("Page.enable"); - await vi.waitFor(() => expect(browser.commands).toHaveLength(1)); + await waitFor(() => expect(browser.commands).toHaveLength(1)); browser.emitRawMessage( Buffer.from( JSON.stringify({ @@ -382,7 +383,7 @@ describe("CDP connection command responses", () => { const rejected = expect(pending).rejects.toMatchObject({ reason: "protocol_error", }); - await vi.waitFor(() => expect(browser.commands).toHaveLength(2)); + await waitFor(() => expect(browser.commands).toHaveLength(2)); browser.emitRawMessage( Buffer.concat([ Buffer.from( @@ -437,7 +438,7 @@ describe("CDP connection command responses", () => { _tag: "BrowserObservationError", reason: "protocol_error", }); - await vi.waitFor(() => expect(browser.commands).toHaveLength(1)); + await waitFor(() => expect(browser.commands).toHaveLength(1)); const command = browser.commands[0]; if (command === undefined) throw new Error("Fake browser received no command"); @@ -466,7 +467,7 @@ describe("CDP connection command responses", () => { code: -32_001, reportedMessage: "Session with given id not found.", }); - await vi.waitFor(() => expect(browser.commands).toHaveLength(1)); + await waitFor(() => expect(browser.commands).toHaveLength(1)); const command = browser.commands[0]; if (command === undefined) throw new Error("Fake browser received no command"); diff --git a/tests/boundary/browser/cdpEndpointResponseBounds.test.ts b/tests/boundary/browser/cdpEndpointResponseBounds.test.ts index ff8dd821ee..255e22f567 100644 --- a/tests/boundary/browser/cdpEndpointResponseBounds.test.ts +++ b/tests/boundary/browser/cdpEndpointResponseBounds.test.ts @@ -74,7 +74,7 @@ describe("CDP discovery response UTF-8", () => { operation: "list_browser_targets", reason: "invalid_endpoint_response", }); - await expect.poll(() => responseClosed, { timeout: 2_000 }).toBe(true); + await expect.poll(() => responseClosed).toBe(true); sendMalformed = false; await expect( discoverCdpEndpoint(server.url.href, "list_browser_targets"), @@ -146,7 +146,7 @@ describe("CDP discovery response bounds", () => { _tag: "BrowserObservationError", reason: "payload_limit", }); - await expect.poll(() => responseClosed, { timeout: 2_000 }).toBe(true); + await expect.poll(() => responseClosed).toBe(true); }); it("ends a response that exceeds the total elapsed deadline", async () => { @@ -177,7 +177,7 @@ describe("CDP discovery response bounds", () => { _tag: "BrowserObservationError", reason: "timeout", }); - await expect.poll(() => responseClosed, { timeout: 2_000 }).toBe(true); + await expect.poll(() => responseClosed).toBe(true); }, [ () => controller.abort(), async () => { @@ -215,7 +215,7 @@ describe("CDP discovery response bounds", () => { _tag: "AnalysisCancelledError", operation: "list_browser_targets", }); - await expect.poll(() => responseClosed, { timeout: 2_000 }).toBe(true); + await expect.poll(() => responseClosed).toBe(true); }, [ () => controller.abort(), async () => { @@ -242,7 +242,7 @@ describe("CDP discovery response bounds", () => { _tag: "BrowserObservationError", reason: "invalid_endpoint_response", }); - await expect.poll(() => responseClosed, { timeout: 2_000 }).toBe(true); + await expect.poll(() => responseClosed).toBe(true); }); }); diff --git a/tests/boundary/browser/cdpTargetDeadlines.test.ts b/tests/boundary/browser/cdpTargetDeadlines.test.ts index 42ec869824..f680343be8 100644 --- a/tests/boundary/browser/cdpTargetDeadlines.test.ts +++ b/tests/boundary/browser/cdpTargetDeadlines.test.ts @@ -29,6 +29,7 @@ import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider. import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js"; +import { waitFor } from "../../support/testWait.js"; // Keep real socket scheduling; expire only a timer whose owning phase is ready. beforeEach(() => { @@ -96,10 +97,10 @@ it("bounds a stalled WebSocket handshake and releases its socket", async () => { AbortSignal.timeout(10_000), ), ).rejects.toMatchObject({ reason: "timeout" }); - await vi.waitFor(() => expect(sockets.size).toBe(1), { timeout: 5_000 }); + await waitFor(() => expect(sockets.size).toBe(1)); expireTimer(WEB_RUNTIME_LIMITS.commandTimeoutMs); await rejection; - await vi.waitFor(() => expect(sockets.size).toBe(0)); + await waitFor(() => expect(sockets.size).toBe(0)); } finally { for (const socket of sockets) socket.destroy(); await new Promise((resolve) => server.close(() => resolve())); @@ -119,12 +120,10 @@ it.each(["Target.attachToTarget", "Page.enable", "Runtime.enable"])( }), { signal: AbortSignal.timeout(10_000) }, ); - await vi.waitFor( - () => - expect(browser.commands.map(({ method }) => method)).toContain( - hangOnMethod, - ), - { timeout: 5_000 }, + await waitFor(() => + expect(browser.commands.map(({ method }) => method)).toContain( + hangOnMethod, + ), ); expireTimer(WEB_RUNTIME_LIMITS.commandTimeoutMs); const result = await pending; @@ -136,7 +135,7 @@ it.each(["Target.attachToTarget", "Page.enable", "Runtime.enable"])( expect(browser.commands.map(({ method }) => method)).toContain( hangOnMethod, ); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { await browser.close(); } @@ -158,9 +157,7 @@ it("ignores a late timed-out reply and keeps following commands correlated", asy AbortSignal.timeout(10_000), ), ).rejects.toMatchObject({ reason: "timeout" }); - await vi.waitFor(() => expect(browser.commands).toHaveLength(1), { - timeout: 5_000, - }); + await waitFor(() => expect(browser.commands).toHaveLength(1)); expireTimer(WEB_RUNTIME_LIMITS.commandTimeoutMs); await rejection; const command = browser.commands[0]; @@ -208,9 +205,7 @@ it.each(["success", "rejection", "abort", "disconnect"])( : "BrowserObservationError", }); if (outcome === "success") { - await vi.waitFor(() => expect(browser.commands).toHaveLength(1), { - timeout: 5_000, - }); + await waitFor(() => expect(browser.commands).toHaveLength(1)); // A healthy reply may take longer than the former 100 ms test budget. await new Promise((resolve) => setTimeout(resolve, 250)); const command = browser.commands[0]; @@ -253,12 +248,10 @@ it.each(["Runtime.enable", "Debugger.enable"])( }, { signal: AbortSignal.timeout(10_000) }, ); - await vi.waitFor( - () => - expect(inspector.commands.map(({ method }) => method)).toContain( - hangOnMethod, - ), - { timeout: 5_000 }, + await waitFor(() => + expect(inspector.commands.map(({ method }) => method)).toContain( + hangOnMethod, + ), ); expireTimer(WEB_RUNTIME_LIMITS.commandTimeoutMs); const result = await pending; @@ -295,12 +288,7 @@ it("keeps the chosen observation window independent of command deadlines", async observation_ms: observationMs, }), ); - await vi.waitFor( - () => expect(activeTimers(observationMs)).toHaveLength(1), - { - timeout: 5_000, - }, - ); + await waitFor(() => expect(activeTimers(observationMs)).toHaveLength(1)); expect(activeTimers(WEB_RUNTIME_LIMITS.commandTimeoutMs)).toHaveLength(0); expireTimer(observationMs); const result = await pending; @@ -339,7 +327,7 @@ it("keeps a short command deadline off HTTP discovery and names Page.getFrameTre expect(browser.httpRequests.map(({ url }) => url)).toEqual( expect.arrayContaining(["/json/version", "/json/list"]), ); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { await provider.close(); await browser.close(); @@ -363,12 +351,10 @@ it("gives browser-socket attach its own command deadline after the handshake", a }), { signal: AbortSignal.timeout(10_000) }, ); - await vi.waitFor( - () => - expect(browser.commands.map(({ method }) => method)).toContain( - "Target.attachToTarget", - ), - { timeout: 5_000 }, + await waitFor(() => + expect(browser.commands.map(({ method }) => method)).toContain( + "Target.attachToTarget", + ), ); expect(activeTimers(commandTimeoutMs)).toHaveLength(1); expireTimer(commandTimeoutMs); @@ -379,7 +365,7 @@ it("gives browser-socket attach its own command deadline after the handshake", a `CDP command Target.attachToTarget exceeded its ${String(commandTimeoutMs)} ms command_timeout_ms`, ); expect(text).not.toContain("WebSocket handshake exceeded"); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { await provider.close(); await browser.close(); @@ -410,7 +396,7 @@ it("stops a stalled blocked-service-worker check before discovery", async () => const clock = vi.spyOn(performance, "now").mockReturnValue(1_000); const pending = workers.initialize(); try { - await vi.waitFor(() => + await waitFor(() => expect(activeTimers(SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS)).toHaveLength( 1, ), @@ -464,12 +450,10 @@ it("keeps blocked service-worker attachment outside the discovery deadline", asy ); const pending = workers.initialize(); try { - await vi.waitFor( - () => - expect(browser.commands.map(({ method }) => method)).toContain( - "Target.attachToTarget", - ), - { timeout: 5_000 }, + await waitFor(() => + expect(browser.commands.map(({ method }) => method)).toContain( + "Target.attachToTarget", + ), ); expect(activeTimers(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)).toHaveLength(0); expect(activeTimers(commandTimeoutMs)).toHaveLength(1); @@ -554,14 +538,14 @@ it("applies a non-default handshake deadline and releases its socket", async () const rejection = expect(pending).rejects.toMatchObject({ reason: "timeout", }); - await vi.waitFor(() => expect(sockets.size).toBe(1), { timeout: 5_000 }); + await waitFor(() => expect(sockets.size).toBe(1)); expireTimer(1_500); await rejection; expect(captured).toBeInstanceOf(BrowserObservationError); if (!(captured instanceof BrowserObservationError)) throw new Error("Expected a handshake deadline"); expect(captured.userMessage).toContain("1500 ms command_timeout_ms"); - await vi.waitFor(() => expect(sockets.size).toBe(0)); + await waitFor(() => expect(sockets.size).toBe(0)); } finally { for (const socket of sockets) socket.destroy(); await new Promise((resolve) => server.close(() => resolve())); @@ -580,12 +564,10 @@ it("applies a non-default command deadline after HTTP discovery", async () => { }), { signal: AbortSignal.timeout(10_000) }, ); - await vi.waitFor( - () => - expect(browser.commands.map(({ method }) => method)).toContain( - "Page.enable", - ), - { timeout: 5_000 }, + await waitFor(() => + expect(browser.commands.map(({ method }) => method)).toContain( + "Page.enable", + ), ); expireTimer(1_500); const result = await pending; @@ -593,7 +575,7 @@ it("applies a non-default command deadline after HTTP discovery", async () => { expect(result.error.userMessage ?? "").toContain( "1500 ms command_timeout_ms", ); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { await browser.close(); } @@ -609,9 +591,7 @@ it("reschedules a raised command deadline onto the fixed cleanup grace", async ( ); try { const pending = connection.send("Runtime.enable"); - await vi.waitFor(() => expect(browser.commands).toHaveLength(1), { - timeout: 5_000, - }); + await waitFor(() => expect(browser.commands).toHaveLength(1)); expect(activeTimers(60_000)).toHaveLength(1); connection.beginCleanupGrace(5_000); expect(activeTimers(60_000)).toHaveLength(0); @@ -645,12 +625,10 @@ it("keeps runtime cleanup on the fixed grace when command_timeout_ms is shorter" }), { signal: AbortSignal.timeout(10_000) }, ); - await vi.waitFor( - () => - expect(browser.commands.map(({ method }) => method)).toContain( - "Profiler.disable", - ), - { timeout: 5_000 }, + await waitFor(() => + expect(browser.commands.map(({ method }) => method)).toContain( + "Profiler.disable", + ), ); expect(activeTimers(commandTimeoutMs)).toHaveLength(0); expect( @@ -691,12 +669,10 @@ it("names the fixed cleanup grace when runtime restoration stalls", async () => }), { signal: AbortSignal.timeout(10_000) }, ); - await vi.waitFor( - () => - expect(browser.commands.map(({ method }) => method)).toContain( - "Profiler.disable", - ), - { timeout: 5_000 }, + await waitFor(() => + expect(browser.commands.map(({ method }) => method)).toContain( + "Profiler.disable", + ), ); expect(activeTimers(commandTimeoutMs)).toHaveLength(0); expireEveryTimer(WEB_RUNTIME_LIMITS.cleanupTimeoutMs); @@ -728,12 +704,10 @@ it("applies caller-selected discovery_timeout_ms to browser-socket discovery", a discovery_timeout_ms: discoveryTimeoutMs, }), ); - await vi.waitFor( - () => - expect(browser.commands.map(({ method }) => method)).toContain( - "Target.getTargets", - ), - { timeout: 5_000 }, + await waitFor(() => + expect(browser.commands.map(({ method }) => method)).toContain( + "Target.getTargets", + ), ); expect(activeTimers(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)).toHaveLength(0); expect(activeTimers(discoveryTimeoutMs)).not.toHaveLength(0); @@ -750,7 +724,7 @@ it("applies caller-selected discovery_timeout_ms to browser-socket discovery", a `Increase discovery_timeout_ms above ${String(discoveryTimeoutMs)}`, ); expect(projected.remediation?.action).toContain("--discovery-timeout-ms"); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); } finally { await provider.close(); await browser.close(); @@ -779,9 +753,7 @@ it("applies caller-selected discovery_timeout_ms to HTTP discovery independently command_timeout_ms: commandTimeoutMs, }), ); - await vi.waitFor(() => expect(versionRequested).toBe(true), { - timeout: 5_000, - }); + await waitFor(() => expect(versionRequested).toBe(true)); expect(activeTimers(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)).toHaveLength(0); expect(activeTimers(commandTimeoutMs)).toHaveLength(0); expect(activeTimers(discoveryTimeoutMs)).toHaveLength(1); @@ -850,7 +822,7 @@ it("clamps browser-socket handshake timers at MAX_BROWSER_DEADLINE_MS without 32 undefined, MAX_BROWSER_DEADLINE_MS, ).catch((cause: unknown) => cause); - await vi.waitFor(() => expect(sockets.size).toBe(1), { timeout: 5_000 }); + await waitFor(() => expect(sockets.size).toBe(1)); expireEveryTimer(MAX_BROWSER_DEADLINE_MS); const failure = await pending; expect(failure).toBeInstanceOf(BrowserObservationError); diff --git a/tests/boundary/browser/cdpTargetSessionOwner.test.ts b/tests/boundary/browser/cdpTargetSessionOwner.test.ts index 9b97b1e0ab..e52055a23c 100644 --- a/tests/boundary/browser/cdpTargetSessionOwner.test.ts +++ b/tests/boundary/browser/cdpTargetSessionOwner.test.ts @@ -16,6 +16,7 @@ import { import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; +import { waitFor } from "../../support/testWait.js"; it.each(["connect", "acquireBrowserSocket"] as const)( "refuses %s after clean shutdown without inventing retained cleanup", @@ -109,7 +110,7 @@ it("joins a late owned handshake and waits for its active operation", async () = expect(closed).toBe(false); await owner.finish(connection); await closing; - await vi.waitFor(() => expect(webSockets.clients.size).toBe(0)); + await waitFor(() => expect(webSockets.clients.size).toBe(0)); }); it("retries only the failed transport and does not repeat successful child cleanup", async () => { @@ -157,13 +158,13 @@ it("retries only the failed transport and does not repeat successful child clean } await owner.retryPending(); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(1)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(1)); const cleanup = vi.fn(async () => undefined); await owner.finish(active, cleanup); await owner.retryPending(); await owner.close(); expect(cleanup).toHaveBeenCalledTimes(1); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); expect(browser.commands.map(({ method }) => method)).not.toContain( "Target.closeTarget", ); @@ -201,7 +202,7 @@ it("accepts asynchronous terminate success only after the close event", async () terminate.mockRestore(); } await owner.close(); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); }); it("clears returned inspection and shutdown cleanup status only after socket release", async () => { @@ -241,7 +242,7 @@ it("clears returned inspection and shutdown cleanup status only after socket rel expect(browser.activeConnectionCount()).toBe(1); denied = false; await provider.close(); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); expect(result.error.cleanupIncomplete).toBe(false); expect(retry.cleanupIncomplete).toBe(false); expect(result.error.partialObservation).toBe(observation); @@ -308,7 +309,7 @@ it("keeps retry aggregates incomplete until every reported socket is released", expect(errors.map((error) => error.cleanupIncomplete)).toEqual([false, true]); expect(aggregate.cleanupIncomplete).toBe(true); expect(mixed.cleanupResources).toEqual(["second-socket"]); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(2)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(2)); denied.clear(); await owner.retryPending(); expect(errors.map((error) => error.cleanupIncomplete)).toEqual([ @@ -317,7 +318,7 @@ it("keeps retry aggregates incomplete until every reported socket is released", ]); expect(aggregate.cleanupIncomplete).toBe(false); expect(mixed.cleanupIncomplete).toBe(false); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(1)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(1)); await owner.finish(active); }); diff --git a/tests/boundary/browser/cdpWebRuntimeTermination.test.ts b/tests/boundary/browser/cdpWebRuntimeTermination.test.ts index 588088225b..05b9837993 100644 --- a/tests/boundary/browser/cdpWebRuntimeTermination.test.ts +++ b/tests/boundary/browser/cdpWebRuntimeTermination.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; import type { CdpEvent } from "../../../src/browser/CdpConnection.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js"; import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; +import { waitFor } from "../../support/testWait.js"; describe("runtime flat-session lifecycle", () => { it("rejects session detachment between counter startup and local arming", async () => { @@ -228,7 +229,7 @@ describe("runtime disconnect ownership", () => { }), { signal: AbortSignal.timeout(2_000) }, ); - await vi.waitFor(() => { + await waitFor(() => { expect( browser.commands.some( ({ method }) => method === "Profiler.stopPreciseCoverage", diff --git a/tests/boundary/browser/historicalCaptureDecoder.test.ts b/tests/boundary/browser/historicalCaptureDecoder.test.ts index 947c3609f1..c96b5694de 100644 --- a/tests/boundary/browser/historicalCaptureDecoder.test.ts +++ b/tests/boundary/browser/historicalCaptureDecoder.test.ts @@ -33,6 +33,7 @@ import { decodeHarCapture } from "../../../src/browser/history/HarCapture.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { primaryAnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { waitFor } from "../../support/testWait.js"; const linux = it.skipIf(process.platform !== "linux"); @@ -336,12 +337,9 @@ it.each(["cancelled", "process"] as const)( sensitive_values: ["capture-private-runtime"], }); const target = decoder.inspect(input, { signal: controller.signal }); - await vi.waitFor( - async () => { - targetPid = JSON.parse(await readFile(markerPath, "utf8")); - }, - { timeout: 5000 }, - ); + await waitFor(async () => { + targetPid = JSON.parse(await readFile(markerPath, "utf8")); + }); const peer = decoder.inspect(input); await peerEntered.promise; if (outcome === "cancelled") controller.abort(reason); diff --git a/tests/boundary/cli/cliJsonStdinCli.test.ts b/tests/boundary/cli/cliJsonStdinCli.test.ts index 933a34455d..73b266ab7a 100644 --- a/tests/boundary/cli/cliJsonStdinCli.test.ts +++ b/tests/boundary/cli/cliJsonStdinCli.test.ts @@ -74,9 +74,7 @@ cliTest.for([false, true])( child.once("close", () => resolve()); }); await expect - .poll(() => /REA_TEST_STDIN_READ (\d+)/u.test(stderr), { - timeout: 10_000, - }) + .poll(() => /REA_TEST_STDIN_READ (\d+)/u.test(stderr)) .toBe(true); const heapSizeLimit = Number( /REA_TEST_STDIN_READ (\d+)/u.exec(stderr)?.[1], @@ -182,9 +180,7 @@ cliTest.skipIf(process.platform === "win32").for( child.once("error", reject); child.once("close", () => resolve()); }); - await expect - .poll(() => stderr.includes("REA_TEST_STDIN_READ"), { timeout: 10_000 }) - .toBe(true); + await expect.poll(() => stderr.includes("REA_TEST_STDIN_READ")).toBe(true); // Keep the pipe open: EOF must not be what releases the pending read. child.kill(signal); expect(await waitForExit(child, 5_000), stderr).toBe(true); diff --git a/tests/boundary/cli/cliWorkerCompletion.test.ts b/tests/boundary/cli/cliWorkerCompletion.test.ts index 19a6a72f7b..e33fc5e1f9 100644 --- a/tests/boundary/cli/cliWorkerCompletion.test.ts +++ b/tests/boundary/cli/cliWorkerCompletion.test.ts @@ -63,12 +63,7 @@ process.stdout.write = (chunk, ...arguments_) => { if (frame.ready) ready.resolve(); }), ); - await awaitAcknowledgement( - ready.promise, - closed, - "CLI worker startup", - 15_000, - ); + await awaitAcknowledgement(ready.promise, closed, "CLI worker startup"); child.stdin.end( encodeCliSessionFrame({ id: 1, diff --git a/tests/boundary/cli/cutterCommands.test.ts b/tests/boundary/cli/cutterCommands.test.ts index 95031e2599..1630a06a98 100644 --- a/tests/boundary/cli/cutterCommands.test.ts +++ b/tests/boundary/cli/cutterCommands.test.ts @@ -98,7 +98,9 @@ describe("Cutter CLI commands", () => { ownsProcessLifetime: true, }); try { - await expect.poll(() => accepted, { timeout: 5_000 }).toBe(8); + // Probes that time out close their sockets, so only a full concurrent + // wave holds eight open at once. + await expect.poll(() => sockets.size).toBe(8); launched.process.kill("SIGINT"); expect(await supervisor.waitForExit(5_000)).toBe(true); expect(await supervisor.waitForOutputClose(1_000)).toBe(true); diff --git a/tests/boundary/cli/debuggerOneShotExit.test.ts b/tests/boundary/cli/debuggerOneShotExit.test.ts index 4daf27251d..0a0cd6193d 100644 --- a/tests/boundary/cli/debuggerOneShotExit.test.ts +++ b/tests/boundary/cli/debuggerOneShotExit.test.ts @@ -259,9 +259,7 @@ const startMcpSession = async ( child.stdin.write( `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, ); - await expect - .poll(() => stdout, { timeout: EXIT_DEADLINE_MS }) - .toContain(`"id":${String(id)}`); + await expect.poll(() => stdout).toContain(`"id":${String(id)}`); }; await request(1, "initialize", { protocolVersion: "2025-06-18", @@ -305,9 +303,7 @@ describe.skipIf(process.platform === "win32")( expectRetainedOutcome(name, result, pid); // REA reports the debugger as retained and did not stop it. Exiting // closes its command stream; that is the only thing it sees from REA. - await expect - .poll(launch.notes, { timeout: EXIT_DEADLINE_MS }) - .toContain("stdin-end"); + await expect.poll(launch.notes).toContain("stdin-end"); expect(await launch.notes()).not.toContain("sigterm"); expect(isAlive(pid)).toBe(true); }, @@ -332,9 +328,7 @@ describe.skipIf(process.platform === "win32")( }); expect(result.stdout).not.toContain("retained_debugger_pids"); const pid = await launch.debuggerPid(); - await expect - .poll(() => isAlive(pid), { timeout: EXIT_DEADLINE_MS }) - .toBe(false); + await expect.poll(() => isAlive(pid)).toBe(false); }, ); @@ -353,16 +347,10 @@ describe.skipIf(process.platform === "win32")( child.stdout.on("data", (chunk: Buffer) => (stdout += String(chunk))); child.stdin.end(); // The unanswered request is issued by final cleanup, after the command. - await expect - .poll(launch.notes, { timeout: EXIT_DEADLINE_MS }) - .toContain("inventory-request"); + await expect.poll(launch.notes).toContain("inventory-request"); child.kill("SIGINT"); - await expect - .poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS }) - .toBe(130); - await expect - .poll(() => stdout, { timeout: EXIT_DEADLINE_MS }) - .toContain("retained_debugger_pids"); + await expect.poll(() => child.exitCode).toBe(130); + await expect.poll(() => stdout).toContain("retained_debugger_pids"); const pid = await launch.debuggerPid(); expectRetainedOutcome( @@ -395,18 +383,14 @@ describe.skipIf(process.platform === "win32")( if (trigger === "SIGTERM") child.kill("SIGTERM"); else child.stdin.end(); - await expect - .poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS }) - .toBe(1); + await expect.poll(() => child.exitCode).toBe(1); const pid = await launch.debuggerPid(); // Nothing else reports these once the server has exited. const guidance = () => stderr() .split("\n") .find((line) => line === retainedDebuggerRemediation([pid])); - await expect - .poll(guidance, { timeout: EXIT_DEADLINE_MS }) - .toBeDefined(); + await expect.poll(guidance).toBeDefined(); expectNamesProcess(guidance()!, pid); const logs = stderr() .split("\n") diff --git a/tests/boundary/cli/doctorCancellation.test.ts b/tests/boundary/cli/doctorCancellation.test.ts index 7ebcfeae86..9c8c9bda17 100644 --- a/tests/boundary/cli/doctorCancellation.test.ts +++ b/tests/boundary/cli/doctorCancellation.test.ts @@ -11,6 +11,8 @@ import { type OwnedProcessGroup, } from "../../../src/process/ProcessOwnership.js"; +const PROBE_DEADLINE_MS = 10_000; + cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for( (["jadx", "kuna"] as const).flatMap((provider) => ( @@ -85,13 +87,11 @@ setTimeout(() => process.exit(99), 20000); let ownership: OwnedProcessGroup | undefined; try { await expect - .poll( - () => - readFile(ready, "utf8").then( - () => true, - () => false, - ), - { timeout: 10_000 }, + .poll(() => + readFile(ready, "utf8").then( + () => true, + () => false, + ), ) .toBe(true); const readyRecord: unknown = JSON.parse(await readFile(ready, "utf8")); @@ -119,10 +119,12 @@ setTimeout(() => process.exit(99), 20000); runId: observed.runId, }; } + const signalled = performance.now(); child.kill(signal); - await expect - .poll(() => child.exitCode, { timeout: 10_000 }) - .not.toBeNull(); + 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); const result = await closed; expect(JSON.parse(stdout)).toMatchObject({ code: "cancelled" }); if (ownership !== undefined) { diff --git a/tests/boundary/cli/electronCli.test.ts b/tests/boundary/cli/electronCli.test.ts index 6a9957bef9..8048a730d6 100644 --- a/tests/boundary/cli/electronCli.test.ts +++ b/tests/boundary/cli/electronCli.test.ts @@ -4,7 +4,7 @@ import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { closeVerifierResources } from "../../../scripts/lib/verifier-cleanup.mjs"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -23,6 +23,7 @@ import { electronRuntimeEvidence, } from "../../fixtures/javascriptRuntimeReconciliation.js"; import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { waitFor } from "../../support/testWait.js"; const cliProcesses = createTestProcessRegistry(); const cli = createTestCli(cliProcesses); @@ -198,14 +199,11 @@ it.skipIf(process.platform === "win32")( }); const hardStop = setTimeout(() => child.kill("SIGKILL"), 15_000); try { - await vi.waitFor( - () => { - expect( - browser.commands.some(({ method }) => method === "Runtime.enable"), - ).toBe(true); - }, - { timeout: 5_000 }, - ); + await waitFor(() => { + expect( + browser.commands.some(({ method }) => method === "Runtime.enable"), + ).toBe(true); + }); child.kill("SIGINT"); const result = await closed; @@ -219,7 +217,7 @@ it.skipIf(process.platform === "win32")( expect(methods).toContain("Target.detachFromTarget"); expect(methods).not.toContain("Target.closeTarget"); expect(methods).not.toContain("Browser.close"); - await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + await waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); expect((await fetch(`${browser.endpoint}/json/version`)).ok).toBe(true); } finally { clearTimeout(hardStop); diff --git a/tests/boundary/filesystem/lintRuleConfiguration.test.ts b/tests/boundary/filesystem/lintRuleConfiguration.test.ts index 5187faac5c..8a016d3f0d 100644 --- a/tests/boundary/filesystem/lintRuleConfiguration.test.ts +++ b/tests/boundary/filesystem/lintRuleConfiguration.test.ts @@ -105,6 +105,16 @@ const cases: readonly AcceptanceCase[] = [ exitCode: 0, diagnostics: [], }, + { + name: "rejects test-owned wait deadlines through the repository plugin", + filename: testSource, + source: ` + import { expect } from "vitest"; + await expect.poll(() => true, { timeout: 5_000 }).toBe(true); + `, + exitCode: 1, + diagnostics: [{ code: "rea(project-wait-deadline)", severity: "error" }], + }, { name: "rejects an unreturned promise callback containing assertions", filename: testSource, diff --git a/tests/boundary/mcp/buildCaptureRetryMcp.test.ts b/tests/boundary/mcp/buildCaptureRetryMcp.test.ts index cf445a0156..36c88dc89d 100644 --- a/tests/boundary/mcp/buildCaptureRetryMcp.test.ts +++ b/tests/boundary/mcp/buildCaptureRetryMcp.test.ts @@ -3,7 +3,7 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import type { Client } from "@modelcontextprotocol/client"; -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { seedEndCapture } from "../../../src/application/desktop/desktopCapture.fixture.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -21,6 +21,7 @@ import { callOk, startBuildComputer } from "../../fixtures/buildComputerMcp.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { cancelDuringCommit } from "../../fixtures/outputTreeCommit.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { waitFor } from "../../support/testWait.js"; const call = (client: Client, name: string, args: Record) => client.callTool({ name, arguments: args }); @@ -201,7 +202,7 @@ it("releases a capture whose start was cancelled after launch, so another start { name: "start_build_capture", arguments: { computer_id: id } }, { signal: controller.signal }, ); - await vi.waitFor(() => expect(screenshots()).toBe(2), { timeout: 30_000 }); + await waitFor(() => expect(screenshots()).toBe(2)); controller.abort(); await expect(started).rejects.toThrow(); const again = await callOk(client, "start_build_capture", { @@ -235,7 +236,7 @@ it("cancels a capture's start when the computer closes while it waits for the op computer.calls.filter(({ name }) => name === "computer_screenshot").length; expect(screenshots()).toBe(1); const started = call(client, "start_build_capture", { computer_id: id }); - await vi.waitFor(() => expect(screenshots()).toBe(2), { timeout: 30_000 }); + await waitFor(() => expect(screenshots()).toBe(2)); await callOk(client, "close_build_computer", { computer_id: id }); expect(parseMcpToolError(await started).error.code).toBe("cancelled"); }); diff --git a/tests/boundary/mcp/buildComputerMcp.test.ts b/tests/boundary/mcp/buildComputerMcp.test.ts index 1fa68ac35b..51fab8e919 100644 --- a/tests/boundary/mcp/buildComputerMcp.test.ts +++ b/tests/boundary/mcp/buildComputerMcp.test.ts @@ -11,7 +11,7 @@ import { setTimeout as delay } from "node:timers/promises"; import type { Client } from "@modelcontextprotocol/client"; import type { CallToolResult } from "@modelcontextprotocol/server"; -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { z } from "zod"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -29,6 +29,7 @@ import { type FakeComputer, } from "../../fixtures/fakeAgentEnvComputer.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { waitFor } from "../../support/testWait.js"; const images = (result: CallToolResult) => result.content.filter((block) => block.type === "image"); @@ -677,7 +678,7 @@ it("runs one computer's calls in the order they arrive and another computer's be callOk(client, "act_on_build_computer", { computer_id: id, action }); const order: string[] = []; const reset = act(first, "reset").then(() => order.push("reset")); - await vi.waitFor(() => expect(app.methods).toEqual(["data/reset"])); + await waitFor(() => expect(app.methods).toEqual(["data/reset"])); const behind = act(first, "screenshot").then(() => order.push("screenshot behind the reset"), ); @@ -703,7 +704,7 @@ it("sends progress while an action waits for the call before it on its computer" computer_id: id, action: "reset", }); - await vi.waitFor(() => expect(app.methods).toEqual(["data/reset"])); + await waitFor(() => expect(app.methods).toEqual(["data/reset"])); const progress: unknown[] = []; const waited = await client.callTool( { @@ -729,7 +730,7 @@ it("cancels a computer's call in progress on close and refuses the calls waiting const call = (name: string, fields: Record = {}) => client.callTool({ name, arguments: { computer_id: id, ...fields } }); const reset = call("act_on_build_computer", { action: "reset" }); - await vi.waitFor(() => expect(app.methods).toEqual(["data/reset"])); + await waitFor(() => expect(app.methods).toEqual(["data/reset"])); const waiting = [ call("act_on_build_computer", { action: "screenshot" }), call("stop_build_capture"), diff --git a/tests/boundary/mcp/buildWitnessMcp.test.ts b/tests/boundary/mcp/buildWitnessMcp.test.ts index 65802e2209..aec41e35bb 100644 --- a/tests/boundary/mcp/buildWitnessMcp.test.ts +++ b/tests/boundary/mcp/buildWitnessMcp.test.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { CallToolResult } from "@modelcontextprotocol/server"; -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import type { BuildSettle, @@ -33,6 +33,7 @@ import { import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { cancelDuringCommit } from "../../fixtures/outputTreeCommit.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { waitFor } from "../../support/testWait.js"; const TASK = { name: "add-eggs", @@ -895,7 +896,7 @@ it("keeps a completed reset when the screenshot after it is cancelled", async () }, { signal: controller.signal }, ); - await vi.waitFor(() => expect(witness.app.methods).toContain("data/reset")); + await waitFor(() => expect(witness.app.methods).toContain("data/reset")); controller.abort(); await expect(resetting).rejects.toThrow(); const near = parseMcpToolError(await witness.check({ stage: "near_miss" })); @@ -909,7 +910,7 @@ it("checks after_reset once the reset sent before it has relaunched the app", as await throughNearMiss(witness); const order: string[] = []; const reset = witness.act("reset").then(() => order.push("reset")); - await vi.waitFor(() => expect(app.methods.at(-1)).toBe("data/reset")); + await waitFor(() => expect(app.methods.at(-1)).toBe("data/reset")); const last = await check({ stage: "after_reset" }); order.push("check"); await reset; diff --git a/tests/boundary/mcp/cutterCancellationMcp.test.ts b/tests/boundary/mcp/cutterCancellationMcp.test.ts index 6162d4fa3c..ee904541cf 100644 --- a/tests/boundary/mcp/cutterCancellationMcp.test.ts +++ b/tests/boundary/mcp/cutterCancellationMcp.test.ts @@ -19,6 +19,9 @@ import { createToolResultDelivery } from "../../../src/server/toolResult.js"; import { silentLogger } from "../../../src/logger.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +// CutterBridgeClient's per-probe request deadline. +const CUTTER_REQUEST_DEADLINE_MS = 25_000; + it("propagates MCP cancellation into an active Cutter command", async () => { let markDispatched: (() => void) | undefined; let observedAbort = false; @@ -253,15 +256,20 @@ it("cancels active discovery sockets, stops queued probes and recovers on the sa try { await server.connect(serverTransport); await client.connect(clientTransport); + const requested = performance.now(); const request = client.callTool( { name: "list_cutter_sessions", arguments: {} }, { signal: controller.signal }, ); - await awaitAcknowledgement(connected, request, "Cutter discovery", 10_000); + await awaitAcknowledgement(connected, request, "Cutter discovery"); controller.abort(new Error("selected discovery cancellation")); await expect(request).rejects.toThrow("selected discovery cancellation"); await expect.poll(() => settled).toBe(true); await expect.poll(() => sockets.size).toBe(0); + // Probes the abort missed would still close at their own request deadline. + expect(performance.now() - requested).toBeLessThan( + CUTTER_REQUEST_DEADLINE_MS, + ); expect(connections).toBe(8); await Promise.all(paths.map((path) => rm(path))); const recovered = await client.callTool({ diff --git a/tests/boundary/mcp/evidenceExportCancellation.test.ts b/tests/boundary/mcp/evidenceExportCancellation.test.ts index ca084f8133..2ea4195049 100644 --- a/tests/boundary/mcp/evidenceExportCancellation.test.ts +++ b/tests/boundary/mcp/evidenceExportCancellation.test.ts @@ -82,7 +82,7 @@ it.each([false, true])( message: expect.stringContaining("SDK export cancellation verification"), }); await expect - .poll(() => readdir(root), { timeout: 5000 }) + .poll(() => readdir(root)) .toEqual(overwrite ? ["bundle.json"] : []); if (overwrite) expect(await readFile(path, "utf8")).toBe("original destination"); diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index 1271ade8bb..cea85709c1 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -44,6 +44,7 @@ import { ToolResultDelivery } from "../../../src/server/ToolResultDelivery.js"; import { createToolResultDelivery } from "../../../src/server/toolResult.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { waitFor } from "../../support/testWait.js"; const gate = () => { let markEntered = () => {}; @@ -486,7 +487,7 @@ it.each([ const serverReason: unknown = serverSignal?.reason; held.release(); const result = await pending; - await vi.waitFor(() => + await waitFor(() => expect(success.mock.calls.length + partial.mock.calls.length).toBe(1), ); expect(fixture.controls.detachCalls).toBe(1); @@ -629,7 +630,7 @@ it("preserves the native resume failure after MCP cancellation reaches the serve expect(serverSignal?.aborted).toBe(true); held.release(); await pending; - await vi.waitFor(() => expect(delivery).toHaveBeenCalledOnce()); + await waitFor(() => expect(delivery).toHaveBeenCalledOnce()); expect(delivery.mock.calls[0]?.[0]).toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", operation: "resume_frida_session", diff --git a/tests/boundary/mcp/ghidraFileOffsetMcp.test.ts b/tests/boundary/mcp/ghidraFileOffsetMcp.test.ts index d5be3e1afb..f3e79445f1 100644 --- a/tests/boundary/mcp/ghidraFileOffsetMcp.test.ts +++ b/tests/boundary/mcp/ghidraFileOffsetMcp.test.ts @@ -1,6 +1,6 @@ import { access } from "node:fs/promises"; -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { z } from "zod"; import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; @@ -18,6 +18,7 @@ import { silentLogger } from "../../../src/logger.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { GhidraFixtureLauncher } from "../providers/ghidra/ghidraClient.fixture.js"; +import { waitFor } from "../../support/testWait.js"; // Only installation discovery and the Java bridge are simulated. The bridge // fixture answers with the production address_to_file_offset codes; requests @@ -193,7 +194,7 @@ it("reports file-backed, uninitialized and unmapped addresses through MCP withou }); expect(mcp.launcher.processes).toHaveLength(1); // The malformed spelling never reached the bridge. - await vi.waitFor(() => expect(mcp.bridgeCalls()).toHaveLength(4)); + await waitFor(() => expect(mcp.bridgeCalls()).toHaveLength(4)); } finally { await mcp.close(); } @@ -255,7 +256,7 @@ it("maps the last byte of a file-backed block and refuses the first address past diagnostics: { remote_code: remoteCode }, }, }); - await vi.waitFor(() => expect(mcp.bridgeCalls()).toHaveLength(5)); + await waitFor(() => expect(mcp.bridgeCalls()).toHaveLength(5)); } finally { await mcp.close(); } diff --git a/tests/boundary/mcp/ghidraRequestCeilingMcp.test.ts b/tests/boundary/mcp/ghidraRequestCeilingMcp.test.ts index 68c2543932..aee117cc9d 100644 --- a/tests/boundary/mcp/ghidraRequestCeilingMcp.test.ts +++ b/tests/boundary/mcp/ghidraRequestCeilingMcp.test.ts @@ -1,6 +1,6 @@ import { access } from "node:fs/promises"; -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; @@ -13,6 +13,7 @@ import { silentLogger } from "../../../src/logger.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { GhidraFixtureLauncher } from "../providers/ghidra/ghidraClient.fixture.js"; +import { waitFor } from "../../support/testWait.js"; it("reports local Ghidra admission through SDK MCP without dispatching or losing the warm session", async () => { const launcher = new GhidraFixtureLauncher(); @@ -90,7 +91,7 @@ it("reports local Ghidra admission through SDK MCP without dispatching or losing arguments: {}, }); expect(after.isError).not.toBe(true); - await vi.waitFor(() => + await waitFor(() => expect( launcher.requests.filter((method) => method === "list_strings"), ).toHaveLength(2), diff --git a/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts b/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts index bc92d1b7a5..55e10d1b90 100644 --- a/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts +++ b/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts @@ -1,7 +1,7 @@ import { access } from "node:fs/promises"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; @@ -16,6 +16,7 @@ import { createDeferred } from "../../fixtures/binarySession.js"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { GhidraFixtureLauncher } from "../providers/ghidra/ghidraClient.fixture.js"; +import { waitFor } from "../../support/testWait.js"; it.each([0, 1])( "releases cancelled MCP startup caller %s while its peer completes and stays warm", @@ -122,7 +123,7 @@ it.each([0, 1])( "Ghidra startup", ); controllers[cancelledIndex]?.abort(); - await vi.waitFor(() => + await waitFor(() => expect(startResults[cancelledIndex]).toMatchObject({ ok: false, error: { kind: "cancelled" }, diff --git a/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts b/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts index 24fa2d7a05..337cf2718a 100644 --- a/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts +++ b/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts @@ -1,5 +1,5 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { expect, it, onTestFinished, vi } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; import { createTestBinarySession, createDeferred, @@ -14,6 +14,7 @@ import type { JavaScriptRuntimeObservationPort } from "../../../src/application/ import type { BrowserObservationPort } from "../../../src/application/BrowserObservationPort.js"; import type { BrowserTargetList } from "../../../src/domain/browserObservationSchemas.js"; import type { JavaScriptRuntimeTargetList } from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { waitFor } from "../../support/testWait.js"; it("retains late-cancelled Go artifact Evidence through MCP and keeps the server usable", async () => { const gate = createDeferred(); @@ -65,7 +66,7 @@ it("retains late-cancelled Go artifact Evidence through MCP and keeps the server controller.abort(); gate.resolve(); await expect(call).rejects.toThrow(); - await vi.waitFor(() => { + await waitFor(() => { expect(session.exportEvidenceBundle().records).toContainEqual( expect.objectContaining({ operation: "inspect_go_binary", @@ -132,7 +133,7 @@ it("retains late-cancelled Inspector target Evidence through MCP and keeps the s controller.abort(); gate.resolve(); await expect(call).rejects.toThrow(); - await vi.waitFor(() => { + await waitFor(() => { expect(session.exportEvidenceBundle().records).toContainEqual( expect.objectContaining({ operation: "list_javascript_runtime_targets", @@ -219,7 +220,7 @@ it("records late-cancelled Browser target Evidence and keeps the server usable", controller.abort(); gate.resolve(); await expect(call).rejects.toThrow(); - await vi.waitFor(() => { + await waitFor(() => { expect(session.exportEvidenceBundle().records).toContainEqual( expect.objectContaining({ operation: "list_browser_targets", diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 0171a2b298..c3d52fcc64 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -40,6 +40,7 @@ import { MCP_STARTUP_POLICY, } from "../../../src/mcpStartupPolicy.js"; import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; +import { waitFor } from "../../support/testWait.js"; // Compile SDK output validators for the tools a test calls, not the whole catalog. const schemaValidation = createMcpSchemaValidation(); @@ -790,16 +791,13 @@ describe("production MCP doctor child lifecycle", () => { REA_MCP_DOCTOR_DEADLINE_MS: "60000", }, }); - const pid = await vi.waitFor( - async () => { - const value = Number.parseInt(await readFile(pidPath, "utf8"), 10); - expect(Number.isSafeInteger(value) && value > 0).toBe(true); - return value; - }, - { timeout: 10_000 }, - ); + const pid = await waitFor(async () => { + const value = Number.parseInt(await readFile(pidPath, "utf8"), 10); + expect(Number.isSafeInteger(value) && value > 0).toBe(true); + return value; + }); await vi.advanceTimersByTimeAsync(60_000); - const result = await vi.waitFor(() => pending, { timeout: 10_000 }); + const result = await waitFor(() => pending); vi.useRealTimers(); expect(result.healthy).toBe(false); expectDoctorDeadline(result.checks[0]?.detail, 60_000); @@ -855,19 +853,16 @@ describe("production MCP doctor child ownership", () => { REA_MCP_DOCTOR_DEADLINE_MS: "60000", }, }); - const pids = await vi.waitFor( - async () => { - const value = JSON.parse(await readFile(pidPath, "utf8")) as { - launcher: number; - descendant: number; - }; - expect(Number.isSafeInteger(value.descendant)).toBe(true); - return value; - }, - { timeout: 10_000 }, - ); + const pids = await waitFor(async () => { + const value = JSON.parse(await readFile(pidPath, "utf8")) as { + launcher: number; + descendant: number; + }; + expect(Number.isSafeInteger(value.descendant)).toBe(true); + return value; + }); await vi.advanceTimersByTimeAsync(60_000); - const result = await vi.waitFor(() => pending, { timeout: 10_000 }); + const result = await waitFor(() => pending); vi.useRealTimers(); // The launcher exits at once, so only owned cleanup can stop the descendant. // The SDK transport signaled only its direct child and reported success. @@ -895,14 +890,11 @@ describe("production MCP doctor request-flow deadline", () => { REA_MCP_DOCTOR_DEADLINE_MS: "60000", }, }); - await vi.waitFor( - async () => { - await readFile(readyPath, "utf8"); - }, - { timeout: 10_000 }, - ); + await waitFor(async () => { + await readFile(readyPath, "utf8"); + }); await vi.advanceTimersByTimeAsync(60_000); - const result = await vi.waitFor(() => pending, { timeout: 10_000 }); + const result = await waitFor(() => pending); vi.useRealTimers(); expect(result.server).not.toBeNull(); const requestFlow = result.checks.find( diff --git a/tests/boundary/mcp/optionalProviderShutdown.test.ts b/tests/boundary/mcp/optionalProviderShutdown.test.ts index 354cb03241..f4e7758087 100644 --- a/tests/boundary/mcp/optionalProviderShutdown.test.ts +++ b/tests/boundary/mcp/optionalProviderShutdown.test.ts @@ -11,6 +11,7 @@ import { createJavaScriptRuntimeObservationProvider } from "../../../src/composi import { run } from "../../../src/main.js"; import { MCP_SHUTDOWN_FAILED } from "../../../src/main/messages.js"; import { createServer } from "../../../src/server/createServer.js"; +import { waitFor } from "../../support/testWait.js"; mkdirSync(".cache", { recursive: true }); const root = mkdtempSync(resolve(".cache/optional-provider-shutdown-")); @@ -169,15 +170,15 @@ it("retries failed process-owned optional cleanup without reclosing completed pe throw new Error("Shutdown hook was not installed"); shutdown(); - await vi.waitFor(() => expect(runtimeClose).toHaveBeenCalledTimes(1)); - await vi.waitFor(() => expect(exitCodes).toEqual([1])); + await waitFor(() => expect(runtimeClose).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(exitCodes).toEqual([1])); expect(browserClose).toHaveBeenCalledTimes(1); expect(transportCloses).toBe(1); expect(servers).toBe(1); expect(unregisterCalls).toBe(0); shutdown(); - await vi.waitFor(() => expect(unregisterCalls).toBe(1)); + await waitFor(() => expect(unregisterCalls).toBe(1)); expect(runtimeClose).toHaveBeenCalledTimes(2); expect(browserClose).toHaveBeenCalledTimes(1); diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts index ace97422b5..9ea21aad57 100644 --- a/tests/boundary/mcp/wasmArtifactMcp.test.ts +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -24,6 +24,7 @@ import { wasmArtifactFixture, wasmArtifactExecution, } from "../../fixtures/wasm/artifact.js"; +import { waitFor } from "../../support/testWait.js"; const schemaValidation = createMcpSchemaValidation(); afterAll(() => schemaValidation.clear()); @@ -344,7 +345,7 @@ it.each([undefined, "summary"] as const)( candidate_paths: [], }); await harness.client.ping(); - await vi.waitFor(() => { + await waitFor(() => { // Failed summary delivery retains the complete producer, without a derived view. expect(session.exportEvidenceBundle().records).toEqual([partial]); }); diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index d16118ac6f..b50c6a5d85 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -815,12 +815,7 @@ fs.writeFileSync(${JSON.stringify(marker)}, 'ready'); setInterval(() => {}, 1000 { signal: controller.signal }, ); await withVerifierCleanup(async () => { - await awaitAcknowledgement( - ready, - pending, - `${phase} tool startup`, - 5_000, - ); + await awaitAcknowledgement(ready, pending, `${phase} tool startup`); controller.abort(new Error("caller cancellation")); const result = await pending; if (result.ok) throw new Error("expected cancellation"); diff --git a/tests/boundary/process/androidAnalysisMcp.test.ts b/tests/boundary/process/androidAnalysisMcp.test.ts index e3caf81183..ae9373aa07 100644 --- a/tests/boundary/process/androidAnalysisMcp.test.ts +++ b/tests/boundary/process/androidAnalysisMcp.test.ts @@ -302,7 +302,14 @@ it("retains partial JADX replies when owned cleanup also fails", async () => { }); it("cleans an active engine when the MCP client disconnects", async () => { - const { provider, apk, launches } = await createJadxProtocolFixture("stall"); + // Without a disconnect-driven stop, the operation deadline would also clean + // the stalled engine; keep it beyond any test deadline. + const { provider, apk, launches } = await createJadxProtocolFixture( + "stall", + undefined, + undefined, + { REA_JADX_OPERATION_TIMEOUT_MS: "2147483647" }, + ); const session = createTestBinarySession(() => { throw new Error("Unexpected native provider acquisition"); }); @@ -314,7 +321,7 @@ it("cleans an active engine when the MCP client disconnects", async () => { const pending = client .callTool({ name: "inspect_android_package", arguments: { path: apk } }) .catch(() => undefined); - await expect.poll(() => launches.length, { timeout: 10_000 }).toBe(1); + await expect.poll(() => launches.length).toBe(1); await client.close(); await pending; await expect diff --git a/tests/boundary/process/androidMappingCleanup.test.ts b/tests/boundary/process/androidMappingCleanup.test.ts index 6f2dad5cd5..845799919a 100644 --- a/tests/boundary/process/androidMappingCleanup.test.ts +++ b/tests/boundary/process/androidMappingCleanup.test.ts @@ -148,13 +148,10 @@ it.each([false, true])( vi.advanceTimersByTime(60_000); vi.useRealTimers(); await expect - .poll( - () => ({ - fd: owner.handle.fd, - cleanupIncomplete: failed.error.cleanupIncomplete, - }), - { timeout: 5_000 }, - ) + .poll(() => ({ + fd: owner.handle.fd, + cleanupIncomplete: failed.error.cleanupIncomplete, + })) .toEqual({ fd: -1, cleanupIncomplete: false }); expect(failed.error.cause).toBe(originalCause); expect(fixture.attempts.every((attempt) => attempt === owner)).toBe(true); diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index cb82a4d162..3292c71154 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -1661,7 +1661,10 @@ const stopNodeChild = async (child: ChildProcess) => { await exited; }; -const waitForChildReadiness = async (child: ChildProcess): Promise => { +const waitForChildReadiness = async ( + child: ChildProcess, + timeoutMs?: number, +): Promise => { const listeners = new AbortController(); const message = once(child, "message", { signal: listeners.signal }); const exit = once(child, "exit", { signal: listeners.signal }); @@ -1670,6 +1673,7 @@ const waitForChildReadiness = async (child: ChildProcess): Promise => { message.then(() => undefined), exit, "title-mutating child readiness", + timeoutMs, ); return (await message)[0]; } finally { @@ -1682,7 +1686,8 @@ it("stops a child that never acknowledges readiness", async () => { stdio: ["ignore", "ignore", "ignore", "ipc"], }); try { - await expect(waitForChildReadiness(child)).rejects.toThrow( + // The gate deadline is the behavior under test. + await expect(waitForChildReadiness(child, 2_000)).rejects.toThrow( "title-mutating child readiness was not acknowledged before the gate deadline", ); } finally { diff --git a/tests/boundary/process/desktopBuildOwnedCommand.test.ts b/tests/boundary/process/desktopBuildOwnedCommand.test.ts index 30c229c306..4af4abf62b 100644 --- a/tests/boundary/process/desktopBuildOwnedCommand.test.ts +++ b/tests/boundary/process/desktopBuildOwnedCommand.test.ts @@ -1,11 +1,12 @@ import { mkdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { runDesktopBuildOwnedCommand } from "../../../src/desktop/DesktopBuildOwnedCommand.js"; import { prepareProcessOwnershipInspection } from "../../../src/process/ProcessOwnershipObservation.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { waitFor } from "../../support/testWait.js"; it.skipIf(process.platform === "win32")( "stops the packing process family before removing its partial archive on timeout", @@ -77,12 +78,9 @@ it.skipIf(process.platform === "win32")( }); void running.catch(() => undefined); try { - await vi.waitFor( - async () => { - expect(Number(await readFile(pidFile, "utf8"))).toBeGreaterThan(0); - }, - { timeout: 5000 }, - ); + await waitFor(async () => { + expect(Number(await readFile(pidFile, "utf8"))).toBeGreaterThan(0); + }); const pid = Number(await readFile(pidFile, "utf8")); controller.abort(reason); await expect(running).rejects.toMatchObject({ diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 7917ab291e..1f85546c6a 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -1219,9 +1219,7 @@ it("resolves every reported shutdown cleanup failure after ownership recovers", cleanupFails = false; // Closing the transport can leave the child between live and exited // snapshots. Retry retained ownership until cleanup is actually verified. - await expect - .poll(() => provider.close(), { timeout: 5_000 }) - .toBeUndefined(); + await expect.poll(() => provider.close()).toBeUndefined(); }; try { const outcome = await service.execute("inspect_android_package", { diff --git a/tests/boundary/process/packagedWasmVerifierOwnership.test.ts b/tests/boundary/process/packagedWasmVerifierOwnership.test.ts index 1ffd9a31c7..ce64797831 100644 --- a/tests/boundary/process/packagedWasmVerifierOwnership.test.ts +++ b/tests/boundary/process/packagedWasmVerifierOwnership.test.ts @@ -324,7 +324,7 @@ it.skipIf(process.platform === "win32").each([ await expectHarnessFailureCleanup(supervisor, events); return; } - await waitForProviderProcessReady(child.process, 25_000); + await waitForProviderProcessReady(child.process); const observed = JSON.parse(await readFile(report, "utf8")); expect(observed.reason).toBe("timeout"); expect(observed.pids).toHaveLength(2); diff --git a/tests/boundary/process/processCaptureCancellationAdapters.test.ts b/tests/boundary/process/processCaptureCancellationAdapters.test.ts index 8cca53e8af..17bbba3760 100644 --- a/tests/boundary/process/processCaptureCancellationAdapters.test.ts +++ b/tests/boundary/process/processCaptureCancellationAdapters.test.ts @@ -59,9 +59,7 @@ const isRunning = async (pid: number): Promise => { }; const waitForExit = async (pid: number): Promise => { - await expect - .poll(() => isRunning(pid), { timeout: 3_000, interval: 25 }) - .toBe(false); + await expect.poll(() => isRunning(pid), { interval: 25 }).toBe(false); }; const cleanupFixture = async (marker: Marker | undefined): Promise => { diff --git a/tests/boundary/process/processCaptureProgress.test.ts b/tests/boundary/process/processCaptureProgress.test.ts index ad41e7eb02..c33faf7d8f 100644 --- a/tests/boundary/process/processCaptureProgress.test.ts +++ b/tests/boundary/process/processCaptureProgress.test.ts @@ -608,12 +608,10 @@ itWithCaptureCapability( ); try { await expect - .poll( - () => - cancelling.notifications.some((note) => - note.params.message?.startsWith("running:"), - ), - { timeout: 4_000 }, + .poll(() => + cancelling.notifications.some((note) => + note.params.message?.startsWith("running:"), + ), ) .toBe(true); controller.abort(); @@ -634,7 +632,7 @@ itWithCaptureCapability( cancelling.notifications .map((note) => note.params.message) .find((message) => message?.startsWith("cleanup:")); - await expect.poll(cleanupMessage, { timeout: 15_000 }).toBeDefined(); + await expect.poll(cleanupMessage).toBeDefined(); expect(cleanupMessage()).toContain("disposition=cancelled"); expect(cleanupMessage()).toContain("owned_process_group=cleaned"); expect(JSON.stringify(cancelling.notifications)).not.toMatch( diff --git a/tests/boundary/process/processCli.test.ts b/tests/boundary/process/processCli.test.ts index 3b24ae6f5a..9cad8b52ca 100644 --- a/tests/boundary/process/processCli.test.ts +++ b/tests/boundary/process/processCli.test.ts @@ -202,7 +202,6 @@ describe("process CLI cleanup errors", () => { expect(Number.isSafeInteger(targetPid) && targetPid > 0).toBe(true); await expect .poll(() => observeProcessStartIdentity(targetPid), { - timeout: 7_000, interval: 25, }) .toBeUndefined(); diff --git a/tests/boundary/process/processFixtureLifecycle.test.ts b/tests/boundary/process/processFixtureLifecycle.test.ts index 2923a8ca96..960e9aacdb 100644 --- a/tests/boundary/process/processFixtureLifecycle.test.ts +++ b/tests/boundary/process/processFixtureLifecycle.test.ts @@ -6,10 +6,7 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { expect, it, vi } from "vitest"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; -import { - createTestProcessRegistry, - remainingProcessDeadline, -} from "../../support/process/processFixture.js"; +import { createTestProcessRegistry } from "../../support/process/processFixture.js"; const within = async ( operation: Promise, @@ -281,15 +278,6 @@ it("terminates a stalled real child at its deadline while retaining captured out } }); -it("derives a child deadline from the test deadline, keeping a reporting reserve", () => { - expect(remainingProcessDeadline(120_000, 0)).toBe(108_000); - expect(remainingProcessDeadline(30_000, 20_000)).toBe(5_000); - // The reserve never drops below five seconds, nor the deadline below one. - expect(remainingProcessDeadline(8_000, 0)).toBe(3_000); - expect(remainingProcessDeadline(8_000, 7_500)).toBe(1_000); - expect(remainingProcessDeadline(0, 0)).toBeUndefined(); -}); - it( "stops a hung child before its running test's deadline and reports its output", // The test deadline is the behavior under test: the child gets what remains. diff --git a/tests/boundary/process/testGroupCancellation.test.ts b/tests/boundary/process/testGroupCancellation.test.ts index eb705bd9d5..1b56f3053d 100644 --- a/tests/boundary/process/testGroupCancellation.test.ts +++ b/tests/boundary/process/testGroupCancellation.test.ts @@ -143,7 +143,6 @@ function release() { clearInterval(timer); clearTimeout(deadline); socket.end(); ready.promise, closed, "test-owned descendant readiness", - 5_000, ); runner.kill(signal); expect(await closed).toEqual({ diff --git a/tests/boundary/process/verifierTerminalOwner.test.ts b/tests/boundary/process/verifierTerminalOwner.test.ts index 928e8f92b2..9c51fe89e9 100644 --- a/tests/boundary/process/verifierTerminalOwner.test.ts +++ b/tests/boundary/process/verifierTerminalOwner.test.ts @@ -44,7 +44,7 @@ const deferred = () => { const waitForBoundary = ( boundary: Promise, running: Promise>, -) => awaitAcknowledgement(boundary, running, "terminal lifecycle", 5_000); +) => awaitAcknowledgement(boundary, running, "terminal lifecycle"); const terminalFixture = async () => { const parent = await createTestTempDirectory("rea-verifier-terminal-"); diff --git a/tests/boundary/providers/RizinDebugSessionInferiorStdin.test.ts b/tests/boundary/providers/RizinDebugSessionInferiorStdin.test.ts index 63b700e43e..bd12e3d800 100644 --- a/tests/boundary/providers/RizinDebugSessionInferiorStdin.test.ts +++ b/tests/boundary/providers/RizinDebugSessionInferiorStdin.test.ts @@ -74,9 +74,7 @@ const openTerminal = async (): Promise => { if (!exited) pty.kill(); await ended; }); - await expect - .poll(() => rizinTestTtyPathPattern.test(output), { timeout: 2_000 }) - .toBe(true); + await expect.poll(() => rizinTestTtyPathPattern.test(output)).toBe(true); return { path: rizinTestTtyPathPattern.exec(output)![0], output: () => output, @@ -334,10 +332,7 @@ it.skipIf(process.platform === "win32")( // read it from the selected TTY, so the caller's input channel really works. current.terminal.write("rea-input-probe\n"); await expect - .poll( - () => current.terminal.output().split("rea-input-probe").length - 1, - { timeout: 2_000 }, - ) + .poll(() => current.terminal.output().split("rea-input-probe").length - 1) .toBe(2); successValue(await manager.close(sessionId)); expect(await current.commands()).toEqual(["resume-target", "dr", "olj"]); diff --git a/tests/boundary/providers/RizinInferiorExit.test.ts b/tests/boundary/providers/RizinInferiorExit.test.ts index b201b65153..36ce76113c 100644 --- a/tests/boundary/providers/RizinInferiorExit.test.ts +++ b/tests/boundary/providers/RizinInferiorExit.test.ts @@ -106,9 +106,7 @@ process.stdin.resume();`, expect(await supervisor.waitForExit(1_000)).toBe(true); else await expect - .poll(() => readFile(redirected, "utf8"), { - timeout: 1_000, - }) + .poll(() => readFile(redirected, "utf8")) .toBe("redirected"); } } else if (line === "e dbg.exitkills") { @@ -137,9 +135,7 @@ process.stdin.resume();`, }, ); try { - await expect - .poll(() => readFile(ready, "utf8"), { timeout: 2_000 }) - .toBe("ready"); + await expect.poll(() => readFile(ready, "utf8")).toBe("ready"); const started = await manager.start({ path: "fixture.bin", inferior_tty: tty, diff --git a/tests/boundary/providers/adb/adbCommandFinalization.test.ts b/tests/boundary/providers/adb/adbCommandFinalization.test.ts index 816ccc36a6..5cbc0b7586 100644 --- a/tests/boundary/providers/adb/adbCommandFinalization.test.ts +++ b/tests/boundary/providers/adb/adbCommandFinalization.test.ts @@ -17,6 +17,7 @@ import { parseEvidence } from "../../../../src/domain/evidence.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { AnalysisTimeoutError } from "../../../../src/domain/analysisErrorCore.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { waitFor } from "../../../support/testWait.js"; const hooks = vi.hoisted(() => ({ afterCommand: undefined as (() => void) | undefined, @@ -131,7 +132,7 @@ test("retains the command owner's exact cancellation reason after active process { timeoutMs: 5000, maxOutputBytes: 1024 }, ); try { - await vi.waitFor(async () => + await waitFor(async () => expect(await readFile(ready, "utf8")).toBe("ready"), ); controller.abort(null); diff --git a/tests/boundary/providers/apktoolDecodeDeadline.test.ts b/tests/boundary/providers/apktoolDecodeDeadline.test.ts index f6b773ee03..8408aef010 100644 --- a/tests/boundary/providers/apktoolDecodeDeadline.test.ts +++ b/tests/boundary/providers/apktoolDecodeDeadline.test.ts @@ -47,7 +47,7 @@ it.skipIf(process.platform === "win32")( ({ kind }) => kind === "running", ); }, - { timeout: 8_000, interval: 25 }, + { interval: 25 }, ) .toBe(true); const outcome = await awaitApktoolOperation(pending, 15_000); @@ -72,7 +72,7 @@ it.skipIf(process.platform === "win32")( (await Promise.all(launched.map(observePosixProcess))).every( ({ kind }) => kind !== "running", ), - { timeout: 5_000, interval: 25 }, + { interval: 25 }, ) .toBe(true); } finally { diff --git a/tests/boundary/providers/apktoolProcessFixture.test.ts b/tests/boundary/providers/apktoolProcessFixture.test.ts index 729a157a26..18da0aea51 100644 --- a/tests/boundary/providers/apktoolProcessFixture.test.ts +++ b/tests/boundary/providers/apktoolProcessFixture.test.ts @@ -7,6 +7,8 @@ import { createApktoolProcessFixture } from "../../fixtures/apktoolProcessTree.j import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { observePosixProcess } from "../../support/process/observePosixProcess.js"; +const VERSION_PROBE_DEADLINE_MS = 20_000; + it .skipIf(process.platform === "win32") .each(["malformed", "unreadable"] as const)( @@ -17,6 +19,7 @@ it await expect(fixture.readMarker()).resolves.toBeUndefined(); const commands = new ApktoolCommandCleanup(); const controller = new AbortController(); + const probeStarted = performance.now(); const probe = inspectApktoolClient( { command: fixture.command, commandSource: "environment" }, commands, @@ -25,7 +28,6 @@ it try { await expect .poll(async () => (await fixture.readMarker())?.ready, { - timeout: 10_000, interval: 25, }) .toBe(true); @@ -52,9 +54,14 @@ it (await Promise.all(pids.map(observePosixProcess))).every( ({ kind }) => kind !== "running", ), - { timeout: 5_000, interval: 25 }, + { interval: 25 }, ) .toBe(true); + // The still-running version probe would also stop them at its own + // deadline (ApktoolConfiguration: 20 s); fixture teardown must be first. + expect(performance.now() - probeStarted).toBeLessThan( + VERSION_PROBE_DEADLINE_MS, + ); } finally { controller.abort(); await fixture.cleanup([probe, commands.close()]); diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index cd0d013804..ab01de80e1 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -52,6 +52,7 @@ import { GhidraFixtureLauncher as FixtureLauncher, ghidraFixturePath as fixturePath, } from "./ghidraClient.fixture.js"; +import { waitFor } from "../../../support/testWait.js"; const PROFILE_DIGEST = "a".repeat(64); const PROVIDER_VERSION = "12.1.4"; @@ -1383,7 +1384,7 @@ it.each([0, 1])( cancelled = result; }); controllers[cancelledIndex]?.abort(); - await vi.waitFor(() => + await waitFor(() => expect(cancelled).toMatchObject({ ok: false, error: { kind: "cancelled" }, @@ -1545,7 +1546,7 @@ it("applies one startup deadline and removes the private runtime", async () => { const pending = client.start(); const child = await launcher.started; await vi.advanceTimersByTimeAsync(30); - const result = await vi.waitFor(() => pending, { timeout: 10_000 }); + const result = await waitFor(() => pending); vi.useRealTimers(); expect(result).toMatchObject({ @@ -1610,7 +1611,7 @@ describe("GhidraClient established requests", () => { { document: null, procedure: "fixture_main" }, { signal: activeController.signal }, ); - await vi.waitFor(() => + await waitFor(() => expect(launcher.requests).toContain("procedure_pseudo_code"), ); const queuedController = new AbortController(); @@ -1675,7 +1676,7 @@ describe("GhidraClient runtime removal failures", () => { { document: null, procedure: "fixture_main" }, { signal: controller.signal }, ); - await vi.waitFor(() => + await waitFor(() => expect(launcher.requests).toContain("procedure_pseudo_code"), ); const queued = client.callTool("procedure_info", { @@ -1769,7 +1770,7 @@ describe("GhidraClient cleanup and diagnostics", () => { client.callTool("list_strings", { document: null }), ).resolves.toMatchObject({ ok: true }); - await vi.waitFor(() => { + await waitFor(() => { expect(client.diagnostics()).toMatchObject({ diagnostic_truncated: true, }); @@ -2416,7 +2417,7 @@ it.each([null, "caller stopped", new Error("caller stopped")])( { document: null, procedure: "fixture_main" }, { signal: activeController.signal }, ); - await vi.waitFor(() => + await waitFor(() => expect(launcher.requests).toContain("procedure_pseudo_code"), ); const queuedController = new AbortController(); @@ -2498,7 +2499,7 @@ it.each([null, "caller stopped", new Error("caller stopped")])( const client = clientFor(launcher, { startupTimeoutMs: 10000 }); const controller = new AbortController(); const pending = client.start(controller.signal); - await vi.waitFor(() => expect(observed).toHaveBeenCalled()); + await waitFor(() => expect(observed).toHaveBeenCalled()); controller.abort(reason); const result = await pending; if (result.ok) throw new Error("Expected startup cancellation"); diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index 7886d0af79..35f9fb32f6 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -30,6 +30,7 @@ import { ghidraHeadlessCommand, GhidraHeadlessLauncher, } from "../../../../src/ghidra/GhidraLauncher.js"; +import { waitFor } from "../../../support/testWait.js"; const fixturePath = fileURLToPath( new URL( @@ -1193,9 +1194,7 @@ describe("Ghidra Windows launcher process ownership", () => { if (!launched.ok) throw launched.error; try { const capturePath = join(runtimeRoot, "launch-capture.json"); - await vi.waitFor(() => access(`${capturePath}.ready`), { - timeout: 10_000, - }); + await waitFor(() => access(`${capturePath}.ready`)); const capture = launchCaptureSchema.parse( JSON.parse(await readFile(capturePath, "utf8")), ); diff --git a/tests/boundary/providers/hopper/hopperBridgeRequestLimit.test.ts b/tests/boundary/providers/hopper/hopperBridgeRequestLimit.test.ts index 8d7aef69e9..c2b1f213a2 100644 --- a/tests/boundary/providers/hopper/hopperBridgeRequestLimit.test.ts +++ b/tests/boundary/providers/hopper/hopperBridgeRequestLimit.test.ts @@ -7,6 +7,7 @@ import { HopperFixtureLauncher, trackHopperClient, } from "./hopperClient.fixture.js"; +import { waitFor } from "../../../support/testWait.js"; const limit = (maximumBytes: number) => ({ maximumBytes, source: "setting" }) as const; @@ -177,12 +178,9 @@ it("serializes a queued Hopper request only when it reaches the wire", async () }, }, ); - await vi.waitFor( - () => { - if (!queuedNotice) throw new Error("echo is not queued yet"); - }, - { timeout: 5_000 }, - ); + await waitFor(() => { + if (!queuedNotice) throw new Error("echo is not queued yet"); + }); expect(serializedPayload(stringify.mock.calls, value)).toBe(false); await launcher.release(held); await expect(active).resolves.toMatchObject({ diff --git a/tests/boundary/providers/hopper/hopperClient.deadlines.test.ts b/tests/boundary/providers/hopper/hopperClient.deadlines.test.ts index 0202cd077d..050a34e8ac 100644 --- a/tests/boundary/providers/hopper/hopperClient.deadlines.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.deadlines.test.ts @@ -5,6 +5,7 @@ import { HopperFixtureLauncher, startHopperFixtureClient, } from "./hopperClient.fixture.js"; +import { waitFor } from "../../../support/testWait.js"; describe("HopperClient request deadlines", () => { it("propagates deadlines through the socket client and accepts late replies safely", async () => { @@ -16,17 +17,15 @@ describe("HopperClient request deadlines", () => { .then(settled); const request = await launcher.waitForRequest("echo", "deadline"); try { - await vi.waitFor( - () => - expect(settled).toHaveBeenCalledWith({ - ok: false, - error: expect.objectContaining({ - _tag: "HopperTimeoutError", - timeoutMs: 100, - operation: "echo", - }), + await waitFor(() => + expect(settled).toHaveBeenCalledWith({ + ok: false, + error: expect.objectContaining({ + _tag: "HopperTimeoutError", + timeoutMs: 100, + operation: "echo", }), - { timeout: 1000 }, + }), ); expect(client.requestActivity()).toMatchObject({ callerState: "cancelled", @@ -92,7 +91,7 @@ describe("HopperClient request deadlines", () => { { value: "queued" }, { timeoutMs: 1000 }, ); - await vi.waitFor(() => + await waitFor(() => expect(client.requestActivity()?.queuedRequests).toBe(1), ); await client.close(); diff --git a/tests/boundary/providers/hopper/hopperClient.diagnostics.test.ts b/tests/boundary/providers/hopper/hopperClient.diagnostics.test.ts index 5b89ef4d21..db7a53db65 100644 --- a/tests/boundary/providers/hopper/hopperClient.diagnostics.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.diagnostics.test.ts @@ -1,10 +1,11 @@ import { access } from "node:fs/promises"; -import { expect, it, onTestFinished, vi } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; import { HopperTimeoutError } from "../../../../src/domain/hopperErrors.js"; import { HopperClient } from "../../../../src/hopper/HopperClient.js"; import { HopperFixtureLauncher } from "./hopperClient.fixture.js"; +import { waitFor } from "../../../support/testWait.js"; const HOPPER_PROCESS_DIAGNOSTIC_BYTES = 8 * 1024 * 1024; @@ -31,7 +32,7 @@ it("bounds long-lived launcher output while keeping the bridge usable", async () await expect(client.start()).resolves.toMatchObject({ ok: true }); const session = await client.callTool("echo", { value: "still usable" }); expect(session).toEqual({ ok: true, value: { value: "still usable" } }); - await vi.waitFor(() => { + await waitFor(() => { expect(observedStderrBytes).toBeGreaterThan( HOPPER_PROCESS_DIAGNOSTIC_BYTES, ); diff --git a/tests/boundary/providers/hopper/hopperClient.fixture.ts b/tests/boundary/providers/hopper/hopperClient.fixture.ts index 0c2a464ef1..a3f72c4c04 100644 --- a/tests/boundary/providers/hopper/hopperClient.fixture.ts +++ b/tests/boundary/providers/hopper/hopperClient.fixture.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { onTestFinished, vi } from "vitest"; +import { onTestFinished } from "vitest"; import { z } from "zod"; import { ok } from "../../../../src/domain/result.js"; @@ -11,6 +11,7 @@ import type { } from "../../../../src/hopper/BridgeLauncher.js"; import { waitForExit } from "../../../support/process/processFixture.js"; import { HopperClient } from "../../../../src/hopper/HopperClient.js"; +import { waitFor } from "../../../support/testWait.js"; export const hopperFixturePath = fileURLToPath( new URL("../../../fixtures/fakeHopper.mjs", import.meta.url), @@ -93,19 +94,16 @@ export class HopperFixtureLauncher implements BridgeLauncher { /** Wait for acknowledgement that the fixture actually received a request. */ waitForRequest(method: string, gate?: string): Promise { - return vi.waitFor( - () => { - const request = this.requests.find( - (value) => - value.method === method && - (gate === undefined || value.params?.gate === gate), - ); - if (request === undefined) - throw new Error(`Waiting for Hopper fixture request ${method}`); - return request; - }, - { timeout: 10_000 }, - ); + return waitFor(() => { + const request = this.requests.find( + (value) => + value.method === method && + (gate === undefined || value.params?.gate === gate), + ); + if (request === undefined) + throw new Error(`Waiting for Hopper fixture request ${method}`); + return request; + }); } /** Release a response held by the fixture's gate parameter. */ diff --git a/tests/boundary/providers/hopper/hopperClient.handshakeRecovery.test.ts b/tests/boundary/providers/hopper/hopperClient.handshakeRecovery.test.ts index af21f16974..9434662c70 100644 --- a/tests/boundary/providers/hopper/hopperClient.handshakeRecovery.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.handshakeRecovery.test.ts @@ -3,7 +3,7 @@ import { access, rm } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; import { promisify } from "node:util"; -import { expect, it, onTestFinished, vi } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { ok } from "../../../../src/domain/result.js"; @@ -13,6 +13,7 @@ import type { } from "../../../../src/hopper/BridgeLauncher.js"; import { HopperClient } from "../../../../src/hopper/HopperClient.js"; import { HopperFixtureLauncher } from "./hopperClient.fixture.js"; +import { waitFor } from "../../../support/testWait.js"; const stop = async (child: ChildProcess): Promise => { if (child.exitCode !== null || child.signalCode !== null) return; @@ -145,7 +146,7 @@ it.each(["missing", "not-listening"] as const)( "health", "shutdown_document", ]); - await vi.waitFor(() => expect(fixture.processes[0]?.exitCode).toBe(0)); + await waitFor(() => expect(fixture.processes[0]?.exitCode).toBe(0)); await expect(access(session.directory)).rejects.toMatchObject({ code: "ENOENT", }); @@ -202,9 +203,7 @@ it("reconnects for authenticated cleanup after a previously valid bridge transpo "health", "shutdown_document", ]); - await vi.waitFor(() => - expect(launcher.fixture.processes[0]?.exitCode).toBe(0), - ); + await waitFor(() => expect(launcher.fixture.processes[0]?.exitCode).toBe(0)); const session = launcher.sessions[0]; if (session === undefined) throw new Error("Fixture did not launch"); await expect(access(session.directory)).rejects.toMatchObject({ @@ -235,7 +234,7 @@ it("preserves launcher diagnostics after a health deadline and reauthenticates f }); expect(client.requestActivity()).toBeNull(); const closing = client.close(); - const next = await vi.waitFor(() => { + const next = await waitFor(() => { const request = launcher.fixture.requests.find( ({ method, id }) => method === "health" && id !== first.id, ); @@ -277,7 +276,7 @@ it("recovers an application exit after the first valid handshake occurs during c }); const closing = client.close(); - const next = await vi.waitFor(() => { + const next = await waitFor(() => { const request = launcher.fixture.requests.find( ({ method, id }) => method === "health" && id !== first.id, ); @@ -299,7 +298,7 @@ it("recovers an application exit after the first valid handshake occurs during c code: "ENOENT", }); const restarted = client.start(); - const health = await vi.waitFor(() => { + const health = await waitFor(() => { const request = launcher.fixture.requests.find( ({ method, id }) => method === "health" && id > next.id, ); diff --git a/tests/boundary/providers/hopper/hopperClient.health.test.ts b/tests/boundary/providers/hopper/hopperClient.health.test.ts index b5284676ad..92d7941461 100644 --- a/tests/boundary/providers/hopper/hopperClient.health.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.health.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; @@ -12,6 +12,7 @@ import type { } from "../../../../src/hopper/BridgeLauncher.js"; import { HopperClient } from "../../../../src/hopper/HopperClient.js"; import { HopperFixtureLauncher } from "./hopperClient.fixture.js"; +import { waitFor } from "../../../support/testWait.js"; class ObservedProcess extends EventEmitter { readonly stdout = new PassThrough(); @@ -90,7 +91,7 @@ it("reports launch work until the first authenticated handshake completes", asyn await client.close(); }); const starting = client.callTool("current_document"); - await awaitAcknowledgement(entered, starting, "Hopper launch", 10_000); + await awaitAcknowledgement(entered, starting, "Hopper launch"); expect(client.requestActivity()).toBeNull(); expect(client.operationHealth()).toMatchObject({ state: "busy", @@ -121,7 +122,7 @@ describe("Hopper provider lifecycle health", () => { const pending = client.callTool("hang"); await launcher.fixture.waitForRequest("hang"); launcher.fixture.processes.at(-1)?.send({ disconnect: true }); - await vi.waitFor(() => + await waitFor(() => expect(client.operationHealth().state).toBe("unreachable"), ); expect(await pending).toMatchObject({ @@ -204,7 +205,7 @@ describe("Hopper provider lifecycle health", () => { }, }, ); - await vi.waitFor(() => + await waitFor(() => expect(client.requestActivity()?.queuedRequests).toBe(1), ); await launcher.fixture.release(request); @@ -228,9 +229,7 @@ it("preserves an owned signal exit and diagnostics for active, queued and later const active = client.callTool("hang"); const request = await launcher.fixture.waitForRequest("hang"); const queued = client.callTool("echo"); - await vi.waitFor(() => - expect(client.requestActivity()?.queuedRequests).toBe(1), - ); + await waitFor(() => expect(client.requestActivity()?.queuedRequests).toBe(1)); const token = launcher.fixture.tokens[0]; launcher.observed.stderr.write(`API_KEY=local-evidence-kept ${token}\n`); launcher.observed.exit(null, "SIGTERM"); diff --git a/tests/boundary/providers/hopper/hopperClient.progress.test.ts b/tests/boundary/providers/hopper/hopperClient.progress.test.ts index 46bb9866ae..f7071504cb 100644 --- a/tests/boundary/providers/hopper/hopperClient.progress.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.progress.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { HopperFixtureLauncher, startHopperFixtureClient as startClient, } from "./hopperClient.fixture.js"; +import { waitFor } from "../../../support/testWait.js"; describe("HopperClient progress", () => { it("forwards correlated Python progress before the terminal response", async () => { @@ -27,7 +28,7 @@ describe("HopperClient progress", () => { }, ); const request = await launcher.waitForRequest("echo", "progress"); - await vi.waitFor(() => + await waitFor(() => expect(updates).toContainEqual( expect.objectContaining({ phase: "hopper_bridge", completed: 0 }), ), @@ -152,7 +153,7 @@ describe("HopperClient progress", () => { ]), ); await launcher.release(request); - await vi.waitFor(() => expect(client.requestActivity()).toBeNull()); + await waitFor(() => expect(client.requestActivity()).toBeNull()); await expect(client.callTool("echo", { alive: true })).resolves.toEqual({ ok: true, value: { alive: true }, diff --git a/tests/boundary/providers/hopper/hopperClient.protocol.test.ts b/tests/boundary/providers/hopper/hopperClient.protocol.test.ts index 5c86456c5a..b810f937ac 100644 --- a/tests/boundary/providers/hopper/hopperClient.protocol.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.protocol.test.ts @@ -1,6 +1,6 @@ import { once } from "node:events"; -import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; import { HopperProcessError } from "../../../../src/domain/hopperErrors.js"; import { HOPPER_OPERATIONS } from "../../../../src/hopper/HopperProviderMetadata.js"; @@ -13,6 +13,7 @@ import { HopperFixtureLauncher as FixtureLauncher, startHopperFixtureClient as startClient, } from "./hopperClient.fixture.js"; +import { waitFor } from "../../../support/testWait.js"; describe("HopperClient protocol", () => { it("does not start cold and preserves normal dispatch once ready", async () => { @@ -82,7 +83,7 @@ describe("HopperClient protocol", () => { void fast.then(() => { fastSettled = true; }); - await vi.waitFor(() => + await waitFor(() => expect(client.requestActivity()).toMatchObject({ operation: "echo", queuedRequests: 1, @@ -264,7 +265,7 @@ it("preserves caller cancellation while a late valid UTF-8 reply drains", async expect(result.error.cause).toBe(reason); expect(client.requestActivity()).toMatchObject({ callerState: "cancelled" }); await launcher.release(request); - await vi.waitFor(() => expect(client.requestActivity()).toBeNull()); + await waitFor(() => expect(client.requestActivity()).toBeNull()); await expect( client.callTool("echo", { label: "next �雪🦊" }), ).resolves.toEqual({ diff --git a/tests/boundary/providers/hopper/hopperClient.startupWaiters.test.ts b/tests/boundary/providers/hopper/hopperClient.startupWaiters.test.ts index 49692a81f0..4e0c22e8de 100644 --- a/tests/boundary/providers/hopper/hopperClient.startupWaiters.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.startupWaiters.test.ts @@ -14,6 +14,7 @@ import { HopperFixtureLauncher, trackHopperClient, } from "./hopperClient.fixture.js"; +import { waitFor } from "../../../support/testWait.js"; it("preserves a warm provider when an already cancelled caller joins", async () => { const launcher = new HopperFixtureLauncher(); @@ -144,7 +145,7 @@ it.each([0, 1])( }); const reason = { source: "caller", opaque: cancelledIndex }; controllers[cancelledIndex]?.abort(reason); - await vi.waitFor(() => + await waitFor(() => expect(cancelled).toMatchObject({ ok: false, error: { _tag: "HopperCancelledError" }, diff --git a/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts b/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts index d423c244f3..a66f540b0a 100644 --- a/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts +++ b/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts @@ -29,6 +29,7 @@ import { LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX, parseLinuxPrivateDisplayDiagnostic, } from "../../../../src/hopper/LinuxPrivateDisplayDiagnostic.js"; +import { waitFor } from "../../../support/testWait.js"; const helperPath = fileURLToPath( new URL("../../../../scripts/hopper-demo-x11.py", import.meta.url), @@ -543,7 +544,7 @@ it.skipIf(process.platform === "win32").each(["exited", "cancelled"] as const)( signal: controller.signal, }); if (outcome === "cancelled") { - await vi.waitFor(async () => expect(await access(ready)).toBeUndefined()); + await waitFor(async () => expect(await access(ready)).toBeUndefined()); controller.abort("caller stopped"); } const result = await operation; diff --git a/tests/boundary/providers/kuna/kunaCommandCancellation.test.ts b/tests/boundary/providers/kuna/kunaCommandCancellation.test.ts index 0be2039e16..b936622bd9 100644 --- a/tests/boundary/providers/kuna/kunaCommandCancellation.test.ts +++ b/tests/boundary/providers/kuna/kunaCommandCancellation.test.ts @@ -26,12 +26,10 @@ it.each([ ); try { await expect - .poll( - async () => - (await fakeKunaCalls(fixture.kuna)).some( - ({ argv }) => argv[0] === "strings", - ), - { timeout: 5000 }, + .poll(async () => + (await fakeKunaCalls(fixture.kuna)).some( + ({ argv }) => argv[0] === "strings", + ), ) .toBe(true); caller.abort(reason); @@ -70,12 +68,10 @@ it.each(["peer", "last"] as const)( : undefined; try { await expect - .poll( - async () => - (await fakeKunaCalls(fixture.kuna)).some( - ({ argv }) => argv[0] === "decompile", - ), - { timeout: 5000 }, + .poll(async () => + (await fakeKunaCalls(fixture.kuna)).some( + ({ argv }) => argv[0] === "decompile", + ), ) .toBe(true); caller.abort(reason); diff --git a/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts b/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts index 80c8a68908..3598c1da49 100644 --- a/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { afterAll, expect, it as test, vi } from "vitest"; +import { afterAll, expect, it as test } from "vitest"; import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; import { parseEvidence } from "../../../../src/domain/evidence.js"; @@ -13,6 +13,7 @@ import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { fakeKunaCalls, writeFakeKuna } from "./fakeKuna.js"; import { FUNCTIONS, unprovenCleanupLauncher } from "./kunaSessionFixture.js"; +import { waitFor } from "../../../support/testWait.js"; const it = test.skipIf(process.platform === "win32"); const schemas = createMcpSchemaValidation(); @@ -309,7 +310,7 @@ it("retains the project failure when cancellation stops the independent retry", { signal: controller.signal, timeout: 15000 }, ) .catch(() => undefined); - const launched = vi.waitFor( + const launched = waitFor( async () => { expect( (await fakeKunaCalls(kuna)).some( @@ -317,14 +318,9 @@ it("retains the project failure when cancellation stops the independent retry", ), ).toBe(true); }, - { timeout: 5000, interval: 20 }, - ); - await awaitAcknowledgement( - launched, - pending, - "independent retry launch", - 6000, + { interval: 20 }, ); + await awaitAcknowledgement(launched, pending, "independent retry launch"); controller.abort(); await pending; expect( diff --git a/tests/boundary/providers/kuna/kunaDoctor.test.ts b/tests/boundary/providers/kuna/kunaDoctor.test.ts index f8dda43362..55e185737e 100644 --- a/tests/boundary/providers/kuna/kunaDoctor.test.ts +++ b/tests/boundary/providers/kuna/kunaDoctor.test.ts @@ -13,6 +13,10 @@ import { STARTUP_OWNER_CLOSED } from "../../../../src/process/SharedStartupAttem const it = test.skipIf(process.platform === "win32"); +// The production `kuna --version` deadline (KunaInstallation). A stop that +// regressed would still end the gated probe there, so cancellation must win. +const VERSION_PROBE_DEADLINE_MS = 10_000; + it("reports an unconfigured Kuna as a missing optional engine", async () => { expect(await inspectKunaProvider({})).toMatchObject({ id: "kuna", @@ -284,18 +288,15 @@ it.each(["caller", "host"] as const)( try { // The preceding Ghidra probe may prepare Darwin process ownership before // Kuna starts; keep startup separate from the bounded post-abort join. - await expect - .poll(async () => (await fakeKunaCalls(kuna)).length, { - timeout: 10_000, - }) - .toBe(1); + await expect.poll(async () => (await fakeKunaCalls(kuna)).length).toBe(1); const probe = (await fakeKunaCalls(kuna))[0]; if (probe === undefined) throw new Error("Missing Kuna version probe"); expect(probe.argv).toEqual(["--version"]); if (source === "caller") controller.abort(reason); else closing = host.close!(); - // The unreleased probe normally waits for its ten-second deadline. - await expect.poll(() => settled, { timeout: 2_000 }).toBe(true); + await expect.poll(() => settled).toBe(true); + // The unreleased probe would otherwise end at its own deadline. + expect(Date.now() - probe.at).toBeLessThan(VERSION_PROBE_DEADLINE_MS); const failure = await inspection; expect(failure).toBeInstanceOf(AnalysisCancelledError); if (!(failure instanceof AnalysisCancelledError)) diff --git a/tests/boundary/providers/kuna/kunaInstallation.test.ts b/tests/boundary/providers/kuna/kunaInstallation.test.ts index 03322223ee..cbbabed282 100644 --- a/tests/boundary/providers/kuna/kunaInstallation.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallation.test.ts @@ -374,13 +374,11 @@ setTimeout(() => process.exit(9), 10000).unref(); ); try { await expect - .poll( - () => - readFile(stopped).then( - () => true, - () => false, - ), - { timeout: 5000 }, + .poll(() => + readFile(stopped).then( + () => true, + () => false, + ), ) .toBe(true); controller.abort({ request: "late doctor cancellation" }); diff --git a/tests/boundary/providers/kuna/kunaInstallationCancellation.test.ts b/tests/boundary/providers/kuna/kunaInstallationCancellation.test.ts index a6642322f1..8fda08e530 100644 --- a/tests/boundary/providers/kuna/kunaInstallationCancellation.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallationCancellation.test.ts @@ -7,6 +7,10 @@ import { session } from "./kunaSessionFixture.js"; const it = test.skipIf(process.platform === "win32"); +// The production `kuna --version` deadline (KunaInstallation). A stop that +// regressed would still end the gated probe there, so cancellation must win. +const VERSION_PROBE_DEADLINE_MS = 10_000; + const gatedInstallation = async () => { const fixture = await session("rea-kuna-installation-cancel-"); const gate = join(fixture.root, "release-version"); @@ -52,7 +56,7 @@ it("lets one caller leave acquisition with its exact reason while a peer keeps t try { const probe = await fixture.started(); controller.abort(reason); - await expect.poll(() => settled, { timeout: 1500 }).toBe(true); + await expect.poll(() => settled).toBe(true); const result = await leaving; if (result.ok) throw new Error("Expected cancellation"); expect(result.error).toMatchObject({ @@ -86,7 +90,8 @@ it.each([null, "caller stopped"])( try { const probe = await fixture.started(); controller.abort(reason); - await expect.poll(() => settled, { timeout: 1500 }).toBe(true); + await expect.poll(() => settled).toBe(true); + expect(Date.now() - probe.at).toBeLessThan(VERSION_PROBE_DEADLINE_MS); const result = await pending; if (result.ok) throw new Error("Expected cancellation"); expect(result.error).toMatchObject({ @@ -118,7 +123,8 @@ it("close cancels and joins an installation probe while callers wait", async () void closing.then(() => { closed = true; }); - await expect.poll(() => closed, { timeout: 1500 }).toBe(true); + await expect.poll(() => closed).toBe(true); + expect(Date.now() - probe.at).toBeLessThan(VERSION_PROBE_DEADLINE_MS); expect((await closing).ok).toBe(true); const result = await pending; if (result.ok) throw new Error("Expected cancellation"); diff --git a/tests/boundary/providers/kuna/kunaProjectConcurrency.test.ts b/tests/boundary/providers/kuna/kunaProjectConcurrency.test.ts index 248feac0f9..f536c09c81 100644 --- a/tests/boundary/providers/kuna/kunaProjectConcurrency.test.ts +++ b/tests/boundary/providers/kuna/kunaProjectConcurrency.test.ts @@ -130,9 +130,7 @@ it.each([true, false])( if (!closed.ok) throw closed.error; }); await expect - .poll(() => actual.readdir(listing.directory).catch(() => []), { - timeout: 10_000, - }) + .poll(() => actual.readdir(listing.directory).catch(() => [])) .toHaveLength(2); listing.names = await actual.readdir(listing.directory); releaseListing(); diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index cf8713f736..f6ea12dcf5 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -750,12 +750,7 @@ it("keeps an overflow as the primary failure when cancellation arrives during fi { signal: caller.signal }, ); try { - await awaitAcknowledgement( - entered, - pending, - "Kuna overflow cleanup", - 10_000, - ); + await awaitAcknowledgement(entered, pending, "Kuna overflow cleanup"); caller.abort("cancel during overflow cleanup"); releaseCleanup(); const result = await pending; diff --git a/tests/boundary/providers/kuna/kunaReaderCancellation.test.ts b/tests/boundary/providers/kuna/kunaReaderCancellation.test.ts index dce88a0bd9..97db66444f 100644 --- a/tests/boundary/providers/kuna/kunaReaderCancellation.test.ts +++ b/tests/boundary/providers/kuna/kunaReaderCancellation.test.ts @@ -45,7 +45,6 @@ const ready = async () => { async () => (await run(client, "procedure_pseudo_code", { procedure: "main" })) .rawResult, - { timeout: 5000 }, ) .toMatchObject({ source: { kind: "project-export" } }); const [key] = await readdir(join(cacheRoot, "projects")); @@ -86,7 +85,7 @@ it.each([ await expect.poll(() => held.streams.length).toBe(1); const reason = Object.freeze({ requested: "reader-stop", operation }); controller.abort(reason); - await expect.poll(() => finished, { timeout: 1000 }).toBe(true); + await expect.poll(() => finished).toBe(true); const cancelledResult = await cancelled; expect(cancelledResult).toMatchObject({ ok: false, @@ -126,18 +125,14 @@ it("close aborts and drains the shared scan after its only caller cancels", asyn try { await expect.poll(() => held.streams.length).toBe(1); controller.abort(); - await expect - .poll(() => result, { timeout: 1000 }) - .toMatchObject({ ok: false }); + await expect.poll(() => result).toMatchObject({ ok: false }); expect(held.streams[0]?.destroyed).toBe(false); let closed: unknown; closing = client.close().then((value) => { closed = value; return value; }); - await expect - .poll(() => closed, { timeout: 1000 }) - .toEqual({ ok: true, value: null }); + await expect.poll(() => closed).toEqual({ ok: true, value: null }); expect(held.streams[0]?.destroyed).toBe(true); expect(held.streams[0]?.closed).toBe(true); } finally { diff --git a/tests/boundary/providers/kuna/kunaReferences.test.ts b/tests/boundary/providers/kuna/kunaReferences.test.ts index c221e049e8..730e3b599c 100644 --- a/tests/boundary/providers/kuna/kunaReferences.test.ts +++ b/tests/boundary/providers/kuna/kunaReferences.test.ts @@ -305,10 +305,8 @@ it("caps distinct pending xrefs and cancels queued work without launching it", a ); try { await expect - .poll( - async () => - (await fakeKunaCalls(kuna)).some(({ argv }) => argv[0] === "xrefs"), - { timeout: 5000 }, + .poll(async () => + (await fakeKunaCalls(kuna)).some(({ argv }) => argv[0] === "xrefs"), ) .toBe(true); expect(await client.execute("xrefs", { address: "0x5000" })).toMatchObject({ diff --git a/tests/boundary/providers/kuna/kunaSessionFixture.ts b/tests/boundary/providers/kuna/kunaSessionFixture.ts index e430907320..993ee77112 100644 --- a/tests/boundary/providers/kuna/kunaSessionFixture.ts +++ b/tests/boundary/providers/kuna/kunaSessionFixture.ts @@ -143,13 +143,10 @@ export const awaitKunaCommand = async ( } })(); try { - // Cold installation, workspace and inventory preparation precede decompile. - // The phase probe took over two seconds with the two-core resource bound. await awaitAcknowledgement( acknowledged, operation, `Kuna ${command} child`, - 5_000, ); } finally { waiting = false; diff --git a/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts b/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts index 02ee7f0a33..94019d817c 100644 --- a/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts +++ b/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts @@ -13,6 +13,7 @@ import { RegularFileCancelled, } from "../../../../src/filesystem/OwnedRegularFile.js"; import { run, session } from "./kunaSessionFixture.js"; +import { waitFor } from "../../../support/testWait.js"; const fault = vi.hoisted(() => ({ fail: false, @@ -375,7 +376,7 @@ it("does not open a second span while a peer still holds a failed close", async { procedure: "main" }, {}, ); - await vi.waitFor(() => { + await waitFor(() => { expect(fault.closeEntered).toBe(1); }); const second = client.execute( @@ -440,7 +441,7 @@ it("does not open a queued span after its caller aborts", async () => { { procedure: "main" }, {}, ); - await vi.waitFor(() => { + await waitFor(() => { expect(fault.closeEntered).toBe(1); }); const abort = new AbortController(); @@ -504,7 +505,7 @@ it.each(["before close", "during verification", "during close"] as const)( { signal: abort.signal }, ); if (when === "during close") { - await vi.waitFor(() => { + await waitFor(() => { expect(fault.closeEntered).toBe(1); }); abort.abort(reason); diff --git a/tests/boundary/providers/kuna/kunaStrings.test.ts b/tests/boundary/providers/kuna/kunaStrings.test.ts index 83276a6bcb..2651838388 100644 --- a/tests/boundary/providers/kuna/kunaStrings.test.ts +++ b/tests/boundary/providers/kuna/kunaStrings.test.ts @@ -1,4 +1,4 @@ -import { expect, it as test, vi } from "vitest"; +import { expect, it as test } from "vitest"; import { readKunaConfiguration } from "../../../../src/kuna/KunaConfiguration.js"; import { KUNA_DIAGNOSTIC_BYTES } from "../../../../src/kuna/KunaLimits.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; @@ -6,6 +6,7 @@ import { ProviderCleanupError } from "../../../../src/domain/providerCleanupErro import { fakeKunaCalls, setFakeKunaBehavior } from "./fakeKuna.js"; import { run, session, unprovenCleanupLauncher } from "./kunaSessionFixture.js"; +import { waitFor } from "../../../support/testWait.js"; const it = test.skipIf(process.platform === "win32"); const scans = async (kuna: string) => @@ -241,7 +242,7 @@ it("cancels one waiter without stopping the shared scan, and rebuilds in a fresh { signal: abort.signal }, ); const survivor = first.execute("search_strings", { pattern: "fixture" }); - await vi.waitFor(async () => expect(await scans(kuna)).toHaveLength(1)); + await waitFor(async () => expect(await scans(kuna)).toHaveLength(1)); abort.abort(); expect(await cancelled).toMatchObject({ ok: false, @@ -273,7 +274,7 @@ it("drains a cancelled last scan before retrying", async () => { {}, { signal: abort.signal }, ); - await vi.waitFor(async () => expect(await scans(kuna)).toHaveLength(1)); + await waitFor(async () => expect(await scans(kuna)).toHaveLength(1)); abort.abort(); expect(await pending).toMatchObject({ ok: false, @@ -303,7 +304,7 @@ it("retains metadata admission until a string process stop is proven", async () {}, { signal: abort.signal }, ); - await vi.waitFor(async () => expect(await scans(kuna)).toHaveLength(1)); + await waitFor(async () => expect(await scans(kuna)).toHaveLength(1)); abort.abort(); expect(await pending).toMatchObject({ ok: false, diff --git a/tests/boundary/providers/native/lldbLauncherBoundary.test.ts b/tests/boundary/providers/native/lldbLauncherBoundary.test.ts index 8d3245bb51..10d98c1d3e 100644 --- a/tests/boundary/providers/native/lldbLauncherBoundary.test.ts +++ b/tests/boundary/providers/native/lldbLauncherBoundary.test.ts @@ -161,15 +161,12 @@ setTimeout(() => process.exit(0), 10000); controller.signal, ); await expect - .poll( - async () => { - started = readySchema.parse( - JSON.parse(await readFile(readyPath, "utf8")), - ); - return started.run_id; - }, - { timeout: 5_000 }, - ) + .poll(async () => { + started = readySchema.parse( + JSON.parse(await readFile(readyPath, "utf8")), + ); + return started.run_id; + }) .toEqual(expect.any(String)); if (started === undefined) throw new Error("Wrapper did not start"); const identity = await observeProcessStartIdentity( @@ -199,9 +196,7 @@ setTimeout(() => process.exit(0), 10000); // fixture self-exits. Do not bypass ownership with an unverified PID kill. const pid = started.pid; await expect - .poll(async () => await observeProcessStartIdentity(pid), { - timeout: 12_000, - }) + .poll(async () => await observeProcessStartIdentity(pid)) .toBeUndefined(); expect((await tracer.close()).ok).toBe(true); } else expect(closed.ok).toBe(true); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index e5c437d7ea..7ec806abf1 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -493,7 +493,7 @@ describe("native command failure capture and limits", () => { { signal: controller.signal }, ); const result = await withVerifierCleanup(async () => { - await awaitAcknowledgement(ready, pending, "command output", 10_000); + await awaitAcknowledgement(ready, pending, "command output"); controller.abort(); return await pending; }, [ diff --git a/tests/boundary/providers/startupCloseWaiters.test.ts b/tests/boundary/providers/startupCloseWaiters.test.ts index 4d0ff6601e..c08a00a7d2 100644 --- a/tests/boundary/providers/startupCloseWaiters.test.ts +++ b/tests/boundary/providers/startupCloseWaiters.test.ts @@ -15,6 +15,7 @@ import { ghidraFixturePath, } from "./ghidra/ghidraClient.fixture.js"; import { HopperFixtureLauncher } from "./hopper/hopperClient.fixture.js"; +import { waitFor } from "../../support/testWait.js"; const providers = [ { @@ -89,7 +90,7 @@ it.each(providers)( () => undefined, ); controller.abort(); - await vi.waitFor(() => + await waitFor(() => expect(settled).toMatchObject({ ok: false, error: cancelled }), ); await expect(access(roots[0] ?? "")).resolves.toBeUndefined(); diff --git a/tests/evaluation/projectWaitDeadlineRule.test.ts b/tests/evaluation/projectWaitDeadlineRule.test.ts new file mode 100644 index 0000000000..fb8f85bd06 --- /dev/null +++ b/tests/evaluation/projectWaitDeadlineRule.test.ts @@ -0,0 +1,49 @@ +import { describe, it } from "vitest"; +import { fileURLToPath } from "node:url"; +import { RuleTester } from "oxlint/plugins-dev"; +import rule from "../../scripts/lint/rules/project-wait-deadline.mjs"; + +RuleTester.describe = describe; +RuleTester.it = it; +const tester = new RuleTester({ + languageOptions: { parserOptions: { lang: "ts" } }, +}); +const filename = fileURLToPath( + new URL("../boundary/ruleControl.test.ts", import.meta.url), +); +const vitest = `import { expect, vi } from "vitest";`; +tester.run("project-wait-deadline", rule, { + valid: [ + `${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);`, + `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 });`, + `import { expect } from "vitest"; const vi = { waitFor: (f: Function) => f() }; await vi.waitFor(() => ready);`, + `import { vi } from "./local.js"; await vi.waitFor(() => ready);`, + `${vitest} function local(expect: { poll: Function }) { return expect.poll(() => 1, { timeout: 5 }); }`, + `${vitest} vi.waitUntil;`, + ].map((code) => ({ code, filename })), + invalid: [ + ...[ + `${vitest} await vi.waitFor(() => ready);`, + `${vitest} await vi.waitFor(() => ready, { interval: 10 });`, + `import { vi as mocks } from "vitest"; await mocks.waitFor(() => ready);`, + `import * as vitest from "vitest"; await vitest.vi.waitFor(() => ready);`, + `${vitest} await vi?.waitFor(() => ready);`, + ].map((code) => ({ code, filename, errors: [{ messageId: "viWaitFor" }] })), + ...[ + `${vitest} await expect.poll(() => ready, { timeout: 5_000 }).toBe(true);`, + `${vitest} await expect.poll(() => ready, { interval: 10, timeout: limit }).toBe(true);`, + `${vitest} await expect.poll(() => ready, 5_000).toBe(true);`, + `${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);`, + ].map((code) => ({ + code, + filename, + errors: [{ messageId: "pollTimeout" }], + })), + ], +}); diff --git a/tests/evaluation/testDeadlines.test.ts b/tests/evaluation/testDeadlines.test.ts index bd3d249d24..45e0daead8 100644 --- a/tests/evaluation/testDeadlines.test.ts +++ b/tests/evaluation/testDeadlines.test.ts @@ -2,8 +2,14 @@ import { expect, inject, it } from "vitest"; import { COVERAGE_DEADLINE_SCALE, HOSTED_RUNNER_DEADLINE_SCALE, + remainingTestDeadline, TEST_DEADLINE_MS, } from "../support/testDeadlines.js"; +import { waitFor } from "../support/testWait.js"; + +// Longer than Vitest's one-second wait default, so these probes fail if the +// project deadline policy stops owning wait deadlines. +const PAST_VITEST_WAIT_DEFAULT_MS = 1_200; it("applies the source project deadline, scaled by coverage and hosted runners", ({ task, @@ -14,3 +20,24 @@ it("applies the source project deadline, scaled by coverage and hosted runners", expect(coverage.flatMap((c) => hosted.map((h) => c * h))).toContain(scale); expect(task.timeout).toBe(TEST_DEADLINE_MS.source * scale); }); + +it("derives a wait or child deadline from the test deadline, keeping a reporting reserve", () => { + expect(remainingTestDeadline(120_000, 0)).toBe(108_000); + expect(remainingTestDeadline(30_000, 20_000)).toBe(5_000); + // The reserve never drops below five seconds, nor the deadline below one. + expect(remainingTestDeadline(8_000, 0)).toBe(3_000); + expect(remainingTestDeadline(8_000, 7_500)).toBe(1_000); + expect(remainingTestDeadline(0, 0)).toBeUndefined(); +}); + +it("bounds expect.poll by the project deadline", async () => { + const ready = performance.now() + PAST_VITEST_WAIT_DEFAULT_MS; + await expect.poll(() => performance.now() >= ready).toBe(true); +}); + +it("bounds waitFor by its test's deadline", async () => { + const ready = performance.now() + PAST_VITEST_WAIT_DEFAULT_MS; + await waitFor(() => { + if (performance.now() < ready) throw new Error("not ready yet"); + }); +}); diff --git a/tests/fixtures/android/jadx.ts b/tests/fixtures/android/jadx.ts index b81f7edcd7..e4a5cbfe42 100644 --- a/tests/fixtures/android/jadx.ts +++ b/tests/fixtures/android/jadx.ts @@ -103,17 +103,14 @@ export const createJadxProtocolFixture = async ( // Retry retained ownership without bypassing the supervisor's proof. // Retain the entire stop result so an incomplete teardown failure reports its reason. await expect - .poll( - async () => { - const stopped = await supervisor.stop(); - if (stopped.status === "incomplete") - throw new Error( - `Owned process cleanup incomplete: ${stopped.reason}`, - ); - return stopped.status; - }, - { timeout: 5_000 }, - ) + .poll(async () => { + const stopped = await supervisor.stop(); + if (stopped.status === "incomplete") + throw new Error( + `Owned process cleanup incomplete: ${stopped.reason}`, + ); + return stopped.status; + }) .not.toBe("incomplete"); if (options.cwd !== undefined) await rm(options.cwd, { recursive: true, force: true }); diff --git a/tests/fixtures/lifecycleAcknowledgement.ts b/tests/fixtures/lifecycleAcknowledgement.ts index f821ded973..23a8699bad 100644 --- a/tests/fixtures/lifecycleAcknowledgement.ts +++ b/tests/fixtures/lifecycleAcknowledgement.ts @@ -1,9 +1,14 @@ -/** Fail promptly if an operation never reaches its controlled lifecycle boundary. */ +import { testWaitDeadline } from "../support/testWait.js"; + +/** + * Fail if an operation settles, or the test's wait deadline passes, before it + * reaches its controlled lifecycle boundary. + */ export const awaitAcknowledgement = async ( acknowledgement: Promise, operation: Promise, boundary: string, - timeoutMs = 2_000, + timeoutMs = testWaitDeadline(), ): Promise => { let deadline: ReturnType | undefined; try { diff --git a/tests/fixtures/outputTreeCommit.ts b/tests/fixtures/outputTreeCommit.ts index 46f6b7360d..e90cbc2164 100644 --- a/tests/fixtures/outputTreeCommit.ts +++ b/tests/fixtures/outputTreeCommit.ts @@ -1,6 +1,7 @@ import { expect, onTestFinished, vi } from "vitest"; import { SafeOutputTree } from "../../src/artifacts/SafeOutputTree.js"; +import { waitFor } from "../support/testWait.js"; /** * Call `cancel` once an output tree has published, inside the commit that @@ -17,7 +18,7 @@ export const cancelDuringCommit = (cancel: () => void): void => { ) { await commit.call(this, signal); cancel(); - await vi.waitFor(() => expect(signal?.aborted).toBe(true)); + await waitFor(() => expect(signal?.aborted).toBe(true)); }); onTestFinished(() => { spy.mockRestore(); diff --git a/tests/fixtures/providerProcess.ts b/tests/fixtures/providerProcess.ts index 6ec569f413..f35e232f63 100644 --- a/tests/fixtures/providerProcess.ts +++ b/tests/fixtures/providerProcess.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { fileURLToPath } from "node:url"; import type { ProviderProcessHandle } from "../../src/process/ProviderProcess.js"; +import { testWaitDeadline } from "../support/testWait.js"; const fixturePath = fileURLToPath( new URL("./providerProcess.mjs", import.meta.url), @@ -33,7 +34,7 @@ export const spawnProviderProcessFixture = ( /** Wait until a long-lived fixture has installed its signal behavior. */ export const waitForProviderProcessReady = ( child: ProviderProcessHandle, - timeoutMs = 2_000, + timeoutMs = testWaitDeadline(), ): Promise => new Promise((resolve, reject) => { const stdout = child.stdout; @@ -69,7 +70,7 @@ export const waitForProviderProcessReady = ( /** Read the distinct process-group leader created by the detached-child mode. */ export const waitForDetachedProviderChild = ( child: ProviderProcessHandle, - timeoutMs = 2_000, + timeoutMs = testWaitDeadline(), ): Promise => new Promise((resolve, reject) => { const stdout = child.stdout; diff --git a/tests/process-global/browserScenarioCleanup.test.ts b/tests/process-global/browserScenarioCleanup.test.ts index 4d6fc0f188..554844df0b 100644 --- a/tests/process-global/browserScenarioCleanup.test.ts +++ b/tests/process-global/browserScenarioCleanup.test.ts @@ -29,6 +29,7 @@ import { jsonValueSchema, } from "../../src/domain/jsonValue.js"; import { ok } from "../../src/domain/result.js"; +import { waitFor } from "../support/testWait.js"; const state = vi.hoisted(() => ({ provider: undefined as BrowserScenarioCapturePort | undefined, @@ -319,7 +320,7 @@ it("retries a late Playwright open after cancellation before accepting another c signal: cancellation.signal, }); try { - await vi.waitFor(() => expect(launchCount).toBe(1)); + await waitFor(() => expect(launchCount).toBe(1)); cancellation.abort(); const cancelled = await firstCapture; if (cancelled.ok) throw new Error("Expected the first capture to cancel."); diff --git a/tests/process-global/darwinCompilerCleanup.test.ts b/tests/process-global/darwinCompilerCleanup.test.ts index 0f7c7ca05e..cbe9c8322f 100644 --- a/tests/process-global/darwinCompilerCleanup.test.ts +++ b/tests/process-global/darwinCompilerCleanup.test.ts @@ -53,6 +53,7 @@ import { primaryAnalysisError } from "../../src/domain/analysisErrorBase.js"; import { systemProcessOwnershipHost } from "../../src/process/ProcessOwnershipObservation.js"; import { ghidraFixturePath } from "../boundary/providers/ghidra/ghidraClient.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { waitFor } from "../support/testWait.js"; let releaseCleanup: (() => void) | undefined; const clients: GhidraClient[] = []; const readers: ReturnType[] = []; @@ -112,14 +113,11 @@ const waitForCompilerReady = async ( settled = { value }; }, ); - const outcome = await vi.waitFor( - async () => { - if (settled !== undefined) return settled; - expect(await readFile(readyPath, "utf8")).toBe("ready"); - return undefined; - }, - { timeout: 10_000 }, - ); + const outcome = await waitFor(async () => { + if (settled !== undefined) return settled; + expect(await readFile(readyPath, "utf8")).toBe("ready"); + return undefined; + }); if (outcome !== undefined) throw new Error("Compiler operation settled before its ready marker", { cause: outcome.value, @@ -186,7 +184,7 @@ it mode === "two retries" ? reader.prepare().catch((cause: unknown) => cause) : reader.close(); - await vi.waitFor(() => expect(fault.attempts).toBe(before + 1)); + await waitFor(() => expect(fault.attempts).toBe(before + 1)); await new Promise((resolve) => setImmediate(resolve)); expect(fault.attempts).toBe(before + 1); release?.(); diff --git a/tests/process-global/environmentFinalizationCli.test.ts b/tests/process-global/environmentFinalizationCli.test.ts index 0a7a5ce318..e75fca2536 100644 --- a/tests/process-global/environmentFinalizationCli.test.ts +++ b/tests/process-global/environmentFinalizationCli.test.ts @@ -131,12 +131,7 @@ it.each([ }, ); try { - await awaitAcknowledgement( - started, - pending, - "environment CLI startup", - 10_000, - ); + await awaitAcknowledgement(started, pending, "environment CLI startup"); const served = observedEnvironment(); if (served === undefined) throw new Error("Environment was not admitted"); if (ending === "signal") { diff --git a/tests/process-global/mainStartupCancellation.test.ts b/tests/process-global/mainStartupCancellation.test.ts index 7c9c4ed83f..ac6c11a5b0 100644 --- a/tests/process-global/mainStartupCancellation.test.ts +++ b/tests/process-global/mainStartupCancellation.test.ts @@ -113,7 +113,6 @@ it.each([ entered.promise.then(() => undefined), pending, "initial target open", - 10_000, ); const signal = await entered.promise; expect(signals.listenerCount("SIGINT")).toBe(1); @@ -187,12 +186,7 @@ it.each(["optional", "late-handle"] as const)( registerProcessShutdown(handler, { signals, stdin: input }), }); try { - await awaitAcknowledgement( - entered.promise, - pending, - "startup stage", - 10_000, - ); + await awaitAcknowledgement(entered.promise, pending, "startup stage"); if (stage === "optional") { await new Promise((resolve) => { input.once("end", resolve); diff --git a/tests/process-global/pwntoolsCleanup.test.ts b/tests/process-global/pwntoolsCleanup.test.ts index b7e35683b1..d28557f031 100644 --- a/tests/process-global/pwntoolsCleanup.test.ts +++ b/tests/process-global/pwntoolsCleanup.test.ts @@ -266,7 +266,7 @@ it.runIf(supportedHost)( await provider.close(); }); const first = provider.inspect({ path }); - await awaitAcknowledgement(started, first, "pwntools launch", 10_000); + await awaitAcknowledgement(started, first, "pwntools launch"); const controller = new AbortController(); const cancelled = provider.inspect({ path }, { signal: controller.signal }); const reason = { stage: "queued" }; diff --git a/tests/process-global/runnerCancellation.test.ts b/tests/process-global/runnerCancellation.test.ts index 8201c8cac1..5b33ba2ab4 100644 --- a/tests/process-global/runnerCancellation.test.ts +++ b/tests/process-global/runnerCancellation.test.ts @@ -11,9 +11,10 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; -import { expect, it, onTestFinished, vi } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { waitFor } from "../support/testWait.js"; const runnerPath = fileURLToPath( new URL("../../scripts/run-exclusive.mjs", import.meta.url), @@ -194,14 +195,12 @@ async function exerciseWindowsGrandchild( try { await runner.ready; await expect(readFile(readyPath, "utf8")).resolves.toBe("ready"); - await vi.waitFor(async () => + await waitFor(async () => expect(await readFile(leaderExitedPath, "utf8")).toBe("exited"), ); const activityBefore = await readFile(activityPath, "utf8"); await expect - .poll(async () => (await readFile(activityPath, "utf8")).length, { - timeout: 5_000, - }) + .poll(async () => (await readFile(activityPath, "utf8")).length) .toBeGreaterThan(activityBefore.length); const activityDuring = await readFile(activityPath, "utf8"); const contender = await launch( diff --git a/tests/process-global/windowsOwnedCommand.test.ts b/tests/process-global/windowsOwnedCommand.test.ts index 1e83fbc1f9..285deb7a7e 100644 --- a/tests/process-global/windowsOwnedCommand.test.ts +++ b/tests/process-global/windowsOwnedCommand.test.ts @@ -13,6 +13,7 @@ import { } from "../../scripts/lib/windows-owned-command.mjs"; import { requireWindowsNativeAuthority } from "../../src/windows/WindowsNativeLoader.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { waitFor } from "../support/testWait.js"; const require = createRequire(import.meta.url); @@ -228,7 +229,7 @@ it.runIf(process.platform === "win32")( settled = true; }, ); - await vi.waitFor(() => expect(settled).toBe(true), { timeout: 15_000 }); + await waitFor(() => expect(settled).toBe(true)); await expect(lifecycle.finished).resolves.toMatchObject({ code: expect.any(Number), }); @@ -316,9 +317,7 @@ it.runIf(process.platform === "win32")( await expect(readFile(readyPath, "utf8")).resolves.toBe("ready"); const before = await readOptional(activityPath); await expect - .poll(async () => (await readFile(activityPath, "utf8")).length, { - timeout: 5_000, - }) + .poll(async () => (await readFile(activityPath, "utf8")).length) .toBeGreaterThan(before?.length ?? 0); expect( hasClosed, @@ -382,7 +381,7 @@ it("retries thrown and false cleanup results, then preserves the first error", a child.emit("error", undefined); expect(child.attempts).toBe(1); - await vi.waitFor(() => + await waitFor(() => expect(diagnostics).toEqual([ { type: "cleanup-failed", @@ -394,7 +393,7 @@ it("retries thrown and false cleanup results, then preserves the first error", a child.emit("error", new Error("secondary native error")); expect(child.attempts).toBe(2); - await vi.waitFor(() => + await waitFor(() => expect(diagnostics).toEqual([ { type: "cleanup-failed", diff --git a/tests/support/process/processFixture.ts b/tests/support/process/processFixture.ts index 7b7c8f92dc..1d76ec93c6 100644 --- a/tests/support/process/processFixture.ts +++ b/tests/support/process/processFixture.ts @@ -5,41 +5,11 @@ import { type SpawnOptionsWithoutStdio, } from "node:child_process"; -import { TestRunner, test as base } from "vitest"; +import { test as base } from "vitest"; + +import { runningTestDeadline } from "../runningTestDeadline.js"; const SHUTDOWN_GRACE_MS = 1_000; -/** Minimum time kept back from a test deadline to stop a child and report it. */ -const MIN_REPORT_RESERVE_MS = 5_000; - -/** - * Default child deadline: the test's remaining deadline, less a reserve for - * stopping the child and reporting its captured output. A hung child then fails - * with its output instead of an opaque test timeout, without a fixed value that - * assumes a host's speed. Undefined when the test itself has no deadline. - */ -export const remainingProcessDeadline = ( - testDeadlineMs: number, - elapsedMs: number, -): number | undefined => { - if (!(testDeadlineMs > 0)) return undefined; - const reserve = Math.max(MIN_REPORT_RESERVE_MS, testDeadlineMs / 10); - return Math.max( - SHUTDOWN_GRACE_MS, - Math.floor(testDeadlineMs - elapsedMs - reserve), - ); -}; - -/** - * Deadline left for a child started by the running test, or undefined outside - * a test. Uses the real clock, so tests that fake `Date` keep a real deadline. - */ -const currentTestProcessDeadline = (): number | undefined => { - const test = TestRunner.getCurrentTest(); - const started = test?.result?.startTime; - if (test === undefined || started === undefined) return undefined; - const now = performance.timeOrigin + performance.now(); - return remainingProcessDeadline(test.timeout, now - started); -}; /** Captured output and termination state from one child process. */ export interface ProcessResult { @@ -135,7 +105,7 @@ export const createTestProcessRegistry = ( run: async (command, arguments_, options = {}) => { const { input, - timeoutMs = currentTestProcessDeadline(), + timeoutMs = runningTestDeadline(), ...spawnOptions } = options; const child = spawn(command, arguments_, spawnOptions); diff --git a/tests/support/runningTestDeadline.ts b/tests/support/runningTestDeadline.ts new file mode 100644 index 0000000000..5ad4d0230d --- /dev/null +++ b/tests/support/runningTestDeadline.ts @@ -0,0 +1,15 @@ +import { TestRunner } from "vitest"; + +import { remainingTestDeadline } from "./testDeadlines.js"; + +/** + * Deadline left for work started by the running test, or undefined outside a + * test. Uses the real clock, so tests that fake `Date` keep a real deadline. + */ +export const runningTestDeadline = (): number | undefined => { + const test = TestRunner.getCurrentTest(); + const started = test?.result?.startTime; + if (test === undefined || started === undefined) return undefined; + const now = performance.timeOrigin + performance.now(); + return remainingTestDeadline(test.timeout, now - started); +}; diff --git a/tests/support/testDeadlines.ts b/tests/support/testDeadlines.ts index 727a505985..8a56abafaf 100644 --- a/tests/support/testDeadlines.ts +++ b/tests/support/testDeadlines.ts @@ -12,3 +12,27 @@ export const COVERAGE_DEADLINE_SCALE = 2; /** Hosted CI runs a worker per core, so it also scales every deadline. */ export const HOSTED_RUNNER_DEADLINE_SCALE = 2; + +/** Least time kept back from a test deadline to stop work and report it. */ +const MIN_REPORT_RESERVE_MS = 5_000; + +/** Least deadline handed to work that starts late in a test. */ +const MIN_REMAINING_DEADLINE_MS = 1_000; + +/** + * Deadline for a wait or child started `elapsedMs` into a test: what remains, + * less a reserve for stopping the work and reporting why. Hung work then fails + * with its own diagnostics instead of an opaque test timeout, without a fixed + * value that assumes a host's speed. Undefined when the test has no deadline. + */ +export const remainingTestDeadline = ( + testDeadlineMs: number, + elapsedMs: number, +): number | undefined => { + if (!(testDeadlineMs > 0)) return undefined; + const reserve = Math.max(MIN_REPORT_RESERVE_MS, testDeadlineMs / 10); + return Math.max( + MIN_REMAINING_DEADLINE_MS, + Math.floor(testDeadlineMs - elapsedMs - reserve), + ); +}; diff --git a/tests/support/testWait.ts b/tests/support/testWait.ts new file mode 100644 index 0000000000..ca8bd8d112 --- /dev/null +++ b/tests/support/testWait.ts @@ -0,0 +1,34 @@ +import { inject, TestRunner, vi } from "vitest"; + +import { remainingTestDeadline, TEST_DEADLINE_MS } from "./testDeadlines.js"; + +/** Options for {@link waitFor}; the test deadline policy owns the timeout. */ +export interface WaitForOptions { + readonly interval?: number; +} + +/** + * Deadline for a wait: as long as its test may run, less the reporting reserve, + * matching the project's `expect.poll` policy. Outside a test, as in + * `beforeAll`, it uses the shortest project deadline. A wait that starts late + * may outlast the test, which then fails with Vitest's own timeout. + */ +export const testWaitDeadline = (): number => { + const testDeadlineMs = + TestRunner.getCurrentTest()?.timeout ?? + TEST_DEADLINE_MS.source * inject("testDeadlineScale"); + return remainingTestDeadline(testDeadlineMs, 0) ?? TEST_DEADLINE_MS.source; +}; + +/** + * `vi.waitFor` bounded by {@link testWaitDeadline} rather than Vitest's + * one-second default, so a slow host neither fails a correct test nor needs a + * literal timeout. Assert elapsed time explicitly, or order the condition + * before a release gate, when promptness is the behavior under test. + */ +export const waitFor = ( + callback: () => T | Promise, + options: WaitForOptions = {}, +): Promise => + // oxlint-disable-next-line rea/project-wait-deadline -- the owning helper. + vi.waitFor(callback, { ...options, timeout: testWaitDeadline() }); diff --git a/vitest.config.ts b/vitest.config.ts index 116807352a..c73d8ead12 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,7 @@ import { COVERAGE_DEADLINE_SCALE, HOSTED_RUNNER_DEADLINE_SCALE, LONG_RUNNING_DEADLINE_MS, + remainingTestDeadline, TEST_DEADLINE_MS as BASE_TEST_DEADLINE_MS, } from "./tests/support/testDeadlines.js"; @@ -60,6 +61,14 @@ const TEST_DEADLINE_MS = { built: BASE_TEST_DEADLINE_MS.built * DEADLINE_SCALE, } as const; +// Vitest's one-second `expect.poll` default is a host-speed guess. A poll the +// project owns waits as long as its test may run, less a reporting reserve, so +// a condition that never holds still fails with the poll's own diagnostics. +const pollPolicy = (deadlineMs: number) => { + const timeout = remainingTestDeadline(deadlineMs, 0); + return timeout === undefined ? {} : { timeout }; +}; + const TEST_PROJECTS = [...SOURCE_TEST_PROJECTS, ...BUILT_TEST_PROJECTS]; const PROJECT_POLICIES = { domain: { @@ -153,6 +162,7 @@ const projects = TEST_PROJECTS.map((project) => { isolate: project.isolate !== false, testTimeout: TEST_DEADLINE_MS[project.group], hookTimeout: TEST_DEADLINE_MS[project.group], + expect: { poll: pollPolicy(TEST_DEADLINE_MS[project.group]) }, ...policy, }, }; @@ -171,6 +181,7 @@ export default defineConfig({ runner: "./tests/processOwnershipRunner.ts", testTimeout: TEST_DEADLINE_MS.source, hookTimeout: TEST_DEADLINE_MS.source, + expect: { poll: pollPolicy(TEST_DEADLINE_MS.source) }, // Deadline probes compare what Vitest applies with the configured policy. provide: { testDeadlineScale: DEADLINE_SCALE }, tags: [