diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b87bc8d9ca4d8..efc38f5ee6623 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -158,12 +158,15 @@ function makeContext(onClose?: (context: BrowserContext) => void) { mainFrame: () => page, frames: () => [page], url: () => url, + commit: (next: string) => { + url = next; + }, title: vi.fn(async () => "test page"), viewportSize: () => viewport, setViewportSize: vi.fn(async (size: typeof viewport) => { viewport = size; }), - goto: vi.fn(async (next: string) => { + goto: vi.fn(async (next: string, _options?: { timeout?: number; waitUntil?: string }) => { url = next; events.emit("load"); const pending = initialNavigation; @@ -174,7 +177,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { goForward: vi.fn(async () => {}), reload: vi.fn(async () => {}), emulateMedia: vi.fn(async () => {}), - waitForLoadState: vi.fn(async () => {}), + waitForLoadState: vi.fn(async (_state?: string, _options?: { timeout?: number }) => {}), evaluate: vi.fn(async () => ({ url, title: "test page", @@ -437,6 +440,77 @@ it.live("preview_open reports its navigation timeout instead of the request budg ).pipe(Effect.provide(layer)), ); +it.live.each([ + { renderer: "idle", title: "Heavy page" }, + { renderer: "busy", title: null }, +])( + "navigate returns the committed page while its load event is still pending ($renderer renderer)", + ({ renderer, title }) => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + // A busy main thread never answers title(); the status must not wait past the broker's grace. + page.title.mockImplementation(() => + renderer === "busy" ? new Promise(() => {}) : Promise.resolve("Heavy page"), + ); + const heavy = "http://localhost:5173/heavy"; + const timedOut = (method: string, timeout: number | undefined) => + Object.assign(new Error(`${method}: Timeout ${timeout}ms exceeded.`), { + name: "TimeoutError", + }); + // The document commits and parses, but a stalled subresource holds back `load`. + page.goto.mockImplementation(async (next, options) => { + page.commit(next); + page.emit("request", { + url: () => next, + isNavigationRequest: () => true, + frame: () => page, + }); + if (options?.waitUntil === "load") throw timedOut("page.goto", options.timeout); + }); + page.waitForLoadState.mockImplementation(async (state, options) => { + if (state === undefined || state === "load") { + throw timedOut("page.waitForLoadState", options?.timeout); + } + }); + + const status = yield* broker.invoke({ + scope, + tabId, + operation: "navigate", + input: { url: heavy, timeoutMs: 4_000 }, + }); + + expect(status).toMatchObject({ url: heavy, title, loading: true }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("navigate fails when the page never commits within its timeout", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + contexts[0]!.page.goto.mockImplementation(async () => { + throw Object.assign(new Error("page.goto: Timeout 4000ms exceeded."), { + name: "TimeoutError", + }); + }); + + const error = yield* broker + .invoke({ + scope, + tabId, + operation: "navigate", + input: { url: "http://10.255.255.1/", timeoutMs: 4_000 }, + }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "PreviewAutomationTimeoutError", timeoutMs: 4_000 }); + }), + ).pipe(Effect.provide(layer)), +); + it.live.each(["desktop" as const, undefined])( "explicit desktop targets ($0 runtime) fail without creating a server tab", (runtime) => diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index a2485b30e9e36..729c139f12c4b 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -123,6 +123,8 @@ const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { }`; const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; const NAVIGATION_TIMEOUT_MS = 15_000; +// page.title() runs in the renderer; a busy page must not hold a status past the broker's grace. +const TITLE_READ_MS = 250; const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; const ACTION_TIMELINE_LIMIT = 50; @@ -1401,7 +1403,14 @@ const make = Effect.gen(function* () { })), }; if (status.url === null || tab.dialog) return status; - return { ...status, title: (await tab.page.title().catch(() => "")) || null }; + let timer: ReturnType | undefined; + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(""), TITLE_READ_MS); + }); + const title = await Promise.race([tab.page.title().catch(() => ""), late]).finally(() => + clearTimeout(timer), + ); + return { ...status, title: title || null }; }; const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => { @@ -1419,20 +1428,22 @@ const make = Effect.gen(function* () { readiness: "load" | "domContentLoaded" | "none", timeout: number, ) => { - const navigation = tab.page.goto(url, { - timeout, - waitUntil: - readiness === "domContentLoaded" - ? "domcontentloaded" - : readiness === "none" - ? "commit" - : "load", - }); + const startedAt = performance.now(); + const navigation = tab.page.goto(url, { timeout, waitUntil: "commit" }); if (readiness === "none") { tab.control.track(navigation); return; } - await navigation.catch((cause: unknown) => { + // Once the target commits, a stalled subresource only delays readiness; report the loading tab. + const ready = () => + tab.page + .waitForLoadState(readiness === "domContentLoaded" ? "domcontentloaded" : "load", { + timeout: Math.max(1, timeout - (performance.now() - startedAt)), + }) + .catch((cause: unknown) => { + if (!(cause instanceof Error && cause.name === "TimeoutError")) throw cause; + }); + await navigation.then(ready).catch((cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); if (message.includes("Download is starting")) { throw new ServerBrowserPage.ServerBrowserOperationError(