Files
BrowserSkill/packages/dsh-plugin-browserskill/tests/dispose.test.ts
T
BB-fat a7bc8bacf6 feat(dsh-plugin-browserskill): host-side ObservationService for the PiP overlay (Phase 1-A)
Per-session live observation for owned sessions only, feeding the
upcoming client overlay:

- State model: SessionObservation (sessionId/url/action/since/
  thumbnailAttachmentId/lastError), one entry per owned session;
  added on start, removed on stop, cleared on dispose.
- Instrumentation: the shared runBsk wrapper begins/ends an action
  (tool name mapped to a verb) around every model-facing call and tags
  the child with its session id; action end triggers an immediate
  thumbnail refresh.
- Thumbnail loop: 1.5s cadence while active, 8s idle downclock, x3
  failure backoff, silent frame retention on errors; captures run
  OUTSIDE the instrumentation (no action events, no current-pointer
  movement) and land in the attachment store by reference.
- Interrupt: runner.killFor(tag) kills exactly the tagged in-flight bsk
  children — chat-Stop-equivalent semantics for the current or a named
  owned session; foreign ids and the no-in-flight case return false.
- Remote seam: dsh 0.1's Typert Remote pipeline and forwarded-event
  allowlist are closed to out-of-tree packages, so state/events/
  interrupt are served over the documented webServer route seam:
  GET /bsk-observation/state, GET /bsk-observation/events (SSE),
  POST /bsk-observation/interrupt; routes mount only when a webServer
  service exists (headless compositions skip them).
- Config: observationEnabled (true), thumbnailIntervalMs (1500),
  idleIntervalMs (8000).

Tests (+13, 72 total): state machine + event stream, cadence fast/idle/
backoff, headless no-store silence, observation-traffic isolation,
interrupt routing (default/specified/none/foreign), HTTP route
handlers, disabled-webServer no-op.
2026-08-14 04:59:26 +00:00

93 lines
3.3 KiB
TypeScript

// Unload-cleanup ownership discipline: dispose must stop exactly the sessions
// this plugin created — never referenced/foreign sessions on the shared
// daemon — and a stale handle (already stopped elsewhere, daemon restarted)
// must not abort the remaining stops.
import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
import { describe, expect, it } from "vitest";
import { apply } from "../src/index";
import type { BskRunOptions, BskRunResult } from "../src/runner";
interface FakeCall {
args: string[];
}
function ok(payload: unknown): BskRunResult {
return { code: 0, stdout: JSON.stringify(payload), stderr: "", timedOut: false, aborted: false };
}
function fail(message: string): BskRunResult {
return {
code: 4,
stdout: JSON.stringify({ code: "session_not_found", message }),
stderr: "",
timedOut: false,
aborted: false,
};
}
function makeExec(): ToolRunContext {
return {
callId: "t",
name: "t",
signal: new AbortController().signal,
} as unknown as ToolRunContext;
}
describe("dispose cleanup ownership", () => {
it("stops only plugin-owned sessions and tolerates stale handles", async () => {
const calls: FakeCall[] = [];
const startReplies = [
{ session_id: "own1", browser_instance_id: "b1" },
{ session_id: "own2", browser_instance_id: "b1" },
];
const runner = {
async run(args: string[], _options: BskRunOptions = {}): Promise<BskRunResult> {
calls.push({ args });
const joined = args.join(" ");
if (joined.startsWith("status")) return ok({});
if (joined.startsWith("session start")) return ok(startReplies.shift());
if (joined.startsWith("snapshot")) {
return ok({ text: "x", ref_count: 1, tab_id: 7, truncated: false });
}
if (joined === "session stop own1") return ok({});
if (joined === "session stop own2") {
// Stale handle: already stopped by someone else / daemon restarted.
return fail("session not found");
}
return fail(`unexpected command: ${joined}`);
},
killAll() {},
killFor: () => 0,
};
const tools = new Map<string, ToolDefinition>();
const disposers: Array<() => Promise<void>> = [];
const ctx = {
tools: { register: (def: ToolDefinition) => tools.set(def.name, def) },
get: () => undefined,
effect: (fn: () => () => Promise<void>) => {
disposers.push(fn());
},
};
apply(ctx as never, { maxSessions: 5 }, { runnerFactory: () => runner });
const start = tools.get("browser_session_start");
const snapshot = tools.get("browser_snapshot");
if (start === undefined || snapshot === undefined) throw new Error("tools not registered");
await start.execute({}, makeExec());
await start.execute({}, makeExec());
// A foreign session cannot even be referenced any more.
await expect(snapshot.execute({ session: "ext9" }, makeExec())).rejects.toThrow(
/does not belong to this plugin/,
);
await Promise.all(disposers.map((dispose) => dispose()));
const stops = calls
.map((call) => call.args.join(" "))
.filter((joined) => joined.startsWith("session stop"));
expect(stops.sort()).toEqual(["session stop own1", "session stop own2"]);
expect(stops.some((joined) => joined.includes("ext9"))).toBe(false);
});
});