Files
BrowserSkill/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx
T
hZsFN 7bf0cdf5a0 test(client): cover observationTabOpen without a right-column tree
Fixes the typecheck failure introduced by marking `splits` optional: withTabOpened keeps a missing `splits` as-is instead of feeding it to the walker, and a regression test asserts observationTabOpen does not dereference an absent right-column tree (DSH 0.1.5 / better-sidebar 0.19+).
2026-09-11 17:57:28 +08:00

348 lines
13 KiB
TypeScript

// @vitest-environment happy-dom
// better-sidebar carrier: tab registration + auto-open, the sidebar-mode
// flag hiding the floating overlay, PiP pop-out from the tab, and the
// store's refcounted acquire/release across overlapping carriers.
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ObservationOverlay } from "../../src/client/ObservationOverlay";
import {
type BetterSidebarLike,
OBSERVATION_TAB_PATH,
OBSERVATION_TAB_TYPE,
ObservationSidebarTab,
observationTabOpen,
registerObservationSidebar,
type SidebarNodeLike,
type SidebarStateLike,
type TabDescriptorLike,
} from "../../src/client/observation-sidebar";
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
import { getSidebarMode, setSidebarMode } from "../../src/client/sidebar-mode";
import type { SessionObservation } from "../../src/observation";
// Owned by the sidebar mock's active conversation ("conv-1") so the scoped
// visibility logic lets it through; FOREIGN belongs to another conversation.
const BUSY: SessionObservation = {
sessionId: "s1",
action: "clicking",
since: Date.now() - 7000,
dshSessionIds: ["conv-1"],
};
const FOREIGN: SessionObservation = {
sessionId: "s9",
action: "clicking",
since: Date.now() - 5000,
dshSessionIds: ["other-conv"],
};
interface Harness {
store: ObservationClientStore;
es: () => EventSourceLike | undefined;
push: (sessions: SessionObservation[]) => void;
}
function makeHarness(initial: SessionObservation[]): Harness {
let es: EventSourceLike | undefined;
let current = initial;
const store = new ObservationClientStore({
fetchFn: async (url: string) => {
if (url === "/bsk-observation/state") {
return { ok: true, json: async () => ({ sessions: current }) };
}
return { ok: true, json: async () => ({ interrupted: true }) };
},
eventSourceFactory: () => {
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: async (id: string) => `blob:${id}`,
});
return {
store,
es: () => 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" }) });
},
};
}
function leafWith(type: string, panelOpen = true): SidebarStateLike {
return {
panelOpen,
splits: {
kind: "split",
id: "root",
dir: "row",
sizes: [1],
children: [
{
kind: "leaf",
id: "pane-1",
active: type,
tabs: [{ id: type, type, title: type }],
},
],
},
bottomSplits: { kind: "leaf", id: "pane-2", active: null, tabs: [] },
};
}
interface SidebarMock {
service: BetterSidebarLike;
descriptor: () => TabDescriptorLike;
openTab: ReturnType<typeof vi.fn>;
disposeTab: ReturnType<typeof vi.fn>;
setState: (state: SidebarStateLike | undefined) => void;
}
/** Insert a tab into the first leaf of the main tree (mirrors a real open). */
function withTabOpened(state: SidebarStateLike, type: string): SidebarStateLike {
let done = false;
const walk = (node: SidebarNodeLike): SidebarNodeLike => {
if (done) return node;
if (node.kind === "leaf") {
done = true;
return { ...node, active: type, tabs: [...node.tabs, { id: type, type, title: type }] };
}
return { ...node, children: node.children.map(walk) };
};
return { ...state, splits: state.splits === undefined ? undefined : walk(state.splits) };
}
function makeSidebar(
initialState: SidebarStateLike | undefined = leafWith("explorer"),
): SidebarMock {
let descriptor: TabDescriptorLike | undefined;
let state: SidebarStateLike | undefined = initialState;
const openTab = vi.fn((seed: { type: string }) => {
// Mirror the real feedback loop: a created tab lands in the state, so
// later evaluations see it open (single-instance dedupe re-focuses).
if (state !== undefined && !observationTabOpen(state)) {
state = withTabOpened(state, seed.type);
}
});
const disposeTab = vi.fn();
const service: BetterSidebarLike = {
registerTab: (next) => {
descriptor = next;
return disposeTab;
},
openTab,
getSnapshot: () => ({ sessionId: "conv-1", state }),
isTabEnabled: () => true,
};
return {
service,
descriptor: () => {
if (descriptor === undefined) throw new Error("no tab registered");
return descriptor;
},
openTab,
disposeTab,
setState: (next) => {
state = next;
},
};
}
afterEach(() => {
cleanup();
setSidebarMode(false);
});
describe("registerObservationSidebar", () => {
it("registers a single-instance tab and holds the feed for its lifetime", () => {
const h = makeHarness([]);
const sidebar = makeSidebar();
expect(getSidebarMode()).toBe(false);
const dispose = registerObservationSidebar(sidebar.service, h.store);
expect(getSidebarMode()).toBe(true);
// The fiber holds the observation feed itself, so the auto-open watcher
// sees new sessions even while the tab is closed.
expect(h.es()).toBeDefined();
const descriptor = sidebar.descriptor();
expect(descriptor.id).toBe(OBSERVATION_TAB_TYPE);
expect(descriptor.single).toBe(true);
expect(descriptor.title).toBe("Browser Skill");
dispose();
expect(getSidebarMode()).toBe(false);
expect(sidebar.disposeTab).toHaveBeenCalledTimes(1);
expect(h.es()?.close).toHaveBeenCalled?.();
});
it("opens the tab right away when a session is already live at activation", async () => {
const h = makeHarness([BUSY]);
const sidebar = makeSidebar();
registerObservationSidebar(sidebar.service, h.store);
// The initial state fetch lands asynchronously; the 0→N watcher then fires.
await waitFor(() =>
expect(sidebar.openTab).toHaveBeenCalledWith({
type: OBSERVATION_TAB_TYPE,
path: OBSERVATION_TAB_PATH,
}),
);
});
it("auto-opens on the first session but never re-focuses an open tab", async () => {
const h = makeHarness([]);
const sidebar = makeSidebar();
registerObservationSidebar(sidebar.service, h.store);
expect(sidebar.openTab).not.toHaveBeenCalled();
// First session arrives: the tab lands in the sidebar.
h.push([BUSY]);
await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1));
// The sidebar now reports the tab open (persisted/restored): further
// session arrivals must not steal focus from whatever the user reads.
sidebar.setState(leafWith(OBSERVATION_TAB_TYPE));
h.push([]);
h.push([{ ...BUSY, sessionId: "s2" }]);
h.push([]);
h.push([{ ...BUSY, sessionId: "s3" }]);
await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1));
expect(sidebar.openTab).toHaveBeenCalledTimes(1);
});
it("does not open while the sidebar has no active session state", () => {
const h = makeHarness([BUSY]);
const sidebar = makeSidebar(undefined);
registerObservationSidebar(sidebar.service, h.store);
expect(sidebar.openTab).not.toHaveBeenCalled();
});
it("nudges an existing tab back into sight when the panel is collapsed", async () => {
const h = makeHarness([]);
// Tab already open (persisted), but the panel is collapsed: a new
// session should surface the tracking view again (focus + expand via a
// content open), like the floating card reappearing.
const sidebar = makeSidebar(leafWith(OBSERVATION_TAB_TYPE, false));
registerObservationSidebar(sidebar.service, h.store);
h.push([BUSY]);
await waitFor(() =>
expect(sidebar.openTab).toHaveBeenCalledWith({
type: OBSERVATION_TAB_TYPE,
path: OBSERVATION_TAB_PATH,
}),
);
});
it("shows the visible session count as the tab badge", () => {
const h = makeHarness([]);
const sidebar = makeSidebar();
registerObservationSidebar(sidebar.service, h.store);
const badge = sidebar.descriptor().badge;
const scope = { sessionId: "conv-1" };
expect(badge?.(undefined, scope, undefined)).toBeNull();
h.store.acquire();
h.push([BUSY, FOREIGN]);
expect(badge?.(undefined, scope, undefined)).toBe(1);
expect(badge?.(undefined, { sessionId: "other-conv" }, undefined)).toBe(1);
expect(badge?.(undefined, { sessionId: "nobody" }, undefined)).toBeNull();
h.store.release();
});
it("does not auto-open for sessions owned by other conversations", async () => {
const h = makeHarness([]);
const sidebar = makeSidebar();
registerObservationSidebar(sidebar.service, h.store);
h.push([FOREIGN]);
await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1));
expect(sidebar.openTab).not.toHaveBeenCalled();
// A session visible to the active conversation does open the tab.
h.push([BUSY]);
await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1));
});
});
describe("observationTabOpen", () => {
it("finds the tab in either workbench tree", () => {
expect(observationTabOpen(undefined)).toBe(false);
expect(observationTabOpen(leafWith("explorer"))).toBe(false);
expect(observationTabOpen(leafWith(OBSERVATION_TAB_TYPE))).toBe(true);
const inBottom: SidebarStateLike = {
splits: { kind: "leaf", id: "p1", active: null, tabs: [] },
bottomSplits: {
kind: "leaf",
id: "p2",
active: OBSERVATION_TAB_TYPE,
tabs: [{ id: OBSERVATION_TAB_TYPE, type: OBSERVATION_TAB_TYPE, title: "Browser" }],
},
};
expect(observationTabOpen(inBottom)).toBe(true);
});
it("does not crash when the right-column tree is absent (DSH 0.1.5+)", () => {
// better-sidebar 0.19+ no longer emits `splits` (the right column moved to
// dsh's native Sidebar); the walk must skip absent roots, not dereference.
const noSplits: SidebarStateLike = {
bottomSplits: { kind: "leaf", id: "p2", active: null, tabs: [] },
};
expect(() => observationTabOpen(noSplits)).not.toThrow();
expect(observationTabOpen(noSplits)).toBe(false);
});
});
describe("ObservationSidebarTab", () => {
it("renders the tracking view without card chrome (no collapse, no drag header)", async () => {
const h = makeHarness([BUSY]);
render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
await screen.findByText(/s1 · clicking/);
expect(screen.queryByRole("button", { name: "Collapse" })).toBeNull();
expect(screen.getByTestId("obs-header").dataset.draggable).toBeUndefined();
// No resize handles — the sidebar owns the geometry.
expect(screen.queryByTestId("obs-resize-se")).toBeNull();
// Session controls ride along: interrupt + stop-with-confirm.
expect(screen.getByRole("button", { name: "Stop session s1" })).toBeTruthy();
});
it("scopes the view to the sidebar's conversation", async () => {
const h = makeHarness([BUSY, FOREIGN]);
render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
// Focus and content belong to the owned session; the foreign one never
// reaches the stage, the status line, or the strip (which needs 2+).
await screen.findByText(/s1 · clicking/);
expect(screen.queryByText(/s9/)).toBeNull();
expect(screen.queryByTestId("obs-strip")).toBeNull();
// Scoping to the other conversation swaps what's visible.
render(<ObservationSidebarTab store={h.store} scopeId="other-conv" />);
await screen.findByText(/s9 · clicking/);
});
});
describe("carrier switching", () => {
it("hides the floating overlay while sidebar mode is active", async () => {
const h = makeHarness([]);
const { container } = render(<ObservationOverlay store={h.store} />);
h.push([BUSY]);
await screen.findByTestId("obs-card");
setSidebarMode(true);
await waitFor(() => expect(container.firstChild).toBeNull());
setSidebarMode(false);
await screen.findByTestId("obs-card");
});
it("keeps the feed alive across overlapping carriers (refcount)", async () => {
const h = makeHarness([]);
const overlay = render(<ObservationOverlay store={h.store} />);
await waitFor(() => expect(h.es()).toBeDefined());
// A second carrier mounts (the sidebar tab), then the overlay unmounts:
// the stream must survive for the remaining consumer.
const tab = render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
overlay.unmount();
h.push([BUSY]);
await screen.findByText(/s1 · clicking/);
expect(h.es()?.close ?? vi.fn()).not.toHaveBeenCalled();
tab.unmount();
});
});