diff --git a/test/helpers/openclaw-test-instance.ts b/test/helpers/openclaw-test-instance.ts index 74662c3f9eb4..f17018b6e76e 100644 --- a/test/helpers/openclaw-test-instance.ts +++ b/test/helpers/openclaw-test-instance.ts @@ -331,9 +331,12 @@ async function waitForGatewayReady( throw exitedBeforeReadinessError(); } - const remainingMs = timeoutMs - (Date.now() - startedAt); - const attemptTimeoutMs = Math.min(1_000, Math.max(1, remainingMs)); const attemptStartedAt = Date.now(); + const remainingMs = timeoutMs - (attemptStartedAt - startedAt); + if (remainingMs <= 0) { + break; + } + const attemptTimeoutMs = Math.min(1_000, Math.max(1, remainingMs)); const probe: ReadinessProbe = { attempt: ++attempts, phase: "headers", elapsedMs: 0 }; const probeAbort = new AbortController(); const abortProbe = () => probeAbort.abort(signal?.reason); diff --git a/test/scripts/desktop-readiness-proof.test.ts b/test/scripts/desktop-readiness-proof.test.ts index e76a3b87736c..9a0a79fdd7e0 100644 --- a/test/scripts/desktop-readiness-proof.test.ts +++ b/test/scripts/desktop-readiness-proof.test.ts @@ -115,6 +115,72 @@ describe("desktop readiness evidence", () => { expect(failure.errors[1]).toBe(cleanup); }); + it("keeps the probe deadline within the startup budget as the clock advances", async () => { + const diagnostics: GatewayReadinessDiagnostic[] = []; + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response('{"ready":true,"failing":[]}', { status: 200 })); + let now = 1_000; + const clock = vi.spyOn(Date, "now").mockImplementation(() => now++); + try { + await testing.waitForGatewayReady( + processState(), + [], + [], + 12345, + 25, + fetchImpl, + undefined, + (entry) => diagnostics.push(entry), + ); + } finally { + clock.mockRestore(); + } + const artifact = await publicArtifact(diagnostics); + const entries = artifact.files[0]!.assertions[0]!.gatewayReadiness!; + expect(entries).toHaveLength(1); + const entry = entries[0]!; + expect(entry).toMatchObject({ outcome: "ready", attempts: 1 }); + expect(entry.deadlineMs - entry.startedAtMs).toBe(25); + expect(entry.probes[0]!.deadlineMs).toBe(entry.deadlineMs); + }); + + it("does not start a probe when the startup budget expires before admission", async () => { + const diagnostics: GatewayReadinessDiagnostic[] = []; + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response('{"ready":true,"failing":[]}', { status: 200 })); + const clock = vi + .spyOn(Date, "now") + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(1_024) + .mockReturnValue(1_026); + let failure: unknown; + try { + failure = await testing + .waitForGatewayReady(processState(), [], [], 12345, 25, fetchImpl, undefined, (entry) => + diagnostics.push(entry), + ) + .catch((error: unknown) => error); + } finally { + clock.mockRestore(); + } + expect(fetchImpl).not.toHaveBeenCalled(); + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).toContain("timeout waiting for gateway readiness"); + const artifact = await publicArtifact(diagnostics, failure); + const entries = artifact.files[0]!.assertions[0]!.gatewayReadiness!; + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + outcome: "timeout", + startedAtMs: 1_000, + deadlineMs: 1_025, + attempts: 0, + probes: [], + omittedProbes: 0, + }); + }); + it("records every completed probe and one success timing line", async () => { const diagnostics: GatewayReadinessDiagnostic[] = []; const fetchImpl = vi