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).
114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
// ObservationClientStore: initial fetch + SSE application, thumbnail loading
|
|
// lifecycle, interrupt wire shape. All I/O faked.
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
|
import type { ObservationEvent, SessionObservation } from "../../src/observation";
|
|
|
|
const OBS_IDLE: SessionObservation = { sessionId: "s1", action: "idle", since: 1000 };
|
|
const OBS_BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: 2000 };
|
|
|
|
function harness(state: SessionObservation[] = []) {
|
|
const fetches: { url: string; init?: { method?: string; body?: string } }[] = [];
|
|
const esInstances: EventSourceLike[] = [];
|
|
const fetchFn = vi.fn(async (url: string, init?: { method?: string; body?: string }) => {
|
|
fetches.push({ url, init });
|
|
if (url === "/bsk-observation/state") {
|
|
return { ok: true, json: async () => ({ sessions: state }) };
|
|
}
|
|
return { ok: true, json: async () => ({ interrupted: true }) };
|
|
});
|
|
const eventSourceFactory = (url: string) => {
|
|
const es: EventSourceLike = { onmessage: null, close: vi.fn() };
|
|
esInstances.push(es);
|
|
return es;
|
|
};
|
|
const loadImage = vi.fn(async (id: string) => `blob:url-${id}`);
|
|
const store = new ObservationClientStore({ fetchFn, eventSourceFactory, loadImage });
|
|
return { store, fetches, esInstances, loadImage };
|
|
}
|
|
|
|
function emit(es: EventSourceLike, event: ObservationEvent): void {
|
|
es.onmessage?.({ data: JSON.stringify(event) });
|
|
}
|
|
|
|
describe("ObservationClientStore", () => {
|
|
it("loads the initial state and applies SSE increments", async () => {
|
|
const { store, esInstances } = harness([OBS_IDLE]);
|
|
const seen: number[] = [];
|
|
store.subscribe(() => seen.push(store.getSnapshot().sessions.length));
|
|
store.start();
|
|
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
|
|
expect(esInstances).toHaveLength(1);
|
|
emit(esInstances[0], { type: "upsert", session: OBS_BUSY });
|
|
expect(store.getSnapshot().sessions[0].action).toBe("clicking");
|
|
emit(esInstances[0], {
|
|
type: "upsert",
|
|
session: { sessionId: "s2", action: "idle", since: 3 },
|
|
});
|
|
expect(store.getSnapshot().sessions).toHaveLength(2);
|
|
emit(esInstances[0], {
|
|
type: "remove",
|
|
session: { sessionId: "s2", action: "idle", since: 0 },
|
|
});
|
|
expect(store.getSnapshot().sessions).toHaveLength(1);
|
|
emit(esInstances[0], { type: "reset" });
|
|
expect(store.getSnapshot().sessions).toHaveLength(0);
|
|
expect(seen.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("ignores malformed SSE frames", () => {
|
|
const { store, esInstances } = harness([]);
|
|
store.start();
|
|
esInstances[0].onmessage?.({ data: "not json" });
|
|
expect(store.getSnapshot().sessions).toHaveLength(0);
|
|
});
|
|
|
|
it("loads each thumbnail once and reports ready", async () => {
|
|
const { store, loadImage } = harness();
|
|
store.ensureThumbnail("att-1");
|
|
store.ensureThumbnail("att-1");
|
|
expect(loadImage).toHaveBeenCalledTimes(1);
|
|
await vi.waitFor(() =>
|
|
expect(store.getSnapshot().thumbnails["att-1"]).toEqual({
|
|
status: "ready",
|
|
url: "blob:url-att-1",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("marks failed thumbnail loads as error", async () => {
|
|
const { store, loadImage } = harness();
|
|
loadImage.mockRejectedValueOnce(new Error("nope"));
|
|
store.ensureThumbnail("att-bad");
|
|
await vi.waitFor(() => expect(store.getSnapshot().thumbnails["att-bad"].status).toBe("error"));
|
|
});
|
|
|
|
it("posts interrupt with and without a session id", async () => {
|
|
const { store, fetches } = harness();
|
|
expect(await store.interrupt()).toBe(true);
|
|
expect(await store.interrupt("s1")).toBe(true);
|
|
const calls = fetches.filter((f) => f.url === "/bsk-observation/interrupt");
|
|
expect(JSON.parse(calls[0].init?.body ?? "")).toEqual({});
|
|
expect(JSON.parse(calls[1].init?.body ?? "")).toEqual({ sessionId: "s1" });
|
|
});
|
|
|
|
it("returns false when interrupt fails", async () => {
|
|
const failing = new ObservationClientStore({
|
|
fetchFn: async () => ({ ok: false, json: async () => ({}) }),
|
|
eventSourceFactory: () => ({ onmessage: null, close: () => {} }),
|
|
loadImage: async () => "blob:x",
|
|
});
|
|
expect(await failing.interrupt("s1")).toBe(false);
|
|
});
|
|
|
|
it("stop closes the stream and clears state", async () => {
|
|
const { store, esInstances } = harness([OBS_IDLE]);
|
|
store.start();
|
|
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
|
|
store.stop();
|
|
expect(esInstances[0].close).toHaveBeenCalled();
|
|
expect(store.getSnapshot().sessions).toHaveLength(0);
|
|
});
|
|
});
|