mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Client half of the PiP observation window, wired to the host ObservationService over the /bsk-observation HTTP+SSE seam: - ObservationClientStore: initial state fetch + SSE increments + on-demand thumbnail blob loading (session-authorized readAttachment, same path as the toolview) + interrupt POST. All I/O injectable. - ObservationOverlay registered into the shell.overlay list seat (the sanctioned frame-wide surface; root is off-limits). Hidden with no owned sessions; appears on first start; vanishes when all stop. - Focus view: status row (green/grey/red dot + session + action + mm:ss ticking), breathing thumbnail (new frames fade in, failures keep the last frame with a warning badge, pre-navigate placeholder), and the action area. - Interrupt: single click, no confirm; greys into 'Interrupting…' and back; disabled when nothing is in flight; one-time tooltip explains the semantics (stops the current action only; the run continues). - Card: drag-move via the header, corner-handle resize (min 240x180, max 80% viewport), both clamped and kept for the page lifetime; collapsible status capsule. - PiP: Pop out (user gesture) opens Document PiP with the card's current size, portals the same content in, and clones the document's style nodes; pagehide falls back to the card with state intact; unsupported browsers hide the button; nothing auto-pops. Tests (+17, 89 total): store (fetch/SSE apply/malformed frames/ thumbnail lifecycle/interrupt wire/stop cleanup) and overlay (hidden-> visible lifecycle, status row, thumbnail via loader, interrupt disabled/active/hint-once, capsule, resize clamps, move clamps, PiP unsupported, PiP pop/fallback). Styling follows web-styling (dsw-alias tokens, CSS Modules, focus-visible, reduced-motion).
187 lines
7.8 KiB
TypeScript
187 lines
7.8 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// ObservationOverlay: visibility lifecycle, focus view, interrupt interaction,
|
|
// one-time hint, drag move/resize 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 { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { 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;
|
|
fetches: { url: string; init?: { body?: string } }[];
|
|
loadImage: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function makeHarness(initial: SessionObservation[]): Harness {
|
|
const fetches: Harness["fetches"] = [];
|
|
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: () => {
|
|
es = { onmessage: null, close: vi.fn() };
|
|
return es;
|
|
},
|
|
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" }) });
|
|
},
|
|
fetches,
|
|
loadImage,
|
|
};
|
|
}
|
|
|
|
afterEach(cleanup);
|
|
|
|
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("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 the semantics hint once", async () => {
|
|
const h = makeHarness([BUSY]);
|
|
render(<ObservationOverlay store={h.store} />);
|
|
const button = await screen.findByRole("button", { name: /Interrupt the current/ });
|
|
fireEvent.pointerEnter(button);
|
|
await screen.findByRole("tooltip");
|
|
fireEvent.pointerLeave(button);
|
|
fireEvent.click(button);
|
|
fireEvent.pointerEnter(button);
|
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
});
|
|
|
|
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("resizes within min/max clamps", async () => {
|
|
const h = makeHarness([BUSY]);
|
|
render(<ObservationOverlay store={h.store} />);
|
|
const card = await screen.findByTestId("obs-card");
|
|
expect(Number.parseFloat(card.style.width)).toBe(320);
|
|
fireEvent.pointerDown(screen.getByTestId("obs-resize"), { clientX: 100, clientY: 100 });
|
|
fireEvent.pointerMove(document, { clientX: 40, clientY: 40 });
|
|
fireEvent.pointerUp(document);
|
|
expect(Number.parseFloat(card.style.width)).toBe(240); // min width clamp
|
|
fireEvent.pointerDown(screen.getByTestId("obs-resize"), { 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("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]);
|
|
},
|
|
} 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");
|
|
});
|
|
});
|