Files

594 lines
25 KiB
TypeScript

// @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<typeof vi.fn>;
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<typeof vi.fn>;
}> = [];
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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<string, unknown>).documentPictureInPicture = {
requestWindow: async () => ({ document: pipDoc, addEventListener: () => {}, close: vi.fn() }),
};
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
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<string, unknown>).documentPictureInPicture;
});
it("renders nothing with no sessions, appears when one starts", async () => {
const h = makeHarness([]);
const { container } = render(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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<string>((resolve) => {
resolveNext = resolve;
});
}
return `blob:${id}`;
});
render(<ObservationOverlay store={h.store} />);
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 <img> 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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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<string, (() => 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<string, unknown>).documentPictureInPicture = { requestWindow };
render(<ObservationOverlay store={h.store} />);
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<string, unknown>).documentPictureInPicture = { requestWindow };
const { unmount } = render(<ObservationOverlay store={h.store} />);
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<string, (() => 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<string, unknown>).documentPictureInPicture = { requestWindow };
render(<ObservationOverlay store={h.store} />);
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<HTMLButtonElement>(
'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(<ObservationOverlay store={h.store} />);
// 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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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 },
});
});
});