Files
BrowserSkill/packages/dsh-plugin-browserskill/tests/tools.test.ts
T
BB-fat a4e668542d fix(dsh-plugin-browserskill): address review findings (leaks, trust fence, queue race)
Blocking:
- B1: delete screenshot scratch PNGs — observation frames reuse one fixed
  per-session path and unlink after every read (finally); browser_screenshot
  unlinks once the bytes are in the attachment store (kept only when the file
  itself is the model-facing artifact).
- B2: replicate dsh's browser-trust fence on /bsk-observation/*: loopback
  Host only, Origin must match Host, sec-fetch-site: cross-site refused, POST
  requires application/json; README documents the loopback trust premise and
  the 0.0.0.0 warning.

Major:
- M1: KeyedExecutor tail chains previous+task (allSettled) so a task aborted
  while queued cannot release the next one into the running session.
- M2: client tracks the live frame per session — replacing/removing/reset
  revokes the old blob URL at once; loads settling after replacement never
  resurrect it.

Minor: drop replaced thumbRefs (+on remove); beginAction fires inside the
queue (no label overwrite while queued, no idle flash); half-initialized
session cleanup stops through the queue with one retry; SSE (re)open refetches
/state (+ snapshot flag renamed subscribed); install probe uses --version (no
daemon spawn); emulate mobile documents the width+height requirement (the
daemon refuses it alone — verified).

Nit: single BskRunOptions declaration; tails map entry dropped on drain; SSE
cleanup on res close; popOut catches requestWindow rejections; resize handle
gains keyboard control (arrows, 16px steps, aria value attrs).

Tests: +14 (fence rules, scratch lifecycle, queue race, blob revocation,
instrumentation timing, SSE resync). 113/113 green.
2026-08-14 11:56:02 +00:00

801 lines
28 KiB
TypeScript

