diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 181f4904738f8..c595fbc7859af 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -203,12 +203,15 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = " mainFrame: () => page, frames: () => [page], url: () => url, + commit: (next: string) => { + url = next; + }, title: vi.fn(async () => "test page"), viewportSize: vi.fn(() => 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; @@ -219,7 +222,7 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = " 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", @@ -690,6 +693,130 @@ it.live("an initial download stays available after preview_open reports the navi ).pipe(Effect.provide(layer)), ); +it.effect.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; + const titleRequested = Promise.withResolvers(); + // A busy main thread never answers title(); drive its native deadline explicitly. + page.title.mockImplementation(() => { + titleRequested.resolve(); + return 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); + } + }); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const result = yield* broker + .invoke({ + scope, + tabId, + operation: "navigate", + input: { url: heavy, timeoutMs: 4_000 }, + }) + .pipe(Effect.forkScoped); + const titleRead = yield* Effect.raceFirst( + Effect.promise(() => titleRequested.promise).pipe(Effect.as(true)), + Fiber.await(result).pipe(Effect.as(false)), + ); + if (titleRead && renderer === "busy") { + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(250)); + } + const status = yield* Fiber.join(result); + + expect(status).toMatchObject({ url: heavy, title, loading: true }); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.useRealTimers())), Effect.provide(layer)), +); + +it.effect("a desktop status answers with the last viewport while the renderer cannot measure", () => + Effect.scoped( + Effect.gen(function* () { + const { broker } = yield* ready; + desktopRendersNext = true; + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { url: "https://example.com/", reuseExistingTab: false, show: false }, + }); + const measuring = Promise.withResolvers(); + desktopSizeAnswer = () => { + measuring.resolve(); + return new Promise(() => {}); + }; + // A resize makes the next status measure again. + yield* Effect.promise(() => + desktopConnections[0]!.context.sessions[0]!.emitAsync("Page.frameResized"), + ); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const result = yield* broker + .invoke({ + scope, + tabId: opened.tabId!, + operation: "status", + input: {}, + }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => measuring.promise); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(250)); + const status = yield* Fiber.join(result); + + expect(status.viewport).toEqual({ width: 1920, height: 1080 }); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.useRealTimers())), 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 e801aad619e55..cf1ecf37c0713 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -158,6 +158,8 @@ const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { }`; const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; const NAVIGATION_TIMEOUT_MS = 15_000; +// Status reads run in the renderer; a busy page must not hold a status past the broker's grace. +const STATUS_READ_MS = 250; const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; const ACTION_TIMELINE_LIMIT = 50; @@ -1821,6 +1823,14 @@ const make = Effect.gen(function* () { } }; + const withinStatusDeadline = (read: Promise, late: () => A): Promise => { + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(late()), STATUS_READ_MS); + }); + return Promise.race([read, deadline]).finally(() => clearTimeout(timer)); + }; + const statusWithTitle = async ( tab: ServerTab | undefined, agentSessionId?: string, @@ -1837,14 +1847,26 @@ const make = Effect.gen(function* () { }; } const url = ServerBrowserPage.withoutDataPayload(tab.page.url()); - const viewport = tab.desktop ? await desktopStatusViewport(tab) : tab.page.viewportSize(); const loadError = loadErrorOf(tab); - const status = { + const statusUrl = loadError?.url ?? (url === "about:blank" ? null : url); + const [viewport, title] = await Promise.all([ + // A late measurement still lands in desktopViewport for the next status. + tab.desktop + ? withinStatusDeadline(desktopStatusViewport(tab), () => tab.desktopViewport) + : tab.page.viewportSize(), + statusUrl === null || tab.dialog + ? null + : withinStatusDeadline( + tab.page.title().catch(() => ""), + () => "", + ).then((title) => title || null), + ]); + return { available: true, visible: isVisible(tab), tabId: tab.tabId, - url: loadError?.url ?? (url === "about:blank" ? null : url), - title: null, + url: statusUrl, + title, loading: tab.loading, ...(loadError ? { loadError } : {}), control: { @@ -1882,8 +1904,6 @@ const make = Effect.gen(function* () { completedAt, })), }; - if (status.url === null || tab.dialog) return status; - return { ...status, title: (await tab.page.title().catch(() => "")) || null }; }; const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => { @@ -1901,20 +1921,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({