// @vitest-environment jsdom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { TaskBrowser } from "@paperclipai/shared"; import { browserUseApi } from "@/api/browser-use"; import { ApiError } from "@/api/client"; import { ConnectivityProvider, createConnectivityStore, type ConnectivityStore } from "@/lib/connectivity"; import { TooltipProvider } from "@/components/ui/tooltip"; import { TaskBrowserPanel } from "./TaskBrowserPanel"; vi.mock("@/api/browser-use", () => ({ browserUseApi: { viewer: vi.fn(async () => ({ url: "https://live.browser-use.com/test", viewport: "default" })), presence: vi.fn(async () => ({ accepted: true })), control: vi.fn(), resize: vi.fn(), releaseViewport: vi.fn(async () => ({})), } })); (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; let root: Root, host: HTMLDivElement, cache: QueryClient; let hidden = false, paneVisible = true; const browser: TaskBrowser = { id: "browser", sessionId: "session", issueId: "task", status: "idle", runStatus: "completed", progress: null, error: null, costCents: 0, idleDeadline: null, expiresAt: null, createdAt: "2026-09-29T00:00:00Z" }; async function render(active = true, selectedBrowser = browser) { await act(async () => root.render()); } async function tick(ms: number) { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); } async function viewerLoaded() { await act(async () => host.querySelector('iframe')!.dispatchEvent(new Event('load'))); } beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); hidden = false; paneVisible = true; vi.mocked(browserUseApi.presence).mockResolvedValue({ accepted: true }); Object.defineProperty(document, "visibilityState", { configurable: true, get: () => hidden ? "hidden" : "visible" }); vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} }); vi.spyOn(HTMLElement.prototype, "getClientRects").mockReturnValue([{}] as any); Object.defineProperty(Element.prototype, "checkVisibility", { configurable: true, value: () => paneVisible }); host = document.createElement("div"); document.body.append(host); root = createRoot(host); cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }); }); afterEach(async () => { await act(async () => root.unmount()); cache.clear(); host.remove(); delete (Element.prototype as any).checkVisibility; vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); }); it("renews only while the selected viewer is visible, even with a fixed viewport", async () => { await render(); expect(browserUseApi.presence).toHaveBeenCalledTimes(1); await viewerLoaded(); await tick(30_000); expect(browserUseApi.presence).toHaveBeenCalledTimes(2); await render(false); await tick(60_000); expect(browserUseApi.presence).toHaveBeenCalledTimes(2); await render(); expect(browserUseApi.presence).toHaveBeenCalledTimes(3); hidden = true; await tick(30_000); expect(browserUseApi.presence).toHaveBeenCalledTimes(3); hidden = false; paneVisible = false; await tick(30_000); expect(browserUseApi.presence).toHaveBeenCalledTimes(3); paneVisible = true; await tick(30_000); expect(browserUseApi.presence).toHaveBeenCalledTimes(4); }); it("automatically reconnects a stalled iframe once, then shows an actionable timeout", async () => { await render(); expect(host.textContent).toContain("Connecting to browser"); const initialFrame = host.querySelector('iframe'); await tick(15_000); expect(host.querySelector('iframe')).not.toBe(initialFrame); expect(host.textContent).toContain("Connecting to browser"); await tick(15_000); expect(host.textContent).toContain("The live view did not load"); const failedFrame = host.querySelector('iframe'); await tick(60_000); expect(host.querySelector('iframe')).toBe(failedFrame); expect(browserUseApi.resize).not.toHaveBeenCalled(); const reconnect = Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Reconnect view')!; await act(async () => reconnect.click()); expect(host.textContent).toContain("Connecting to browser"); await viewerLoaded(); await tick(15_000); expect(host.textContent).not.toContain("The live view did not load"); }); it("does not use up the load window or reconnect while the browser tab is hidden", async () => { await render(false); const initialFrame = host.querySelector('iframe'); await tick(60_000); expect(host.querySelector('iframe')).toBe(initialFrame); expect(host.textContent).not.toContain("The live view did not load"); await render(); await tick(14_000); expect(host.querySelector('iframe')).toBe(initialFrame); await tick(1_000); expect(host.querySelector('iframe')).not.toBe(initialFrame); await viewerLoaded(); const loadedFrame = host.querySelector('iframe'); await tick(60_000); expect(host.querySelector('iframe')).toBe(loadedFrame); expect(host.textContent).not.toContain("The live view did not load"); }); it("reports failed keep-open and clears the warning after recovery", async () => { vi.mocked(browserUseApi.presence).mockRejectedValueOnce(new Error("offline")); await render(); expect(host.textContent).toContain("Could not keep this browser open"); await viewerLoaded(); await tick(30_000); expect(host.textContent).not.toContain("Could not keep this browser open"); }); // --- Disconnect handling for the viewer credential read ------------------------- const unavailable = () => new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }); let store: ConnectivityStore; let probe: ReturnType Promise<{ reachable: boolean; retryAfterMs: number | null }>>>; beforeEach(() => { probe = vi.fn(async () => ({ reachable: true, retryAfterMs: null })); store = createConnectivityStore({ probe: probe as never, browserOnline: true }); }); afterEach(() => { store.dispose(); }); async function renderResilient(active = true) { await act(async () => root.render()); } it("keeps the loaded live view through a transient viewer failure", async () => { await renderResilient(); await viewerLoaded(); expect(host.querySelector("iframe")?.getAttribute("src")).toBe("https://live.browser-use.com/test"); vi.mocked(browserUseApi.viewer).mockRejectedValue(unavailable()); await tick(15_000); // The revalidation failed with an outage-class error: the iframe stays, with no red copy. expect(host.querySelector("iframe")?.getAttribute("src")).toBe("https://live.browser-use.com/test"); expect(host.textContent).not.toContain("The live view disconnected"); expect(host.querySelector('[data-query-view="error"]')).toBeNull(); await tick(60_000); expect(host.querySelector("iframe")).not.toBeNull(); vi.mocked(browserUseApi.viewer).mockResolvedValue({ url: "https://live.browser-use.com/test", viewport: "default" }); await tick(15_000); expect(host.querySelector('[data-query-view="stale"]')).toBeNull(); expect(host.querySelector("iframe")).not.toBeNull(); }); it("removes the iframe when the viewer credential is revoked", async () => { await renderResilient(); await viewerLoaded(); expect(host.querySelector("iframe")).not.toBeNull(); vi.mocked(browserUseApi.viewer).mockRejectedValue(new ApiError("You no longer have access to this browser.", 403, { error: "You no longer have access to this browser." })); await tick(15_000); expect(host.querySelector("iframe")).toBeNull(); const alert = host.querySelector('[data-query-view="error"]'); expect(alert?.textContent).toContain("Couldn't connect to the live browser view"); expect(alert?.textContent).toContain("You no longer have access to this browser."); expect(alert?.querySelector("button")).toBeNull(); // Revalidation keeps running, so restored access brings the view back by itself. vi.mocked(browserUseApi.viewer).mockResolvedValue({ url: "https://live.browser-use.com/restored", viewport: "default" }); await tick(15_000); expect(host.querySelector("iframe")?.getAttribute("src")).toBe("https://live.browser-use.com/restored"); }); it("shows a quiet placeholder for a first load during an outage and recovers by itself", async () => { probe.mockResolvedValue({ reachable: false, retryAfterMs: null }); vi.mocked(browserUseApi.viewer).mockRejectedValue(unavailable()); await renderResilient(); await act(async () => { store.reportError(unavailable()); }); await tick(50); expect(store.getSnapshot().status).toBe("reconnecting"); expect(host.querySelector("iframe")).toBeNull(); expect(host.textContent).toContain("Connecting to browser"); expect(host.querySelector('[data-query-view="error"]')).toBeNull(); expect(host.textContent).not.toContain("tenant_app_unavailable"); probe.mockResolvedValue({ reachable: true, retryAfterMs: null }); vi.mocked(browserUseApi.viewer).mockResolvedValue({ url: "https://live.browser-use.com/test", viewport: "default" }); await tick(1_000); await tick(1_000); expect(store.getSnapshot().status).toBe("online"); expect(host.querySelector("iframe")?.getAttribute("src")).toBe("https://live.browser-use.com/test"); }); it("ignores a viewer response for a previous browser", async () => { let resolveFirst!: (value: { url: string; viewport: "default" }) => void; vi.mocked(browserUseApi.viewer).mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })); await renderResilient(); expect(host.querySelector("iframe")).toBeNull(); vi.mocked(browserUseApi.viewer).mockResolvedValue({ url: "https://live.browser-use.com/second", viewport: "default" }); await act(async () => root.render()); await act(async () => { resolveFirst({ url: "https://live.browser-use.com/first", viewport: "default" }); }); expect(host.querySelector("iframe")?.getAttribute("src")).toBe("https://live.browser-use.com/second"); }); it("accepts the first fixed viewport and control state after switching browsers", async () => { vi.spyOn(HTMLIFrameElement.prototype, "getBoundingClientRect").mockReturnValue({ width: 1000, height: 700 } as DOMRect); vi.mocked(browserUseApi.viewer).mockResolvedValueOnce({ url: "https://live.browser-use.com/fixed", viewport: "phone", viewportState: { preset: "phone", width: 390, height: 844 } }); await render(); await viewerLoaded(); await tick(1000); await act(async () => host.querySelector('[aria-label="Browser options"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))); expect(document.body.textContent).toContain("Phone"); await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); expect(browserUseApi.resize).not.toHaveBeenCalled(); vi.mocked(browserUseApi.viewer).mockResolvedValueOnce({ url: "https://live.browser-use.com/other", viewport: "tablet", viewportState: { preset: "tablet", width: 768, height: 1024 } }); await render(true, { ...browser, id: "other-browser" }); await viewerLoaded(); await tick(1000); await act(async () => host.querySelector('[aria-label="Browser options"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))); expect(document.body.textContent).toContain("Tablet"); expect(browserUseApi.resize).not.toHaveBeenCalled(); }); it("accepts initial viewer control state before automatic sizing is enabled", async () => { vi.mocked(browserUseApi.viewer).mockResolvedValueOnce({ url: "https://live.browser-use.com/controlled", viewport: "fit", viewportState: { preset: "fit", controlledElsewhere: true } }); await render(); expect(host.textContent).toContain("Size follows another viewer"); expect(browserUseApi.resize).not.toHaveBeenCalled(); });