import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
import { describe, expect, it, vi } from "vitest";
import { ObservationService } from "../src/observation";
import { KeyedExecutor } from "../src/queue";
import type { BskRunner, BskRunOptions, BskRunResult } from "../src/runner";
import { SessionRegistry } from "../src/sessions";
import { type PluginConfig, registerTools } from "../src/tools";
const CONFIG: PluginConfig = {
bskPath: "bsk",
defaultTimeoutMs: 120_000,
maxSessions: 5,
observationEnabled: false,
thumbnailIntervalMs: 1500,
idleIntervalMs: 8000,
};
/** An observation service with the feature off: instrumentation becomes a no-op. */
function disabledObservation(deps: { ctx: unknown; runner: BskRunner; registry: SessionRegistry }) {
return new ObservationService({
ctx: deps.ctx as never,
runner: deps.runner,
registry: deps.registry,
queue: new KeyedExecutor(),
options: { enabled: false, thumbnailIntervalMs: 1500, idleIntervalMs: 8000 },
});
}
interface FakeCall {
args: string[];
options: BskRunOptions;
}
/** Mark a canned answer as a per-call sequence (each call shifts one reply). */
function seq(items: unknown[]): { __seq: unknown[] } {
return { __seq: items };
}
/** A runner that answers canned JSON per command prefix and records calls. */
function fakeRunner(responses: Record<string, unknown>) {
const calls: FakeCall[] = [];
return {
calls,
runner: {
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
calls.push({ args, options });
if (options.signal?.aborted) {
return { code: null, stdout: "", stderr: "", timedOut: false, aborted: true };
}
for (const [prefix, response] of Object.entries(responses)) {
if (args.join(" ").startsWith(prefix)) {
if (response instanceof Error) throw response;
const reply =
typeof response === "object" && response !== null && "__seq" in response
? (response as { __seq: unknown[] }).__seq.shift()
: response;
return {
code: 0,
stdout: JSON.stringify(reply),
stderr: "",
timedOut: false,
aborted: false,
};
}
}
return {
code: 2,
stdout: JSON.stringify({
code: "unexpected",
message: `no canned response for: ${args.join(" ")}`,
}),
stderr: "",
timedOut: false,
aborted: false,
};
},
killAll() {},
killFor: () => 0,
},
};
}
function makeCtx(services: Record<string, unknown> = {}) {
const tools = new Map<string, ToolDefinition>();
const ctx = {
tools: { register: (def: ToolDefinition) => tools.set(def.name, def) },
get: (key: string) => services[key],
};
return { ctx, tools };
}
function makeExec(overrides: Record<string, unknown> = {}): ToolRunContext {
return {
callId: "test-call",
name: "test",
signal: new AbortController().signal,
...overrides,
} as unknown as ToolRunContext;
}
const SNAPSHOT_REPLY = {
text: '- button "OK" [ref=@e1]',
ref_count: 1,
tab_id: 7,
truncated: false,
};
function setup(
responses: Record<string, unknown>,
services: Record<string, unknown> = {},
maxSessions = 5,
) {
const { ctx, tools } = makeCtx(services);
const { runner, calls } = fakeRunner(responses);
const registry = new SessionRegistry(maxSessions);
const config = { ...CONFIG, maxSessions };
registerTools({
ctx: ctx as never,
runner: runner as BskRunner,
registry,
config,
observation: disabledObservation({ ctx, runner: runner as BskRunner, registry }),
queue: new KeyedExecutor(),
});
return { tools, calls, registry };
}
async function startSession(tools: Map<string, ToolDefinition>, sessionId = "s1") {
const tool = tools.get("browser_session_start");
if (tool === undefined) throw new Error("browser_session_start not registered");
return tool.execute({}, makeExec()) as Promise<{ sessionId: string }>;
}
const START_REPLY = (id: string) => ({ session_id: id, browser_instance_id: "chrome-1" });
describe("tool registration", () => {
it("registers the full browser tool suite", () => {
const { tools } = setup({});
expect([...tools.keys()].sort()).toEqual([
"browser_click",
"browser_emulate",
"browser_fill",
"browser_navigate",
"browser_observe",
"browser_press",
"browser_screenshot",
"browser_session_list",
"browser_session_start",
"browser_session_stop",
"browser_snapshot",
]);
});
it("validates model args through defineTool (missing required param)", async () => {
const { tools } = setup({});
const navigate = tools.get("browser_navigate");
await expect(navigate?.execute({}, makeExec())).rejects.toThrow(/invalid arguments/);
});
});
describe("browser_session_start", () => {
it("maps the start reply and tracks the session as current", async () => {
const { tools, registry } = setup({ "session start": START_REPLY("s1") });
const value = await startSession(tools);
expect(value.sessionId).toBe("s1");
expect(registry.current()).toBe("s1");
});
it("passes window size and opens the initial URL and device preset", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
emulate: { tab_id: 7, cleared: false },
navigate: { tab_id: 7, url: "https://example.com", reached: "load" },
});
const tool = tools.get("browser_session_start");
const value = (await tool?.execute(
{ width: 1280, height: 800, url: "https://example.com", device: "iphone-14", noFocus: true },
makeExec(),
)) as { sessionId: string; url: string; device: string };
expect(value).toMatchObject({
sessionId: "s1",
url: "https://example.com",
device: "iphone-14",
});
expect(calls[0].args).toEqual([
"session",
"start",
"--width",
"1280",
"--height",
"800",
"--no-focus",
]);
expect(calls[1].args).toEqual(["emulate", "--session", "s1", "--device", "iphone-14"]);
expect(calls[2].args).toEqual(["navigate", "--session", "s1", "https://example.com"]);
});
it("requires width and height together", async () => {
const { tools } = setup({ "session start": START_REPLY("s1") });
const tool = tools.get("browser_session_start");
await expect(tool?.execute({ width: 1280 }, makeExec())).rejects.toThrow(/together/);
});
it("cleans up a half-initialized session when the initial navigate fails", async () => {
const { tools, calls, registry } = setup({
"session start": START_REPLY("s1"),
"session stop": {},
});
// navigate has no canned response -> non-zero exit -> error.
const tool = tools.get("browser_session_start");
await expect(tool?.execute({ url: "https://example.com" }, makeExec())).rejects.toThrow(
/navigate/,
);
expect(calls.some((c) => c.args.join(" ") === "session stop s1")).toBe(true);
expect(registry.current()).toBeUndefined();
});
});
describe("multi-session behavior", () => {
const responses = {
"session start": START_REPLY("s1"),
snapshot: SNAPSHOT_REPLY,
};
it("operation tools default to the current session and echo it back", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const snapshot = tools.get("browser_snapshot");
const value = (await snapshot?.execute({}, makeExec())) as {
session: string;
refCount: number;
};
expect(value).toMatchObject({ session: "s1", refCount: 1 });
expect(calls[1].args).toEqual(["snapshot", "--session", "s1"]);
});
it("an explicit owned session wins and becomes the new current session", async () => {
const { tools, calls, registry } = setup({
"session start": seq([START_REPLY("s1"), START_REPLY("s2")]),
snapshot: SNAPSHOT_REPLY,
});
await startSession(tools, "s1");
await startSession(tools, "s2");
const snapshot = tools.get("browser_snapshot");
const value = (await snapshot?.execute({ session: "s1" }, makeExec())) as {
session: string;
};
expect(value.session).toBe("s1");
expect(calls[2].args).toEqual(["snapshot", "--session", "s1"]);
expect(registry.current()).toBe("s1");
});
it("rejects an explicit foreign session without touching the daemon", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const callsBefore = calls.length;
const snapshot = tools.get("browser_snapshot");
await expect(snapshot?.execute({ session: "other" }, makeExec())).rejects.toThrow(
/does not belong to this plugin/,
);
expect(calls.length).toBe(callsBefore);
});
it("fails with guidance when no session exists", async () => {
const { tools } = setup(responses);
const snapshot = tools.get("browser_snapshot");
await expect(snapshot?.execute({}, makeExec())).rejects.toThrow(/browser_session_start/);
});
it("enforces the configured session cap before spawning", async () => {
const { tools, calls } = setup(
{ "session start": seq([START_REPLY("s1"), START_REPLY("s2")]) },
{},
1,
);
await startSession(tools);
const start = tools.get("browser_session_start");
await expect(start?.execute({}, makeExec())).rejects.toThrow(/session limit/);
// A rejected start must not spawn another bsk session (no leaked session).
expect(calls.filter((c) => c.args.join(" ").startsWith("session start"))).toHaveLength(1);
});
it("shares the cap atomically across concurrent starts (reservation race)", async () => {
const { tools, calls } = setup(
{ "session start": seq([START_REPLY("s1"), START_REPLY("s2")]) },
{},
1,
);
const start = tools.get("browser_session_start");
if (start === undefined) throw new Error("not registered");
// Two truly concurrent starts against a cap of 1: exactly one may spawn.
const outcomes = await Promise.allSettled([
start.execute({}, makeExec()),
start.execute({}, makeExec()),
]);
const fulfilled = outcomes.filter((o) => o.status === "fulfilled");
const rejected = outcomes.filter((o) => o.status === "rejected");
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
expect(String((rejected[0] as PromiseRejectedResult).reason)).toMatch(/session limit/);
expect(calls.filter((c) => c.args.join(" ").startsWith("session start"))).toHaveLength(1);
});
it("refuses to stop a session the plugin did not create", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const stop = tools.get("browser_session_stop");
await expect(stop?.execute({ session: "ext7" }, makeExec())).rejects.toThrow(
/does not belong to this plugin/,
);
// No stop command may reach the daemon for a foreign session.
expect(calls.some((c) => c.args.join(" ").startsWith("session stop"))).toBe(false);
});
});
describe("browser_session_stop / list", () => {
it("stops the current session and clears the registry", async () => {
const { tools, calls, registry } = setup({
"session start": START_REPLY("s1"),
"session stop": {},
});
await startSession(tools);
const stop = tools.get("browser_session_stop");
const value = (await stop?.execute({}, makeExec())) as { stopped: string };
expect(value.stopped).toBe("s1");
expect(calls[1].args).toEqual(["session", "stop", "s1"]);
expect(registry.current()).toBeUndefined();
});
it("lists only plugin-owned sessions with the current marker (no daemon view)", async () => {
const { tools, calls } = setup({
"session start": seq([START_REPLY("s1"), START_REPLY("s2")]),
});
await startSession(tools);
await startSession(tools);
const list = tools.get("browser_session_list");
const value = (await list?.execute({}, makeExec())) as {
sessions: { sessionId: string; current: boolean }[];
};
expect(value.sessions).toEqual([
{ sessionId: "s1", browserInstanceId: "chrome-1", current: false },
{ sessionId: "s2", browserInstanceId: "chrome-1", current: true },
]);
// Registry-only: listing must not call the daemon at all.
expect(calls.some((c) => c.args.join(" ").startsWith("session list"))).toBe(false);
});
});
describe("interaction tools", () => {
const responses = {
"session start": START_REPLY("s1"),
click: { tab_id: 7, used_ref: "@e1", x: 10, y: 20 },
fill: { tab_id: 7, used_ref: "@e2", value_length: 5 },
press: { tab_id: 7, key: "Enter", code: "Enter", modifiers: [] },
navigate: { tab_id: 7, url: "https://a.test", final_url: "https://b.test", reached: "load" },
emulate: { tab_id: 7, cleared: false },
};
it("browser_click maps target, button, and click count", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const click = tools.get("browser_click");
const value = (await click?.execute(
{ target: "@e1", button: "right", clickCount: 2 },
makeExec(),
)) as { session: string; x: number; y: number };
expect(value).toMatchObject({ session: "s1", x: 10, y: 20 });
expect(calls[1].args).toEqual([
"click",
"--session",
"s1",
"--button",
"right",
"--click-count",
"2",
"@e1",
]);
});
it("browser_fill passes value and --no-clear", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const fill = tools.get("browser_fill");
await fill?.execute({ target: "e2", value: "hello", noClear: true }, makeExec());
expect(calls[1].args).toEqual([
"fill",
"--session",
"s1",
"--value",
"hello",
"--no-clear",
"e2",
]);
});
it("browser_press picks --ref for snapshot refs and --selector otherwise", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const press = tools.get("browser_press");
await press?.execute({ key: "Enter", target: "@e3" }, makeExec());
expect(calls[1].args).toEqual(["press", "--session", "s1", "--ref", "@e3", "Enter"]);
await press?.execute({ key: "Ctrl+A", target: "#field" }, makeExec());
expect(calls[2].args).toEqual(["press", "--session", "s1", "--selector", "#field", "Ctrl+A"]);
});
it("browser_navigate converts waitUntil and timeoutMs", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const navigate = tools.get("browser_navigate");
const value = (await navigate?.execute(
{ url: "https://a.test", waitUntil: "networkidle", timeoutMs: 1500 },
makeExec(),
)) as { finalUrl: string; reached: string };
expect(value).toMatchObject({ finalUrl: "https://b.test", reached: "load" });
expect(calls[1].args).toEqual([
"navigate",
"--session",
"s1",
"--wait-until",
"networkidle",
"--timeout",
"1500ms",
"https://a.test",
]);
});
it("browser_emulate rejects off combined with overrides", async () => {
const { tools } = setup(responses);
await startSession(tools);
const emulate = tools.get("browser_emulate");
await expect(emulate?.execute({ off: true, device: "iphone-14" }, makeExec())).rejects.toThrow(
/mutually exclusive/,
);
});
});
describe("browser_screenshot", () => {
function pngFile(): string {
const dir = mkdtempSync(join(tmpdir(), "bsk-test-"));
const path = join(dir, "shot.png");
// Minimal PNG header bytes are enough: the plugin never decodes the image.
writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]));
return path;
}
it("returns the PNG path when no attachment store is mounted", async () => {
const path = pngFile();
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
screenshot: { tab_id: 7, width: 800, height: 600, format: "png", path, byte_size: 8 },
});
await startSession(tools);
const screenshot = tools.get("browser_screenshot");
const value = (await screenshot?.execute({ ref: "@e1" }, makeExec())) as {
path: string;
image?: unknown;
};
expect(value.path).toBe(path);
expect(value.image).toBeUndefined();
expect(calls[1].args.slice(0, 3)).toEqual(["screenshot", "--session", "s1"]);
expect(calls[1].args).toContain("--ref");
const rendered = screenshot?.output.render({ session: "s1" }, value as never);
expect(rendered?.every((block) => block.type === "text")).toBe(true);
});
it("commits the image through the attachment store on an image-capable route", async () => {
const path = pngFile();
const services = {
attachments: {
imageLimits: {
mediaTypes: ["image/png"],
maxImageBytes: 10_000_000,
maxMessageImageBytes: 10_000_000,
},
saveImage: async (input: { data: Uint8Array; mediaType: string; name?: string }) => ({
attachmentId: "att-1",
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 800,
height: 600,
name: input.name,
}),
},
llm: {
resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }),
},
};
const { tools } = setup(
{
"session start": START_REPLY("s1"),
screenshot: { tab_id: 7, width: 800, height: 600, format: "png", path, byte_size: 8 },
},
services,
);
await startSession(tools);
const exec = makeExec({
agent: {
session: { requestHeader: () => ({ config: { provider: "deepseek", model: "vl" } }) },
options: {},
},
});
const screenshot = tools.get("browser_screenshot");
const value = (await screenshot?.execute({}, exec)) as {
image?: { attachmentId: string; mediaType: string };
};
expect(value.image).toMatchObject({ attachmentId: "att-1", mediaType: "image/png" });
const rendered = screenshot?.output.render({}, value as never) as { type: string }[];
expect(rendered.map((block) => block.type)).toEqual(["text", "image"]);
});
it("stays path-only when the model route is text-only", async () => {
const path = pngFile();
const services = {
attachments: {
imageLimits: { mediaTypes: ["image/png"], maxImageBytes: 1e7, maxMessageImageBytes: 1e7 },
saveImage: async () => {
throw new Error("must not be called");
},
},
llm: { resolveModelInfo: async () => ({ inputModalities: ["text"] }) },
};
const { tools } = setup(
{
"session start": START_REPLY("s1"),
screenshot: { tab_id: 7, width: 1, height: 1, format: "png", path, byte_size: 8 },
},
services,
);
await startSession(tools);
const exec = makeExec({
agent: {
session: { requestHeader: () => ({ config: { provider: "p", model: "m" } }) },
options: {},
},
});
const screenshot = tools.get("browser_screenshot");
const value = (await screenshot?.execute({}, exec)) as { image?: unknown; path: string };
expect(value.image).toBeUndefined();
expect(value.path).toBe(path);
});
});
describe("error and cancellation semantics", () => {
it("surfaces the bsk JSON error envelope with code and hint", async () => {
const { tools } = setup({
"session start": START_REPLY("s1"),
});
await startSession(tools);
const snapshot = tools.get("browser_snapshot");
await expect(snapshot?.execute({}, makeExec())).rejects.toThrow(/no canned response/);
});
it("turns an aborted child into an AbortError", async () => {
const { tools } = setup({ "session start": START_REPLY("s1") });
await startSession(tools);
const snapshot = tools.get("browser_snapshot");
const controller = new AbortController();
controller.abort();
const exec = makeExec({ signal: controller.signal });
await expect(snapshot?.execute({}, exec)).rejects.toMatchObject({ name: "AbortError" });
});
it("maps a missing bsk binary onto install guidance", async () => {
const { ctx, tools } = makeCtx();
const registry = new SessionRegistry(5);
const runner = {
async run(): Promise<BskRunResult> {
throw Object.assign(new Error("spawn bsk ENOENT"), { code: "ENOENT" });
},
killAll() {},
killFor: () => 0,
};
registerTools({
ctx: ctx as never,
runner,
registry,
config: CONFIG,
observation: disabledObservation({ ctx, runner, registry }),
queue: new KeyedExecutor(),
});
const start = tools.get("browser_session_start");
await expect(start?.execute({}, makeExec())).rejects.toThrow(/BrowserSkill must be installed/);
});
});
describe("presentation", () => {
it("presents calls as terminal cards with the bsk command line", () => {
const { tools } = setup({});
const click = tools.get("browser_click");
const view = click?.presentCall?.({ target: "@e1" }) as {
card: string;
title: string;
};
expect(view.card).toBe("terminal");
expect(view.title).toContain("bsk click @e1");
});
it("presents results as terminal cards carrying the rendered text", () => {
const { tools } = setup({});
const click = tools.get("browser_click");
const view = click?.presentResult?.({ target: "@e1" }, {
content: [{ type: "text", text: "clicked" }],
isError: false,
} as never) as { card: string; output: string; exitCode: number };
expect(view).toMatchObject({ card: "terminal", output: "clicked", exitCode: 0 });
});
it("projects the text block of multi-block results (screenshot text + image)", () => {
const { tools } = setup({});
const screenshot = tools.get("browser_screenshot");
const view = screenshot?.presentResult?.({}, {
content: [
{ type: "text", text: "[session s1] screenshot of tab 7" },
{ type: "image", attachment: { attachmentId: "a1" } },
],
isError: false,
} as never) as { card: string; output: string };
expect(view).toMatchObject({ card: "terminal", output: "[session s1] screenshot of tab 7" });
});
});
describe("screenshot scratch file lifecycle", () => {
/** Runner that honors --out like the real bsk (writes the PNG there). */
function outWritingRunner() {
const calls: FakeCall[] = [];
return {
calls,
runner: {
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
calls.push({ args, options });
if (args[0] === "session") {
return {
code: 0,
stdout: JSON.stringify({ session_id: "s1", browser_instance_id: "chrome-1" }),
stderr: "",
timedOut: false,
aborted: false,
};
}
if (args[0] === "screenshot") {
const out = args[args.indexOf("--out") + 1];
writeFileSync(out, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
return {
code: 0,
stdout: JSON.stringify({
tab_id: 7,
width: 4,
height: 4,
format: "png",
path: out,
byte_size: 4,
}),
stderr: "",
timedOut: false,
aborted: false,
};
}
return { code: 2, stdout: "{}", stderr: "", timedOut: false, aborted: false };
},
killAll() {},
killFor: () => 0,
} as BskRunner,
};
}
function setupWithRunner(runner: BskRunner, services: Record<string, unknown> = {}) {
const { ctx, tools } = makeCtx(services);
const registry = new SessionRegistry(5);
registerTools({
ctx: ctx as never,
runner,
registry,
config: CONFIG,
observation: disabledObservation({ ctx, runner, registry }),
queue: new KeyedExecutor(),
});
return { tools, registry };
}
const IMAGE_SERVICES = {
attachments: {
imageLimits: { mediaTypes: ["image/png"], maxImageBytes: 1e7, maxMessageImageBytes: 1e7 },
saveImage: async (input: { data: Uint8Array; mediaType: string }) => ({
attachmentId: "att-1",
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 4,
height: 4,
}),
},
llm: { resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }) },
};
it("deletes the scratch PNG once the bytes are in the attachment store", async () => {
const { runner, calls } = outWritingRunner();
const { tools } = setupWithRunner(runner, IMAGE_SERVICES);
await startSession(tools);
const exec = makeExec({
agent: {
session: { requestHeader: () => ({ config: { provider: "p", model: "vl" } }) },
options: {},
},
});
const screenshot = tools.get("browser_screenshot");
const value = (await screenshot?.execute({}, exec)) as { image?: unknown; path: string };
expect(value.image).toBeDefined();
const out = calls.find((c) => c.args[0] === "screenshot")?.args.at(-1);
expect(out).toBeDefined();
expect(existsSync(out ?? "")).toBe(false);
});
it("keeps the PNG only when it is the model-facing artifact (no store)", async () => {
const { runner, calls } = outWritingRunner();
const { tools } = setupWithRunner(runner);
await startSession(tools);
const screenshot = tools.get("browser_screenshot");
const value = (await screenshot?.execute({}, makeExec())) as { image?: unknown; path: string };
expect(value.image).toBeUndefined();
const out = calls.find((c) => c.args[0] === "screenshot")?.args.at(-1);
expect(existsSync(out ?? "")).toBe(true);
});
});
describe("observation action instrumentation timing", () => {
it("a queued call never overwrites the in-flight action label", async () => {
const { ctx, tools } = makeCtx();
const registry = new SessionRegistry(5);
let releaseNavigate!: () => void;
const navigateGate = new Promise<void>((resolve) => {
releaseNavigate = resolve;
});
const runner: BskRunner = {
async run(args: string[]): Promise<BskRunResult> {
if (args[0] === "session") {
return {
code: 0,
stdout: JSON.stringify({ session_id: "s1", browser_instance_id: "chrome-1" }),
stderr: "",
timedOut: false,
aborted: false,
};
}
if (args[0] === "navigate") {
await navigateGate;
return {
code: 0,
stdout: JSON.stringify({ tab_id: 7, url: "http://x" }),
stderr: "",
timedOut: false,
aborted: false,
};
}
return {
code: 0,
stdout: JSON.stringify({ text: "ok", ref_count: 0, tab_id: 7, truncated: false }),
stderr: "",
timedOut: false,
aborted: false,
};
},
killAll() {},
killFor: () => 0,
};
const queue = new KeyedExecutor();
const observation = new ObservationService({
ctx: ctx as never,
runner,
registry,
queue,
options: { enabled: true, thumbnailIntervalMs: 1500, idleIntervalMs: 8000 },
});
registerTools({ ctx: ctx as never, runner, registry, config: CONFIG, observation, queue });
await startSession(tools);
const navigate = tools.get("browser_navigate");
const snapshot = tools.get("browser_snapshot");
const a = navigate?.execute({ url: "http://x" }, makeExec()) as Promise<unknown>;
// A starts immediately (empty queue) and blocks on the gate.
await vi.waitFor(() =>
expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("navigating"),
);
// B queues behind A: the label must stay "navigating" until A settles.
const b = snapshot?.execute({}, makeExec()) as Promise<unknown>;
await new Promise((resolve) => setTimeout(resolve, 30));
expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("navigating");
releaseNavigate();
await a;
await vi.waitFor(() =>
expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("snapshotting"),
);
await b;
await vi.waitFor(() =>
expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("idle"),
);
observation.dispose();
});
});