Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,92 @@ beforeEach(() => {
desktopConnections.length = 0;
});

it.live(
"reports main-frame navigation failures until a retry begins while keeping the host available",
() =>
Effect.scoped(
Effect.gen(function* () {
const { broker, tabId } = yield* ready;
const page = contexts[0]!.page;
const url = "http://localhost:5733/";
yield* Effect.promise(() => page.goto(url));
const request = {
isNavigationRequest: () => true,
frame: () => page,
url: () => url,
method: () => "GET",
failure: () => ({ errorText: "net::ERR_CONNECTION_REFUSED" }),
};
page.emit("request", request);
page.emit("requestfailed", request);
page.emit("load");
yield* Effect.promise(() => page.emitAsync("framenavigated", page));
const failed = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
expect(failed).toMatchObject({
available: true,
loading: false,
navigationError: { url, code: -102, description: "ERR_CONNECTION_REFUSED" },
});

page.emit("requestfailed", {
...request,
frame: () => ({}),
failure: () => ({ errorText: "net::ERR_NAME_NOT_RESOLVED" }),
});
expect(
yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
}),
).toMatchObject({ navigationError: failed.navigationError });

yield* Effect.promise(() => page.goto("https://previous.example/"));
yield* Effect.promise(() => page.emitAsync("framenavigated", page));
const restored = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
expect(restored).toMatchObject({
available: true,
loading: false,
url: "https://previous.example/",
});
expect(restored).not.toHaveProperty("navigationError");

page.emit("request", request);
page.emit("requestfailed", request);

page.emit("request", request);
const retrying = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
expect(retrying.loading).toBe(true);
expect(retrying).not.toHaveProperty("navigationError");
yield* Effect.promise(() => page.goto(url));
const loaded = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
expect(loaded).toMatchObject({ available: true, loading: false, url });
expect(loaded).not.toHaveProperty("navigationError");
}),
).pipe(Effect.provide(layer)),
);

it.live("readiness none responds immediately but takeover input waits for navigation commit", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
26 changes: 22 additions & 4 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ interface ServerTab {
colorScheme: PreviewAppearancePreference;
zoomFactor: number;
loading: boolean;
loadFailure: Extract<PreviewNavStatus, { _tag: "LoadFailed" }> | null;
closing: boolean;
recording: Recording | null;
initialNavigation: Promise<void> | null;
Expand Down Expand Up @@ -557,6 +558,7 @@ const make = Effect.gen(function* () {
};

const report = (tab: ServerTab, navStatus: PreviewNavStatus) => {
tab.loadFailure = navStatus._tag === "LoadFailed" ? navStatus : null;
void tab.cdp
.send("Page.getNavigationHistory")
.catch(() => null)
Expand All @@ -578,13 +580,19 @@ const make = Effect.gen(function* () {
});
};

const reportLoaded = async (tab: ServerTab) => {
const reportLoaded = async (tab: ServerTab, confirmedNavigation = false) => {
const url = tab.page.url();
const loadFailure = tab.loadFailure;
// Chromium's error page loads after `requestfailed` and must not clear LoadFailed.
if (url === "about:blank" || url.startsWith("chrome-error://")) return;
if (
(loadFailure && (!confirmedNavigation || url === loadFailure.url)) ||
url === "about:blank" ||
url.startsWith("chrome-error://")
)
return;
const title = (await tab.page.title().catch(() => "")).slice(0, 512);
// A navigation that started while reading the title owns the status now.
if (tab.loading || tab.page.url() !== url) return;
if (tab.loadFailure !== loadFailure || tab.loading || tab.page.url() !== url) return;
report(tab, { _tag: "Success", url: url.slice(0, 2048), title });
};

Expand Down Expand Up @@ -798,6 +806,7 @@ const make = Effect.gen(function* () {
colorScheme: "system",
zoomFactor: 1,
loading: false,
loadFailure: snapshot.navStatus._tag === "LoadFailed" ? snapshot.navStatus : null,
closing: false,
recording: null,
recordingStart: null,
Expand Down Expand Up @@ -827,7 +836,7 @@ const make = Effect.gen(function* () {
});
page.on("framenavigated", (frame) => {
// Same-document navigations (SPA routes) fire no load event.
if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab);
if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab, true);
});
page.on("requestfailed", (request) => {
const errorText = request.failure()?.errorText ?? "";
Expand Down Expand Up @@ -1336,6 +1345,15 @@ const make = Effect.gen(function* () {
url: url === "about:blank" ? null : url,
title: null,
loading: tab.loading,
...(tab.loadFailure
? {
navigationError: {
url: tab.loadFailure.url,
code: tab.loadFailure.code,
description: tab.loadFailure.description,
},
}
: {}),
control: {
owner: tabOwner(tab),
ownedByCaller: tab.control.agentId === agentSessionId,
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/browser/ServerBrowserSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type KeyboardEvent,
type PointerEvent,
type Ref,
type ReactNode,
useCallback,
useEffect,
useEffectEvent,
Expand Down Expand Up @@ -154,6 +155,8 @@ export function ServerBrowserSurface(props: {
readonly onFirstFrame?: () => void;
readonly onViewport?: (viewport: PreviewStreamViewport) => void;
readonly onControl?: (control: PreviewStreamControl | null) => void;
/** Replaces the page while keeping the stream and ownership controls connected. */
readonly pageOverlay?: ReactNode;
/** The page opened a new tab from this viewer's click; the viewer should switch to it. */
readonly onPopup?: (tabId: string) => void;
readonly className?: string;
Expand All @@ -169,6 +172,7 @@ export function ServerBrowserSurface(props: {
onFirstFrame,
onViewport,
onControl,
pageOverlay,
onPopup,
className,
ref,
Expand Down Expand Up @@ -765,7 +769,7 @@ export function ServerBrowserSurface(props: {
<div className="relative min-h-0 flex-1">
<canvas
ref={canvasRef}
className="block size-full touch-none object-contain"
className={cn("block size-full touch-none object-contain", pageOverlay && "invisible")}
onPointerDown={handlePointer}
onPointerMove={handlePointer}
onPointerUp={handlePointer}
Expand All @@ -780,6 +784,7 @@ export function ServerBrowserSurface(props: {
controller={control?.controller === "agent" ? "agent" : "none"}
/>
) : null}
{pageOverlay ? <div className="absolute inset-0 z-10">{pageOverlay}</div> : null}
{/* Focus target for page keyboard input. Pinned top-left so focusing it never
scrolls the surface; 16px keeps iOS from zooming the app on focus. */}
<textarea
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/RightPanelTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function overlay(
) {
return {
hasWebContents: true,
loadFailure: null,
canGoBack: false,
canGoForward: false,
loading: false,
Expand Down
Loading
Loading