diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 78f03f1f91b72..c329aef3553d9 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -521,6 +521,26 @@ describe("server browser evaluate", () => { }); }); +describe("server browser wait", () => { + it("ends at its timeout when a check never answers", async () => { + // As on a desktop tab whose Playwright utility world never answers. + const page = { + locator: () => ({ count: () => new Promise(() => {}) }), + } as unknown as Page; + vi.useFakeTimers(); + try { + let failure: unknown; + void ServerBrowserPage.waitFor(page, { selector: "button", timeoutMs: 300 }).catch( + (error: unknown) => (failure = error), + ); + await vi.advanceTimersByTimeAsync(300); + expect(failure).toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("server browser net errors", () => { it.each([ ["net::ERR_CONNECTION_REFUSED", "ERR_CONNECTION_REFUSED", -102], diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 31cff821c6e42..21284ae7504e8 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -947,7 +947,6 @@ export const evaluate = async ( export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) => { const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const deadline = Date.now() + timeoutMs; const locator = targetLocator(page, input); const { text, urlIncludes } = input; const checks: Array<() => Promise> = []; @@ -962,16 +961,28 @@ export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) ); } if (urlIncludes !== undefined) checks.push(async () => page.url().includes(urlIncludes)); - for (;;) { - const results = await Promise.all(checks.map((check) => check())); - if (results.every(Boolean)) return; - if (Date.now() >= deadline) { - throw new ServerBrowserOperationError({ - tag: "PreviewAutomationTimeoutError", - message: `Waited ${timeoutMs}ms without every condition holding.`, - }); + // `count()` and `evaluate()` take no timeout; a stalled check would hold the tab's control queue. + let timer: ReturnType | undefined; + const expired = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new ServerBrowserOperationError({ + tag: "PreviewAutomationTimeoutError", + message: `Waited ${timeoutMs}ms without every condition holding.`, + }), + ), + timeoutMs, + ); + }); + try { + for (;;) { + const results = await Promise.race([Promise.all(checks.map((check) => check())), expired]); + if (results.every(Boolean)) return; + await Promise.race([new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)), expired]); } - await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)); + } finally { + clearTimeout(timer); } };