// @vitest-environment happy-dom // ObservationOverlay: visibility lifecycle, focus view, interrupt interaction, // hover tooltip, drag move/resize from every corner with clamps, collapse capsule, and PiP // upgrade/fallback (mocked documentPictureInPicture). NOTE: use RTL's waitFor // (act-flushing), not vi.waitFor, when asserting store-driven UI updates. import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyResize, ObservationOverlay } from "../../src/client/ObservationOverlay"; import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store"; import type { SessionObservation } from "../../src/observation"; const BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: Date.now() - 7000 }; interface Harness { store: ObservationClientStore; es: () => EventSourceLike; push: (sessions: SessionObservation[]) => void; emitRaw: (event: unknown) => void; fetches: { url: string; init?: { body?: string } }[]; loadImage: ReturnType; eventUrls: string[]; } function makeHarness(initial: SessionObservation[]): Harness { const fetches: Harness["fetches"] = []; const eventUrls: string[] = []; let es: EventSourceLike | undefined; let current = initial; const loadImage = vi.fn(async (id: string) => `blob:${id}`); const store = new ObservationClientStore({ fetchFn: async (url: string, init?: { body?: string }) => { fetches.push({ url, init }); if (url === "/bsk-observation/state") { return { ok: true, json: async () => ({ sessions: current }) }; } return { ok: true, json: async () => ({ interrupted: true }) }; }, eventSourceFactory: (url) => { eventUrls.push(url); const connection = { onmessage: null, close: vi.fn() } as EventSourceLike; es = connection; queueMicrotask(() => connection.onmessage?.({ data: JSON.stringify({ type: "snapshot", sessions: current, available: true }), }), ); return connection; }, loadImage, }); return { store, es: () => { if (es === undefined) throw new Error("no EventSource yet"); return es; }, push: (sessions) => { current = sessions; for (const s of sessions) { es?.onmessage?.({ data: JSON.stringify({ type: "upsert", session: s }) }); } if (sessions.length === 0) es?.onmessage?.({ data: JSON.stringify({ type: "reset" }) }); }, emitRaw: (event) => { es?.onmessage?.({ data: JSON.stringify(event) }); }, fetches, loadImage, eventUrls, }; } afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("visible thumbnail demand", () => { function intersections() { const observers: Array<{ notify: (visible: boolean) => void; disconnect: ReturnType; }> = []; vi.stubGlobal( "IntersectionObserver", class { private target?: Element; disconnect = vi.fn(); constructor(private callback: IntersectionObserverCallback) { observers.push(this); } observe(target: Element) { this.target = target; } notify(visible: boolean) { this.callback( [{ target: this.target, isIntersecting: visible } as IntersectionObserverEntry], this as unknown as IntersectionObserver, ); } }, ); return observers; } it("pauses screenshots on collapse while keeping the metadata feed", async () => { const observers = intersections(); const h = makeHarness([BUSY]); const { unmount } = render(); await screen.findByTestId("obs-card"); expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0"); act(() => observers[0].notify(true)); expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1"); fireEvent.click(screen.getByRole("button", { name: "Collapse" })); expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0"); expect(h.es().close).not.toHaveBeenCalled(); expect(observers[0].disconnect).toHaveBeenCalledOnce(); // A callback queued before disconnect must not reclaim screenshot demand. act(() => observers[0].notify(true)); expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0"); fireEvent.click(screen.getByRole("button", { name: /Expand browser observation/ })); act(() => observers[1].notify(true)); expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1"); unmount(); expect(h.es().close).toHaveBeenCalledOnce(); }); it("ignores an initial visibility notification delivered after collapse", async () => { const observers = intersections(); const h = makeHarness([BUSY]); render(); await screen.findByTestId("obs-card"); fireEvent.click(screen.getByRole("button", { name: "Collapse" })); act(() => observers[0].notify(true)); expect(h.eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]); }); it("pauses for a hidden document or hidden panel and resumes when visible", async () => { const observers = intersections(); let visibility: DocumentVisibilityState = "visible"; vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility); const h = makeHarness([BUSY]); render(); await screen.findByTestId("obs-card"); act(() => observers[0].notify(true)); expect(h.eventUrls.at(-1)).toContain("thumbnails=1"); act(() => { visibility = "hidden"; document.dispatchEvent(new Event("visibilitychange")); }); expect(h.eventUrls.at(-1)).toContain("thumbnails=0"); act(() => { visibility = "visible"; document.dispatchEvent(new Event("visibilitychange")); }); expect(h.eventUrls.at(-1)).toContain("thumbnails=1"); act(() => observers[0].notify(false)); expect(h.eventUrls.at(-1)).toContain("thumbnails=0"); }); it("keeps a visible PiP watching when the main document is hidden", async () => { // The detached PiP test document has no window/IntersectionObserver. vi.stubGlobal("IntersectionObserver", undefined); let visibility: DocumentVisibilityState = "visible"; vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility); const pipDoc = document.implementation.createHTMLDocument("pip"); Object.defineProperty(pipDoc, "visibilityState", { value: "visible" }); (window as unknown as Record).documentPictureInPicture = { requestWindow: async () => ({ document: pipDoc, addEventListener: () => {}, close: vi.fn() }), }; const h = makeHarness([BUSY]); render(); fireEvent.click(await screen.findByRole("button", { name: /Pop out/ })); await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); act(() => { visibility = "hidden"; document.dispatchEvent(new Event("visibilitychange")); }); expect(h.eventUrls.at(-1)).toContain("thumbnails=1"); }); }); describe("ObservationOverlay", () => { beforeEach(() => { delete (window as unknown as Record).documentPictureInPicture; }); it("renders nothing with no sessions, appears when one starts", async () => { const h = makeHarness([]); const { container } = render(); await waitFor(() => expect(h.es).not.toThrow()); expect(container.firstChild).toBeNull(); h.push([{ sessionId: "s1", action: "idle", since: Date.now() }]); await screen.findByText(/s1 · idle/); expect(screen.getByTestId("obs-card")).toBeTruthy(); }); it("shows the status row and a placeholder without a thumbnail", async () => { const h = makeHarness([BUSY]); render(); await screen.findByText(/s1 · clicking · 00:0/); expect(screen.getByText("waiting for page")).toBeTruthy(); }); it("loads and renders the thumbnail through the store", async () => { const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-9" }]); render(); await waitFor(() => expect(h.loadImage).toHaveBeenCalledWith("att-9")); await screen.findByRole("img", { name: "session s1 view" }); }); it("offers a retry after a thumbnail fails without requiring a new frame id", async () => { const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-9" }]); h.loadImage.mockRejectedValueOnce(new Error("temporary failure")); render(); fireEvent.click(await screen.findByRole("button", { name: "Retry thumbnail" })); await screen.findByRole("img", { name: "session s1 view" }); expect(h.loadImage).toHaveBeenCalledTimes(2); expect(screen.queryByRole("button", { name: "Retry thumbnail" })).toBeNull(); }); it("keeps the current frame on stage while the next thumbnail loads", async () => { let resolveNext: ((url: string) => void) | undefined; const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]); h.loadImage.mockImplementation(async (id: string) => { if (id === "att-2") { return new Promise((resolve) => { resolveNext = resolve; }); } return `blob:${id}`; }); render(); const img = await screen.findByRole("img", { name: "session s1 view" }); expect(img.getAttribute("src")).toBe("blob:att-1"); h.push([{ ...BUSY, thumbnailAttachmentId: "att-2" }]); await waitFor(() => expect(h.loadImage).toHaveBeenCalledWith("att-2")); // Same node, same src — no placeholder flash, no remount/fade. expect(screen.queryByText("waiting for page")).toBeNull(); expect(screen.getByRole("img", { name: "session s1 view" })).toBe(img); expect(img.getAttribute("src")).toBe("blob:att-1"); resolveNext?.("blob:att-2"); await waitFor(() => expect(img.getAttribute("src")).toBe("blob:att-2")); expect(screen.getByRole("img", { name: "session s1 view" })).toBe(img); }); it("interrupt: disabled while idle, active call posts and shows progress", async () => { const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]); render(); const button = await screen.findByRole("button", { name: /Interrupt the current/ }); expect(button).toHaveProperty("disabled", true); h.push([BUSY]); await waitFor(() => expect(button).toHaveProperty("disabled", false)); fireEvent.click(button); await waitFor(() => expect(h.fetches.some((f) => f.url === "/bsk-observation/interrupt")).toBe(true), ); }); it("shows a hover tooltip on the interrupt icon and keeps the button icon-only", async () => { const h = makeHarness([BUSY]); render(); const button = await screen.findByRole("button", { name: /Interrupt the current/ }); expect(button.textContent ?? "").not.toMatch(/Interrupt/i); fireEvent.pointerEnter(button.parentElement as HTMLElement); const tip = await screen.findByRole("tooltip"); expect(tip.textContent).toBe("Stop the current browser action."); fireEvent.pointerLeave(button.parentElement as HTMLElement); expect(screen.queryByRole("tooltip")).toBeNull(); fireEvent.pointerEnter(button.parentElement as HTMLElement); expect(screen.getByRole("tooltip")).toBeTruthy(); }); it("collapses to a capsule and expands back", async () => { const h = makeHarness([BUSY]); render(); await screen.findByText(/s1 · clicking/); fireEvent.click(screen.getByRole("button", { name: "Collapse" })); const capsule = await screen.findByRole("button", { name: "Expand browser observation overlay", }); expect(capsule.textContent).toContain("clicking"); fireEvent.click(capsule); await screen.findByText(/s1 · clicking/); }); it("stop session: the first click only arms the confirm, the second posts", async () => { const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]); render(); await screen.findByText(/s1 · idle/); const stop = screen.getByRole("button", { name: "Stop session s1" }); fireEvent.click(stop); // Armed: a plain click must never close an Agent Window by itself. expect(h.fetches.some((f) => f.url === "/bsk-observation/stop")).toBe(false); const confirm = await screen.findByRole("button", { name: "Confirm stop session s1" }); expect(confirm.getAttribute("aria-pressed")).toBe("true"); fireEvent.click(confirm); await waitFor(() => expect( h.fetches.some( (f) => f.url === "/bsk-observation/stop" && f.init?.body === '{"sessionId":"s1"}', ), ).toBe(true), ); }); it("disarms the stop confirm after the expiry window", async () => { const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]); render(); const stop = await screen.findByRole("button", { name: "Stop session s1" }); vi.useFakeTimers(); try { fireEvent.click(stop); // Synchronous queries only: waitFor-style finds hang under fake timers. expect(screen.getByRole("button", { name: "Confirm stop session s1" })).toBeTruthy(); act(() => { vi.advanceTimersByTime(3100); }); expect(screen.getByRole("button", { name: "Stop session s1" })).toBeTruthy(); expect(h.fetches.some((f) => f.url === "/bsk-observation/stop")).toBe(false); } finally { vi.useRealTimers(); } }); it("resizes within min/max clamps", async () => { const h = makeHarness([BUSY]); render(); const card = await screen.findByTestId("obs-card"); card.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, width: 320, height: 240, right: 320, bottom: 240, toJSON: () => {}, }) as DOMRect; expect(Number.parseFloat(card.style.width)).toBe(320); fireEvent.pointerDown(screen.getByTestId("obs-resize-se"), { clientX: 100, clientY: 100 }); fireEvent.pointerMove(document, { clientX: 0, clientY: 0 }); fireEvent.pointerUp(document); expect(Number.parseFloat(card.style.width)).toBe(240); // min width clamp fireEvent.pointerDown(screen.getByTestId("obs-resize-se"), { clientX: 100, clientY: 100 }); fireEvent.pointerMove(document, { clientX: 5000, clientY: 5000 }); fireEvent.pointerUp(document); expect(Number.parseFloat(card.style.width)).toBe(window.innerWidth * 0.8); }); it("exposes an invisible resize hit target on every corner", async () => { const h = makeHarness([BUSY]); render(); await screen.findByTestId("obs-card"); for (const corner of ["nw", "ne", "sw", "se"]) { expect(screen.getByTestId(`obs-resize-${corner}`)).toBeTruthy(); } }); it("moves the card with header drags, clamped to the viewport", async () => { const h = makeHarness([BUSY]); render(); const card = await screen.findByTestId("obs-card"); fireEvent.pointerDown(screen.getByTestId("obs-header"), { clientX: 50, clientY: 50 }); fireEvent.pointerMove(document, { clientX: -500, clientY: -500 }); fireEvent.pointerUp(document); expect(card.style.left).toBe("0px"); expect(card.style.top).toBe("0px"); }); it("hides Pop out when Document PiP is unsupported", async () => { const h = makeHarness([BUSY]); render(); await screen.findByText(/s1 · clicking/); expect(screen.queryByRole("button", { name: /Pop out/ })).toBeNull(); }); it("pops out with the card's current size and falls back on pagehide", async () => { const h = makeHarness([BUSY]); const pipDoc = document.implementation.createHTMLDocument("pip"); const listeners = new Map void)[]>(); const requestWindow = vi.fn(async (options?: { width?: number; height?: number }) => { void options; return { document: pipDoc, addEventListener: (name: string, fn: () => void) => { listeners.set(name, [...(listeners.get(name) ?? []), fn]); }, close: vi.fn(), } as unknown as Window; }); (window as unknown as Record).documentPictureInPicture = { requestWindow }; render(); const popout = await screen.findByRole("button", { name: /Pop out/ }); fireEvent.click(popout); await waitFor(() => expect(requestWindow).toHaveBeenCalledWith({ width: 320, height: 240 })); // Content now renders inside the PiP document. await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); // Closing the PiP returns to the in-page card with state preserved. for (const fn of listeners.get("pagehide") ?? []) fn(); await screen.findByTestId("obs-card"); }); it("closes the PiP window when the carrier unmounts", async () => { const h = makeHarness([BUSY]); const pipDoc = document.implementation.createHTMLDocument("pip"); const close = vi.fn(); const requestWindow = vi.fn(async () => { return { document: pipDoc, addEventListener: () => {}, close, } as unknown as Window; }); (window as unknown as Record).documentPictureInPicture = { requestWindow }; const { unmount } = render(); const popout = await screen.findByRole("button", { name: /Pop out/ }); fireEvent.click(popout); await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); // Unmounting (tab close, conversation switch, plugin HMR) closes the PiP // instead of leaving a blank window behind. unmount(); expect(close).toHaveBeenCalledTimes(1); }); it("shows a close button inside the PiP window that closes it", async () => { const h = makeHarness([BUSY]); const pipDoc = document.implementation.createHTMLDocument("pip"); const close = vi.fn(); const listeners = new Map void)[]>(); const requestWindow = vi.fn(async () => { return { document: pipDoc, addEventListener: (name: string, fn: () => void) => { listeners.set(name, [...(listeners.get(name) ?? []), fn]); }, close, } as unknown as Window; }); (window as unknown as Record).documentPictureInPicture = { requestWindow }; render(); const popout = await screen.findByRole("button", { name: /Pop out/ }); fireEvent.click(popout); await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); const closeBtn = pipDoc.body.querySelector( 'button[aria-label="Close mini window"]', ); expect(closeBtn).not.toBeNull(); closeBtn!.click(); expect(close).toHaveBeenCalledTimes(1); }); it("renders the strip for two sessions and pins focus on click", async () => { const older: SessionObservation = { sessionId: "s1", action: "idle", since: Date.now() - 60000, }; const newer: SessionObservation = { sessionId: "s2", action: "clicking", since: Date.now() }; const h = makeHarness([older, newer]); render(); // Auto-follows the most recently active session. await screen.findByText(/s2 · clicking/); const strip = screen.getByTestId("obs-strip"); expect(strip.textContent).toContain("s1"); expect(strip.textContent).toContain("s2"); // Pin s1: focus moves and stays even when s2 gets newer activity. fireEvent.click(screen.getByRole("button", { name: "Pin session s1" })); await screen.findByText(/s1 · idle/); h.push([{ ...newer, since: Date.now() + 5000, action: "filling" }]); await waitFor(() => expect(screen.queryByText(/s2 · filling/)).toBeNull()); expect(screen.getByText(/s1 · idle/)).toBeTruthy(); // Unpin: auto-follow resumes. fireEvent.click(screen.getByRole("button", { name: "Unpin session s1" })); await screen.findByText(/s2 · filling/); }); it("flags an errored strip item without stealing focus", async () => { const s1: SessionObservation = { sessionId: "s1", action: "idle", since: Date.now() - 30000 }; const s2: SessionObservation = { sessionId: "s2", action: "idle", since: Date.now() - 10000 }; const h = makeHarness([s1, s2]); render(); await screen.findByText(/s2 · idle/); // s2's latest action failed: red edge on its strip item, focus stays on s2 already… // now push an even-more-recent ERROR for s1 — focus must NOT jump to it. h.push([ { ...s2, since: Date.now() - 5000 }, { ...s1, since: Date.now(), lastError: "click failed" }, ]); await waitFor(() => expect( screen.getByRole("button", { name: "Pin session s1" }).closest("[data-state]"), ).toHaveProperty("dataset", expect.objectContaining({ state: "error" })), ); expect(screen.getByText(/s2 · idle/)).toBeTruthy(); }); it("shows browser-unavailable with a kept frame and a greyed interrupt", async () => { const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]); render(); await waitFor(() => expect(h.loadImage).toHaveBeenCalledWith("att-1")); await screen.findByRole("img", { name: "session s1 view" }); h.emitRaw({ type: "availability", available: false }); await screen.findByText("browser unavailable"); // Last frame stays on stage; interrupt is greyed. expect(screen.getByRole("img", { name: "session s1 view" })).toBeTruthy(); expect(screen.getByRole("button", { name: /Interrupt the current/ })).toHaveProperty( "disabled", true, ); }); it("greys out a dead session in the strip and skips it for focus", async () => { const s1: SessionObservation = { sessionId: "s1", action: "idle", since: Date.now() - 20000 }; const s2: SessionObservation = { sessionId: "s2", action: "idle", since: Date.now() - 10000 }; const h = makeHarness([s1, s2]); render(); await screen.findByText(/s2 · idle/); h.push([{ ...s1, dead: true, since: Date.now() }, s2]); await waitFor(() => expect( screen.getByRole("button", { name: "Pin session s1" }).closest("[data-state]"), ).toHaveProperty("dataset", expect.objectContaining({ state: "dead" })), ); // Dead sessions never take the stage. expect(screen.getByText(/s2 · idle/)).toBeTruthy(); }); it("interrupts a strip session directly on hover without focusing it", async () => { const s1: SessionObservation = { sessionId: "s1", action: "clicking", since: Date.now() - 30000, }; const s2: SessionObservation = { sessionId: "s2", action: "idle", since: Date.now() }; const h = makeHarness([s1, s2]); render(); await screen.findByText(/s2 · idle/); const item = screen.getByRole("button", { name: "Pin session s1" }).closest("div"); if (item === null) throw new Error("no strip item"); fireEvent.pointerEnter(item); const mini = await screen.findByRole("button", { name: "Interrupt session s1" }); fireEvent.click(mini); await waitFor(() => expect( h.fetches.some( (f) => f.url === "/bsk-observation/interrupt" && f.init?.body === '{"sessionId":"s1"}', ), ).toBe(true), ); // Focus did not move to s1. expect(screen.getByText(/s2 · idle/)).toBeTruthy(); }); }); describe("applyResize", () => { const base = { x: 200, y: 100, w: 320, h: 240 }; const viewport = { w: 1000, h: 800 }; it("grows the planted opposite corner from each handle", () => { expect(applyResize(base, "se", 20, 10, viewport)).toEqual({ pos: { x: 200, y: 100 }, size: { w: 340, h: 250 }, }); expect(applyResize(base, "nw", -20, -10, viewport)).toEqual({ pos: { x: 180, y: 90 }, size: { w: 340, h: 250 }, }); expect(applyResize(base, "ne", 20, -10, viewport)).toEqual({ pos: { x: 200, y: 90 }, size: { w: 340, h: 250 }, }); expect(applyResize(base, "sw", -20, 10, viewport)).toEqual({ pos: { x: 180, y: 100 }, size: { w: 340, h: 250 }, }); }); it("clamps to the minimum size without drifting the planted corner", () => { expect(applyResize(base, "se", -200, -200, viewport)).toEqual({ pos: { x: 200, y: 100 }, size: { w: 240, h: 180 }, }); expect(applyResize(base, "nw", 200, 200, viewport)).toEqual({ pos: { x: 280, y: 160 }, size: { w: 240, h: 180 }, }); }); });