Compare commits

..
Author SHA1 Message Date
“haonan”andCursor c48a53269e style: rustfmt doctor protocol-version test asserts
cargo fmt --check failed on two long assert lines in PR CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:50:01 +08:00
“haonan”andCursor 3bbd6ab19f fix: treat protocol version differences as upgrade reminders
Agents were stopping on popup and status copy that looked like a hard incompatibility. Keep the connection usable, reframe exit 5 as an unknown method, and ask users to upgrade instead of aborting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:22:30 +08:00
108 changed files with 1623 additions and 8485 deletions
-4
View File
@@ -11,10 +11,6 @@ dist/
# Logs
*.log
# Local browser evaluation artifacts
evals/browser/results/
evals/browser/agents.local.json
# macOS
.DS_Store
-1
View File
@@ -208,7 +208,6 @@ The repository is a Cargo + pnpm workspace:
- `apps/extension` — browser extension
- `packages/ui` and `packages/i18n` — shared extension UI support
- `packages/dsh-plugin-browserskill` — DeepSeek Harness plugin (`@wxg-prc-cpg/browser-skill-dsh-plugin`)
- [`evals/browser`](evals/browser/README.md) — deterministic local pages and agent-neutral browser capability evaluation
## License
-1
View File
@@ -178,7 +178,6 @@ Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;
- `apps/extension` — 浏览器扩展
- `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持
- `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`)
- [`evals/browser`](evals/browser/README.zh-CN.md) — 确定性本地页面与 Agent 无关的浏览器能力测试台
## 许可证
@@ -247,6 +247,41 @@ describe("App", () => {
expect(copyButton.getAttribute("disabled")).not.toBeNull();
expect(screen.getByText("连接后可用")).toBeTruthy();
});
it("treats protocol drift as still connected and prompts an upgrade", async () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
state: "version_skew",
instanceId: "03c3e47f",
handshake: {
server: "bh",
version: mockDaemonVersion,
protocol_version: "1.0",
},
},
statusState: "version_skew",
setLabel,
setConnectionEnabled,
});
render(<App />);
expect(screen.getByText("已连接")).toBeTruthy();
expect(screen.getByText("可升级")).toBeTruthy();
expect(screen.queryByText("协议不一致")).toBeNull();
expect(screen.queryByText("Action needed")).toBeNull();
expect(screen.queryByText("兼容")).toBeNull();
const warning = screen.getByText(/协议版本不同,请及时升级/);
expect(warning.textContent).toContain("CLI 协议");
expect(warning.textContent).toContain("扩展协议");
openRecordView();
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
expect(copyButton.getAttribute("disabled")).toBeNull();
fireEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
});
});
describe("control hints toggle", () => {
+4 -3
View File
@@ -79,6 +79,7 @@ export function App() {
}, [copiedTick]);
const isSkewed = statusState === "version_skew";
const connectionLive = statusState === "connected" || isSkewed;
const daemonVersion = snapshot.handshake?.version ?? "—";
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
const extensionVersion = snapshot.extensionVersion || "—";
@@ -90,7 +91,7 @@ export function App() {
setCopiedInstanceId(true);
};
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
const recordReady = connectionLive && Boolean(snapshot.instanceId);
const recordPurpose = purposeDraft.trim();
const recordStartUrl = startUrlDraft.trim();
const recordCommand = snapshot.instanceId
@@ -206,12 +207,12 @@ export function App() {
</div>
{isSkewed && (
<p
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
className="mt-2 text-xs leading-snug text-muted-foreground"
data-slot="popup-version-skew-warning"
>
{t("popup.versionSkewWarning", {
extensionProtocol: PROTOCOL_VERSION,
daemonProtocol,
cliProtocol: daemonProtocol,
})}
</p>
)}
@@ -1656,7 +1656,7 @@ describe("buildVomScene", () => {
const scene = buildVomScene(axNodes, captured);
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
expect.objectContaining({ id: 20, role: "button", name: "close", cursor: "pointer" }),
expect.objectContaining({ id: 20, role: "generic", cursor: "pointer" }),
);
const rendered = renderVom(scene);
expect(rendered.text).toContain('@e1 button "close"');
@@ -1732,7 +1732,7 @@ describe("buildVomScene", () => {
};
const scene = buildVomScene(axNodes, captured);
expect(scene.nodes.find((n) => n.id === 20)).toBeUndefined();
expect(scene.nodes.find((n) => n.id === 20)?.role).toBe("generic");
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("link");
});
@@ -1801,9 +1801,9 @@ describe("buildVomScene", () => {
const scene = buildVomScene(axNodes, captured);
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
expect.objectContaining({ id: 20, role: "button", attrs: { "aria-label": "收藏" } }),
expect.objectContaining({ id: 20, role: "generic", attrs: { "aria-label": "收藏" } }),
);
expect(scene.nodes.find((n) => n.id === 30)).toBeUndefined();
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("generic");
const rendered = renderVom(scene);
expect(rendered.text).toContain('@e1 button "收藏"');
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([
@@ -2131,7 +2131,7 @@ describe("buildVomScene", () => {
});
expect(scene.surfaces).toEqual([
{ triggerId: 20, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
{ triggerId: 21, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
]);
expect(renderVom(scene).text).toContain(
'@e1 button "image" [hover first: My profile | Sign out]',
@@ -3155,98 +3155,6 @@ describe("handleSnapshot", () => {
});
});
it("decorates live observations with active controlled content", async () => {
const strings = [
"html",
"body",
"button",
"div",
"#text",
"role",
"tab",
"aria-selected",
"true",
"aria-controls",
"reviews-panel",
"id",
"static",
"auto",
"visible",
"1",
"A detailed review",
];
const index = (value: string) => strings.indexOf(value);
const style = [index("static"), index("auto"), index("auto"), index("visible"), index("1")];
const snapshot = {
strings,
documents: [
{
nodes: {
parentIndex: [-1, 0, 1, 1, 3],
nodeName: [index("html"), index("body"), index("button"), index("div"), index("#text")],
backendNodeId: [10, 11, 12, 13, 14],
attributes: [
[],
[],
[
index("role"),
index("tab"),
index("aria-selected"),
index("true"),
index("aria-controls"),
index("reviews-panel"),
],
[index("id"), index("reviews-panel")],
[],
],
nodeValue: [-1, -1, -1, -1, index("A detailed review")],
},
layout: {
nodeIndex: [0, 1, 2, 3, 4],
styles: [style, style, style, style, style],
bounds: [
[0, 0, 1000, 800],
[0, 0, 1000, 800],
[20, 20, 120, 40],
[20, 80, 500, 200],
[20, 80, 200, 20],
],
paintOrders: [0, 0, 1, 1, 1],
},
},
],
};
const ax: CdpAxNode[] = [
{
nodeId: "root",
backendDOMNodeId: 11,
role: { type: "role", value: "RootWebArea" },
childIds: ["reviews"],
},
{
nodeId: "reviews",
parentId: "root",
backendDOMNodeId: 12,
role: { type: "role", value: "tab" },
name: { type: "computedString", value: "Reviews" },
properties: [
{ name: "selected", value: { value: true } },
{ name: "controls", value: { value: "reviews-panel" } },
],
},
];
const result = await captureVomObservation(
makeOverlayDeps(ax, snapshot, VP_METRICS).cdp,
4,
"https://example.com",
);
expect(result.text).toContain('@e1 tab "Reviews"');
expect(result.text).toContain("[§ active: Reviews]");
expect(result.text).toContain("A detailed review");
});
it("does not leak form secrets through the record-safe observation payload", async () => {
const secrets = {
email: "user@example.com",
File diff suppressed because it is too large Load Diff
@@ -36,7 +36,7 @@ function fakeSnapshotReply() {
},
layout: {
nodeIndex: [1, 2, 3],
// Legacy fixture: omitted style columns fall back to visible defaults.
// styles columns follow requested computedStyles order: [position, pointer-events]
styles: [
[i("static"), i("auto")],
[i("fixed"), i("auto")],
@@ -682,46 +682,6 @@ describe("captureViewModel", () => {
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
});
it("captures painted visibility as DOM fallback evidence", async () => {
const S = ["html", "body", "button", "static", "auto", "visible", "hidden", "1", "0"];
const i = (value: string) => S.indexOf(value);
const snapshot = {
strings: S,
documents: [
{
nodes: {
parentIndex: [-1, 0, 1, 1, 1],
nodeName: [i("html"), i("body"), i("button"), i("button"), i("button")],
backendNodeId: [10, 11, 12, 13, 14],
attributes: [[], [], [], [], []],
},
layout: {
nodeIndex: [1, 2, 3, 4],
styles: [
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
[i("static"), i("auto"), i("auto"), i("hidden"), i("1")],
[i("static"), i("auto"), i("auto"), i("visible"), i("0")],
],
bounds: [
[0, 0, 1000, 800],
[10, 10, 100, 30],
[10, 50, 100, 30],
[10, 90, 100, 30],
],
paintOrders: [0, 1, 2, 3],
},
},
],
};
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
expect(nodes.find((node) => node.backendNodeId === 12)?.rendered).toBe(true);
expect(nodes.find((node) => node.backendNodeId === 13)?.rendered).toBe(false);
expect(nodes.find((node) => node.backendNodeId === 14)?.rendered).toBe(false);
});
it("subtracts scroll offset to make rects viewport-relative", async () => {
const cdp = {
send: vi.fn(async (_t: number, method: string) => {
@@ -132,67 +132,4 @@ describe("captureFrameData", () => {
]),
);
});
it("keeps OOPIF semantics when viewport projection is unavailable", async () => {
const owner = { ...ownerNode(10, 50), rect: null, localRect: null };
const captured: CapturedViewModel = {
nodes: [owner],
viewport: { width: 1000, height: 800 },
iframeNodes: new Map(),
frameNodes: new Map([["main", [owner]]]),
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method) => {
if (method === "Accessibility.enable") return {};
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
if (method === "DOM.getBoxModel") throw new Error("owner geometry unavailable");
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
}
throw new Error(`unexpected root ${method}`);
}) as CdpRunner["send"],
sendToTarget: vi.fn(async (_target, method) => {
if (method === "Page.getLayoutMetrics") throw new Error("viewport unavailable");
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
if (method === "DOMSnapshot.captureSnapshot") return childSnapshot("child", 101);
if (method === "Accessibility.getFullAXTree") {
return {
nodes: [
{
nodeId: "button",
backendDOMNodeId: 101,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Continue" },
},
],
};
}
throw new Error(`unexpected ${method}`);
}) as unknown as NonNullable<CdpRunner["sendToTarget"]>,
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4, sessionId: "child-session" },
},
],
})),
};
const documents = await captureFrameData(cdp, 4, captured);
const childDocument = documents.find((document) => document.frameId === "child");
expect(childDocument?.axNodes).toEqual([
expect.objectContaining({ backendDOMNodeId: 101, frameId: "child" }),
]);
expect(childDocument?.domNodes.find((node) => node.backendNodeId === 101)).toEqual(
expect.objectContaining({ rect: null, localRect: { x: 10, y: 20, w: 100, h: 40 } }),
);
});
});
@@ -1,183 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { CdpRunner } from "../../shared";
import type { CapturedNode } from "../capture";
import type { FrameDocument, FrameOwnedAxNode } from "../frame-document";
import { probeTooltipNames } from "../name-enrichment";
import { buildSemanticGraph, resolveSemanticGraph } from "../semantic-graph";
import { stableIdentifierName } from "../semantic-graph/name-evidence";
import { frameBackendKey } from "../semantic-graph/types";
function document(
target: CdpTarget,
backendNodeId: number,
accessibleName?: string,
frameId = "child",
x = 10,
): FrameDocument<FrameOwnedAxNode> {
const node: CapturedNode = {
backendNodeId,
parentBackendNodeId: null,
frameId,
tag: "button",
attrs: {
id: "toolInsertRecord",
"data-test-id": "toolInsertRecord",
...(accessibleName ? { "aria-label": accessibleName } : {}),
},
rect: { x, y: 20, w: 40, h: 30 },
localRect: { x: 2, y: 3, w: 40, h: 30 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
cursor: "pointer",
};
return {
frameId,
parentFrameId: "main",
contextScopeId: "child",
target,
axNodes: [],
domNodes: [node],
url: "https://example.com/frame",
};
}
function semantics(documents: FrameDocument<FrameOwnedAxNode>[]) {
return resolveSemanticGraph(
buildSemanticGraph({
documents,
viewport: { width: 800, height: 600 },
rootFrameId: documents[0]?.frameId,
}),
{ identifierFallback: false },
);
}
describe("tooltip name enrichment", () => {
it("uses the owning frame target and upgrades an identifier name from a tooltip", async () => {
const target = { tabId: 7, sessionId: "oopif-session" };
let hovered = false;
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method, params) => {
if (method === "Input.dispatchMouseEvent") {
hovered = (params as { x?: number }).x === 30;
}
return {};
}) as CdpRunner["send"],
sendToTarget: vi.fn(async (_target, method) => {
if (method === "Page.createIsolatedWorld") return { executionContextId: 11 };
if (method === "DOM.resolveNode") return { object: { objectId: "button-501" } };
if (method === "Runtime.callFunctionOn") {
return { result: { value: hovered ? ["插入行"] : [] } };
}
return {};
}) as CdpRunner["sendToTarget"],
};
const documents = [document(target, 501)];
const graph = semantics(documents);
const names = await probeTooltipNames(cdp, 7, documents, graph);
expect(names.get(frameBackendKey("child", 501))).toBe("插入行");
expect(cdp.sendToTarget).toHaveBeenCalledWith(
target,
"Page.createIsolatedWorld",
expect.objectContaining({ frameId: "child" }),
);
const sendsBeforeCache = vi.mocked(cdp.send).mock.calls.length;
const targetSendsBeforeCache = vi.mocked(
cdp.sendToTarget as NonNullable<CdpRunner["sendToTarget"]>,
).mock.calls.length;
const cached = await probeTooltipNames(cdp, 7, documents, graph);
expect(cached.get(frameBackendKey("child", 501))).toBe("插入行");
expect(cdp.send).toHaveBeenCalledTimes(sendsBeforeCache);
expect(cdp.sendToTarget).toHaveBeenCalledTimes(targetSendsBeforeCache);
});
it("does not probe controls that already have a higher-confidence name", async () => {
const cdp: CdpRunner = {
send: vi.fn(async () => ({})) as CdpRunner["send"],
sendToTarget: vi.fn(async () => ({ executionContextId: 12 })) as CdpRunner["sendToTarget"],
};
const documents = [document({ tabId: 8 }, 601, "Add a new record")];
const names = await probeTooltipNames(cdp, 8, documents, semantics(documents));
expect(names.size).toBe(0);
expect(cdp.send).not.toHaveBeenCalled();
expect(cdp.sendToTarget).not.toHaveBeenCalled();
});
it("does not collect supplemental names from form controls", async () => {
const cdp: CdpRunner = {
send: vi.fn(async () => ({})) as CdpRunner["send"],
sendToTarget: vi.fn(async () => ({ executionContextId: 12 })) as CdpRunner["sendToTarget"],
};
const inputDocument = document({ tabId: 8 }, 602);
inputDocument.domNodes[0] = {
...inputDocument.domNodes[0],
tag: "input",
attrs: { type: "email" },
formValue: "user@example.com",
formDefaultValue: "",
formState: "filled",
};
const names = await probeTooltipNames(cdp, 8, [inputDocument], semantics([inputDocument]));
expect(names.size).toBe(0);
expect(cdp.send).not.toHaveBeenCalled();
expect(cdp.sendToTarget).not.toHaveBeenCalled();
});
it("keeps tooltip evidence isolated across sibling frame sessions", async () => {
let hoveredX = -10;
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method, params) => {
if (method === "Input.dispatchMouseEvent") {
hoveredX = (params as { x: number }).x;
}
return {};
}) as CdpRunner["send"],
sendToTarget: vi.fn(async (target, method, params) => {
if (method === "Page.createIsolatedWorld") {
return { executionContextId: target.sessionId === "left-session" ? 21 : 22 };
}
if (method === "DOM.resolveNode") {
return { object: { objectId: target.sessionId === "left-session" ? "left" : "right" } };
}
if (method === "Runtime.callFunctionOn") {
const objectId = (params as { objectId: string }).objectId;
const value =
objectId === "left" && hoveredX === 30
? ["Left action"]
: objectId === "right" && hoveredX === 130
? ["Right action"]
: [];
return { result: { value } };
}
return {};
}) as CdpRunner["sendToTarget"],
};
const documents = [
document({ tabId: 9, sessionId: "left-session" }, 701, undefined, "left", 10),
document({ tabId: 9, sessionId: "right-session" }, 701, undefined, "right", 110),
];
const names = await probeTooltipNames(cdp, 9, documents, semantics(documents));
expect(names.get(frameBackendKey("left", 701))).toBe("Left action");
expect(names.get(frameBackendKey("right", 701))).toBe("Right action");
});
});
describe("stable identifier evidence", () => {
it("accepts readable action identifiers and rejects framework-generated ids", () => {
expect(stableIdentifierName({ id: "toolInsertRecord" })).toBe("Insert record");
expect(stableIdentifierName({ id: "reactivateAccount" })).toBe("Reactivate account");
expect(stableIdentifierName({ id: "headlessui-menu-button-r1" })).toBeUndefined();
expect(stableIdentifierName({ id: "7f15a3bc91" })).toBeUndefined();
});
});
@@ -1,782 +0,0 @@
import { isVomReferenceNode, renderVom } from "@browser-skill/vom";
import { describe, expect, it } from "vitest";
import type { CapturedNode } from "../capture";
import type { FrameDocument } from "../frame-document";
import { buildSemanticVomScene, type SemanticAxNode } from "../semantic-graph";
function dom(
backendNodeId: number,
parentBackendNodeId: number | null,
tag: string,
attrs: Record<string, string> = {},
textContent?: string,
): CapturedNode {
return {
backendNodeId,
parentBackendNodeId,
tag,
attrs,
rect: { x: 0, y: 0, w: 100, h: 30 },
paintOrder: backendNodeId,
position: "static",
pointerEvents: "auto",
...(textContent ? { textContent } : {}),
};
}
function document(
frameId: string,
axNodes: SemanticAxNode[],
domNodes: CapturedNode[],
frame?: Pick<FrameDocument<SemanticAxNode>, "parentFrameId" | "ownerBackendNodeId">,
): FrameDocument<SemanticAxNode> {
return {
frameId,
contextScopeId: frameId,
target: { tabId: 1 },
axNodes,
domNodes,
...frame,
};
}
describe("semantic VOM graph", () => {
it("preserves backend-less AX structure without making it referenceable", () => {
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["toolbar"],
},
{
nodeId: "toolbar",
parentId: "root",
role: { type: "role", value: "toolbar" },
name: { type: "computedString", value: "Formatting" },
childIds: ["bold", "italic"],
},
{
nodeId: "bold",
parentId: "toolbar",
backendDOMNodeId: 2,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Bold" },
},
{
nodeId: "italic",
parentId: "toolbar",
backendDOMNodeId: 3,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Italic" },
},
],
[dom(1, null, "body"), dom(2, 1, "button"), dom(3, 1, "button")],
),
],
});
const toolbar = scene.nodes.find((node) => node.role === "toolbar");
const buttons = scene.nodes.filter((node) => node.role === "button");
expect(toolbar).toEqual(expect.objectContaining({ name: "Formatting", referenceable: false }));
expect(buttons.map((node) => node.parentId)).toEqual([toolbar?.id, toolbar?.id]);
expect(toolbar && isVomReferenceNode(toolbar)).toBe(false);
expect(renderVom(scene).refs.map((ref) => ref.backendNodeId)).toEqual([2, 3]);
});
it("resolves missing AX names through one DOM naming pipeline", () => {
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["labelled", "titled", "text"],
},
{
nodeId: "labelled",
parentId: "root",
backendDOMNodeId: 2,
role: { type: "role", value: "button" },
},
{
nodeId: "titled",
parentId: "root",
backendDOMNodeId: 3,
role: { type: "role", value: "button" },
},
{
nodeId: "text",
parentId: "root",
backendDOMNodeId: 4,
role: { type: "role", value: "button" },
},
],
[
dom(1, null, "body"),
dom(2, 1, "button", { "aria-label": "Add row" }),
dom(3, 1, "button", { title: "Settings" }),
dom(4, 1, "button"),
dom(5, 4, "span", {}, "Save"),
],
),
],
});
expect(scene.nodes.filter((node) => node.role === "button").map((node) => node.name)).toEqual([
"Add row",
"Settings",
"Save",
]);
});
it("aggregates AX text at the semantic text frontier", () => {
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["covered", "inline-only", "repeated", "labelled"],
},
{
nodeId: "covered",
parentId: "root",
backendDOMNodeId: 2,
role: { type: "role", value: "paragraph" },
childIds: ["covered-static"],
},
{
nodeId: "covered-static",
parentId: "covered",
role: { type: "role", value: "StaticText" },
name: { type: "computedString", value: "Covered text" },
childIds: ["covered-inline"],
},
{
nodeId: "covered-inline",
parentId: "covered-static",
role: { type: "role", value: "InlineTextBox" },
name: { type: "computedString", value: "Covered text" },
},
{
nodeId: "inline-only",
parentId: "root",
backendDOMNodeId: 3,
role: { type: "role", value: "paragraph" },
childIds: ["inline-leaf"],
},
{
nodeId: "inline-leaf",
parentId: "inline-only",
role: { type: "role", value: "InlineTextBox" },
name: { type: "computedString", value: "Inline fallback" },
},
{
nodeId: "repeated",
parentId: "root",
backendDOMNodeId: 4,
role: { type: "role", value: "paragraph" },
childIds: ["first-go", "second-go"],
},
{
nodeId: "first-go",
parentId: "repeated",
role: { type: "role", value: "StaticText" },
name: { type: "computedString", value: "Go" },
},
{
nodeId: "second-go",
parentId: "repeated",
role: { type: "role", value: "StaticText" },
name: { type: "computedString", value: "Go" },
},
{
nodeId: "labelled",
parentId: "root",
backendDOMNodeId: 5,
role: { type: "role", value: "button" },
childIds: ["visible-label"],
},
{
nodeId: "visible-label",
parentId: "labelled",
role: { type: "role", value: "StaticText" },
name: { type: "computedString", value: "Visible text" },
},
],
[
dom(1, null, "body"),
dom(2, 1, "p"),
dom(3, 1, "p"),
dom(4, 1, "p"),
dom(5, 1, "button", { "aria-label": "Explicit action" }),
],
),
],
});
expect(scene.nodes.find((node) => node.backendNodeId === 2)?.name).toBe("Covered text");
expect(scene.nodes.find((node) => node.backendNodeId === 3)?.name).toBe("Inline fallback");
expect(scene.nodes.find((node) => node.backendNodeId === 4)?.name).toBe("Go Go");
expect(scene.nodes.find((node) => node.backendNodeId === 5)?.name).toBe("Explicit action");
expect(renderVom(scene).text).not.toContain("Covered text Covered text");
});
it("uses stable identifiers only as a final name fallback", () => {
const base = document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["fallback", "labelled", "container"],
},
{
nodeId: "fallback",
parentId: "root",
backendDOMNodeId: 2,
role: { type: "role", value: "button" },
},
{
nodeId: "labelled",
parentId: "root",
backendDOMNodeId: 3,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Localized name" },
},
{
nodeId: "container",
parentId: "root",
backendDOMNodeId: 4,
role: { type: "role", value: "generic" },
},
],
[
dom(1, null, "body"),
dom(2, 1, "button", { "data-test-id": "toolInsertRecord" }),
dom(3, 1, "button", { id: "toolIgnoredFallback" }),
dom(4, 1, "div", { id: "toolbarContainer" }),
],
);
const staticScene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [base],
});
const enrichedScene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [base],
supplementalNames: new Map([["main\u00002", "插入行"]]),
});
expect(staticScene.nodes.find((node) => node.backendNodeId === 2)?.name).toBe("Insert record");
expect(staticScene.nodes.find((node) => node.backendNodeId === 3)?.name).toBe("Localized name");
expect(staticScene.nodes.find((node) => node.backendNodeId === 4)?.name).toBeUndefined();
expect(enrichedScene.nodes.find((node) => node.backendNodeId === 2)?.name).toBe("插入行");
expect(enrichedScene.nodes.find((node) => node.backendNodeId === 3)?.name).toBe(
"Localized name",
);
});
it("isolates equal backend node ids in sibling frames", () => {
const scene = buildSemanticVomScene({
viewport: { width: 1000, height: 800 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["owner-a", "owner-b"],
},
{
nodeId: "owner-a",
parentId: "root",
backendDOMNodeId: 10,
role: { type: "role", value: "Iframe" },
},
{
nodeId: "owner-b",
parentId: "root",
backendDOMNodeId: 20,
role: { type: "role", value: "Iframe" },
},
],
[dom(1, null, "body"), dom(10, 1, "iframe"), dom(20, 1, "iframe")],
),
document(
"frame-a",
[
{
nodeId: "button-a",
backendDOMNodeId: 5,
role: { type: "role", value: "button" },
childIds: ["text-a"],
},
{
nodeId: "text-a",
parentId: "button-a",
role: { type: "role", value: "StaticText" },
name: { type: "computedString", value: "Accept" },
childIds: ["inline-a"],
},
{
nodeId: "inline-a",
parentId: "text-a",
role: { type: "role", value: "InlineTextBox" },
name: { type: "computedString", value: "Accept" },
},
],
[dom(5, null, "button")],
{ parentFrameId: "main", ownerBackendNodeId: 10 },
),
document(
"frame-b",
[
{
nodeId: "button-b",
backendDOMNodeId: 5,
role: { type: "role", value: "button" },
childIds: ["text-b"],
},
{
nodeId: "text-b",
parentId: "button-b",
role: { type: "role", value: "StaticText" },
name: { type: "computedString", value: "Decline" },
childIds: ["inline-b"],
},
{
nodeId: "inline-b",
parentId: "text-b",
role: { type: "role", value: "InlineTextBox" },
name: { type: "computedString", value: "Decline" },
},
],
[dom(5, null, "button")],
{ parentFrameId: "main", ownerBackendNodeId: 20 },
),
],
});
const frameButtons = scene.nodes.filter((node) => node.backendNodeId === 5);
const owners = new Map(
scene.nodes
.filter((node) => node.backendNodeId === 10 || node.backendNodeId === 20)
.map((node) => [node.backendNodeId, node.id]),
);
expect(frameButtons.map((node) => node.frameId)).toEqual(["frame-a", "frame-b"]);
expect(frameButtons.map((node) => node.name)).toEqual(["Accept", "Decline"]);
expect(frameButtons.map((node) => node.parentId)).toEqual([owners.get(10), owners.get(20)]);
expect(renderVom(scene).refs.map((ref) => [ref.frameId, ref.backendNodeId])).toEqual([
["frame-a", 5],
["frame-b", 5],
]);
});
it("requires render evidence before promoting DOM fallback semantics", () => {
const hiddenOwner = {
...dom(10, 1, "iframe", { hidden: "" }),
rect: null,
localRect: null,
rendered: false,
};
const hiddenButton = {
...dom(2, 1, "button", { "aria-label": "Hidden action" }),
rendered: false,
};
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
},
],
[dom(1, null, "body"), hiddenButton, hiddenOwner],
),
document("hidden-frame", [], [dom(20, null, "button", {}, "Frame action")], {
parentFrameId: "main",
ownerBackendNodeId: 10,
}),
],
});
expect(scene.nodes.some((node) => node.backendNodeId === 2)).toBe(false);
expect(scene.nodes.some((node) => node.backendNodeId === 10)).toBe(false);
expect(scene.nodes.some((node) => node.frameId === "hidden-frame")).toBe(false);
expect(renderVom(scene).refs).toEqual([]);
});
it("retains rendered DOM fallback controls when AX semantics are unavailable", () => {
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[],
[dom(1, null, "body"), { ...dom(2, 1, "button", {}, "Save"), rendered: true }],
),
],
});
expect(renderVom(scene).refs).toEqual([
expect.objectContaining({ backendNodeId: 2, role: "button", name: "Save" }),
]);
});
it("lets explicit DOM structure override ignored AX metadata", () => {
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["toolbar"],
},
{
nodeId: "toolbar",
parentId: "root",
backendDOMNodeId: 2,
ignored: true,
role: { type: "role", value: "generic" },
childIds: ["wrapper"],
},
{
nodeId: "wrapper",
parentId: "toolbar",
backendDOMNodeId: 5,
role: { type: "role", value: "generic" },
childIds: ["bold", "italic"],
},
{
nodeId: "bold",
parentId: "wrapper",
backendDOMNodeId: 3,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Bold" },
},
{
nodeId: "italic",
parentId: "wrapper",
backendDOMNodeId: 4,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Italic" },
},
],
[
dom(1, null, "body"),
dom(2, 1, "div", { role: "toolbar" }),
dom(5, 2, "div"),
dom(3, 5, "button"),
dom(4, 5, "button"),
],
),
],
});
const toolbar = scene.nodes.find((node) => node.backendNodeId === 2);
const buttons = scene.nodes.filter((node) => [3, 4].includes(node.backendNodeId ?? -1));
expect(toolbar).toEqual(expect.objectContaining({ role: "toolbar" }));
expect(toolbar && isVomReferenceNode(toolbar)).toBe(false);
expect(scene.nodes.some((node) => node.role === "group")).toBe(false);
expect(buttons.map((node) => node.parentId)).toEqual([toolbar?.id, toolbar?.id]);
});
it("infers an unnamed group only from independent interaction branches", () => {
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["wrapper"],
},
{
nodeId: "wrapper",
parentId: "root",
backendDOMNodeId: 2,
role: { type: "role", value: "generic" },
childIds: ["first", "second"],
},
{
nodeId: "first",
parentId: "wrapper",
backendDOMNodeId: 3,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "First" },
},
{
nodeId: "second",
parentId: "wrapper",
backendDOMNodeId: 4,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Second" },
},
],
[dom(1, null, "body"), dom(2, 1, "div"), dom(3, 2, "button"), dom(4, 2, "button")],
),
],
});
const group = scene.nodes.find((node) => node.backendNodeId === 2);
const buttons = scene.nodes.filter((node) => [3, 4].includes(node.backendNodeId ?? -1));
expect(group).toEqual(expect.objectContaining({ role: "group" }));
expect(group?.name).toBeUndefined();
expect(group && isVomReferenceNode(group)).toBe(false);
expect(buttons.map((node) => node.parentId)).toEqual([group?.id, group?.id]);
const rendered = renderVom(scene);
const lines = rendered.text.split("\n");
const groupLine = lines.findIndex((line) => line.trim() === "group");
expect(groupLine).toBeGreaterThanOrEqual(0);
for (const ref of rendered.refs) {
expect(ref.line).toBeGreaterThan(groupLine);
expect(lines[ref.line]).toMatch(/^\s+@e\d+ button/);
}
});
it("never promotes a form value into its semantic name through supplemental evidence", () => {
const secret = "user@example.com";
const input = {
...dom(2, 1, "input", { type: "email" }),
formValue: secret,
formDefaultValue: "",
formState: "filled" as const,
};
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
supplementalNames: new Map([["main\u00002", secret]]),
identifierFallback: false,
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["email"],
},
{
nodeId: "email",
parentId: "root",
backendDOMNodeId: 2,
role: { type: "role", value: "textbox" },
value: { value: secret },
},
],
[dom(1, null, "body"), input],
),
],
});
const field = scene.nodes.find((node) => node.backendNodeId === 2);
expect(field?.name).toBeUndefined();
const rendered = renderVom(scene, { redactValues: true });
expect(rendered.text).toContain("•••");
expect(JSON.stringify(rendered)).not.toContain(secret);
});
it("keeps uncertain single-branch wrappers flat", () => {
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["wrapper"],
},
{
nodeId: "wrapper",
parentId: "root",
backendDOMNodeId: 2,
role: { type: "role", value: "generic" },
childIds: ["button"],
},
{
nodeId: "button",
parentId: "wrapper",
backendDOMNodeId: 3,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Only action" },
},
],
[dom(1, null, "body"), dom(2, 1, "div"), dom(3, 2, "button")],
),
],
});
expect(scene.nodes.some((node) => node.role === "group")).toBe(false);
expect(scene.nodes.find((node) => node.backendNodeId === 3)?.parentId).toBe(
scene.nodes.find((node) => node.backendNodeId === 1)?.id,
);
});
it("does not infer across mixed content boundaries", () => {
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["wrapper"],
},
{
nodeId: "wrapper",
parentId: "root",
backendDOMNodeId: 2,
role: { type: "role", value: "generic" },
childIds: ["first", "second", "paragraph"],
},
{
nodeId: "first",
parentId: "wrapper",
backendDOMNodeId: 3,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "First" },
},
{
nodeId: "second",
parentId: "wrapper",
backendDOMNodeId: 4,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Second" },
},
{
nodeId: "paragraph",
parentId: "wrapper",
backendDOMNodeId: 5,
role: { type: "role", value: "paragraph" },
},
],
[
dom(1, null, "body"),
dom(2, 1, "div"),
dom(3, 2, "button"),
dom(4, 2, "button"),
dom(5, 2, "p", {}, "Description"),
],
),
],
});
expect(scene.nodes.some((node) => node.role === "group")).toBe(false);
});
it("keeps only the innermost reliable inferred group", () => {
const scene = buildSemanticVomScene({
viewport: { width: 800, height: 600 },
rootFrameId: "main",
documents: [
document(
"main",
[
{
nodeId: "root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
childIds: ["outer"],
},
{
nodeId: "outer",
parentId: "root",
backendDOMNodeId: 2,
role: { type: "role", value: "generic" },
childIds: ["inner", "third"],
},
{
nodeId: "inner",
parentId: "outer",
backendDOMNodeId: 3,
role: { type: "role", value: "generic" },
childIds: ["first", "second"],
},
{
nodeId: "first",
parentId: "inner",
backendDOMNodeId: 4,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "First" },
},
{
nodeId: "second",
parentId: "inner",
backendDOMNodeId: 5,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Second" },
},
{
nodeId: "third",
parentId: "outer",
backendDOMNodeId: 6,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Third" },
},
],
[
dom(1, null, "body"),
dom(2, 1, "div"),
dom(3, 2, "div"),
dom(4, 3, "button"),
dom(5, 3, "button"),
dom(6, 2, "button"),
],
),
],
});
const groups = scene.nodes.filter((node) => node.role === "group");
expect(groups.map((node) => node.backendNodeId)).toEqual([3]);
expect(scene.nodes.find((node) => node.backendNodeId === 6)?.parentId).not.toBe(groups[0]?.id);
});
});
+36 -30
View File
@@ -1,17 +1,16 @@
// CDP capture adapter: DOMSnapshot.captureSnapshot + Page.getLayoutMetrics
// → CapturedNode[] + Viewport. This is the ONLY VOM module that touches
// raw CDP. The captureSnapshot reply is columnar (parallel arrays + a
// shared string table); requested computed styles are decoded through
// STYLE_COL so capture and visibility policy share one explicit contract.
// shared string table); we request exactly three computed styles so the
// `styles` columns are [position, pointer-events, cursor] in that order.
import type { Rect, Viewport } from "@browser-skill/vom";
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
import type { CdpRunner } from "../shared";
import { clearHover, waitForHover } from "./hover-perception";
const REQUESTED_STYLES = ["position", "pointer-events", "cursor", "visibility", "opacity"] as const;
const REQUESTED_STYLES = ["position", "pointer-events", "cursor"] as const;
const STYLE_COL = Object.fromEntries(
REQUESTED_STYLES.map((name, index) => [name, index]),
) as Record<(typeof REQUESTED_STYLES)[number], number>;
@@ -39,12 +38,6 @@ export interface CapturedNode {
* `textContent`: the live parser always sets it, hand-built fixtures may not.
*/
cursor?: string;
/**
* Whether the live DOM snapshot provides a painted, non-hidden box for this
* node. Semantic resolution uses this only for DOM fallback nodes; AX-backed
* nodes remain authoritative even when they are outside the viewport.
*/
rendered?: boolean;
textContent?: string;
formState?: "empty" | "filled" | "default";
formValue?: string;
@@ -467,6 +460,36 @@ function hoverStateExpression(): string {
})()`;
}
async function wait(ms: number, signal?: AbortSignal): Promise<void> {
throwIfAborted(signal);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(captureAbortError());
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
async function clearHover(cdp: CdpRunner, tabId: number): Promise<void> {
await cdp
.send(tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: -10,
y: -10,
})
.catch(() =>
cdp.send(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", x: 0, y: 0 }).catch(() => {
// best effort
}),
);
}
function capturedText(node: CapturedNode): string | undefined {
const value =
node.attrs["aria-label"] ?? node.attrs.title ?? node.attrs.alt ?? node.textContent ?? "";
@@ -646,7 +669,7 @@ async function probeHoverSurfaces(
try {
await clearHover(cdp, tabId);
throwIfAborted(options.signal);
await waitForHover(HOVER_SETTLE_MS, options.signal);
await wait(HOVER_SETTLE_MS, options.signal);
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: hoverStateExpression(),
returnByValue: true,
@@ -661,7 +684,7 @@ async function probeHoverSurfaces(
y: candidate.y,
});
throwIfAborted(options.signal);
await waitForHover(HOVER_SETTLE_MS, options.signal);
await wait(HOVER_SETTLE_MS, options.signal);
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: hoverStateExpression(),
returnByValue: true,
@@ -794,8 +817,6 @@ function parseDocumentNodes(
const posCol = STYLE_COL.position;
const peCol = STYLE_COL["pointer-events"];
const cursorCol = STYLE_COL.cursor;
const visibilityCol = STYLE_COL.visibility;
const opacityCol = STYLE_COL.opacity;
const inputValues = sparseIndexMap(dn.inputValue);
const textValues = sparseIndexMap(dn.textValue);
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
@@ -840,7 +861,6 @@ function parseDocumentNodes(
let position = "static";
let pointerEvents = "auto";
let cursor = "auto";
let rendered = false;
const li = layoutByNode.get(n);
if (li !== undefined) {
const b = dl?.bounds?.[li];
@@ -861,13 +881,6 @@ function parseDocumentNodes(
position = str(strings, styleRow[posCol]) || "static";
pointerEvents = str(strings, styleRow[peCol]) || "auto";
cursor = str(strings, styleRow[cursorCol]) || "auto";
const visibility = str(strings, styleRow[visibilityCol]) || "visible";
const opacity = str(strings, styleRow[opacityCol]) || "1";
rendered =
localRect !== null &&
visibility !== "hidden" &&
visibility !== "collapse" &&
(Number.parseFloat(opacity) || 0) > 0;
}
// Skip non-element nodes (#text, #cdata-section, etc.) — they carry no
@@ -903,7 +916,6 @@ function parseDocumentNodes(
position,
pointerEvents,
cursor,
rendered,
textContent,
...(formValue !== undefined ? { formValue } : {}),
...(tag === "input" || tag === "textarea"
@@ -1008,13 +1020,7 @@ export async function captureViewModel(
options: CaptureViewModelOptions = {},
): Promise<CapturedViewModel> {
throwIfAborted(options.signal);
let metrics: LayoutMetricsReply = {};
try {
metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
} catch (error) {
if (isAbortError(error)) throw error;
console.debug("[bsk capture] layout metrics unavailable", error);
}
const metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
throwIfAborted(options.signal);
const dpr = devicePixelRatio(metrics);
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
+22 -24
View File
@@ -33,19 +33,25 @@ async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFra
function transformFrameNodes(
nodes: CapturedNode[],
projection: GeometryProjection | null,
projection: GeometryProjection,
): CapturedNode[] {
return nodes.map((node) => ({
...node,
rect: projection
? (() => {
const bounds = projectRectToViewport(node.rect, projection);
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
})()
: null,
rect: (() => {
const bounds = projectRectToViewport(node.rect, projection);
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
})(),
}));
}
function frameOwnerNode(captured: CapturedViewModel, frame: CdpFrame): CapturedNode | undefined {
if (frame.ownerBackendNodeId === undefined) return undefined;
const parentNodes = frame.parentFrameId
? captured.frameNodes?.get(frame.parentFrameId)
: captured.nodes;
return parentNodes?.find((node) => node.backendNodeId === frame.ownerBackendNodeId);
}
async function captureMissingFrameDocuments(
cdp: CdpRunner,
tabId: number,
@@ -59,20 +65,14 @@ async function captureMissingFrameDocuments(
for (const frame of graph.frames) {
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
const owner = frameOwnerNode(captured, frame);
if (!owner?.rect || frame.ownerBackendNodeId === undefined) continue;
try {
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
signal,
});
let projection: GeometryProjection | null = null;
try {
projection = await resolveFrameProjection(cdp, graph, frame.frameId);
} catch (err) {
if (isAbortError(err)) throw err;
console.debug("[bsk observation] child frame geometry projection failed", {
frameId: frame.frameId,
err,
});
}
const projection = await resolveFrameProjection(cdp, graph, frame.frameId);
if (!projection) continue;
const rootFrameId = child.rootFrameId ?? frame.frameId;
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
@@ -85,13 +85,11 @@ async function captureMissingFrameDocuments(
for (const [childFrameId, nodes] of childFrames) {
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
}
if (frame.ownerBackendNodeId !== undefined) {
captured.iframeNodes.set(
frame.ownerBackendNodeId,
captured.frameNodes.get(frame.frameId) ?? [],
);
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
}
captured.iframeNodes.set(
frame.ownerBackendNodeId,
captured.frameNodes.get(frame.frameId) ?? [],
);
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
@@ -1,37 +0,0 @@
import type { CdpRunner } from "../shared";
function abortError(): Error {
const error = new Error("hover perception aborted");
error.name = "AbortError";
return error;
}
export async function waitForHover(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) throw abortError();
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(abortError());
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export async function clearHover(cdp: CdpRunner, tabId: number): Promise<void> {
await cdp
.send(tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: -10,
y: -10,
})
.catch(() =>
cdp.send(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", x: 0, y: 0 }).catch(() => {
// Best effort cleanup.
}),
);
}
@@ -1,296 +0,0 @@
import { isVomReferenceNode } from "@browser-skill/vom";
import { type CdpRunner, sendToCdpTarget } from "../shared";
import type { FrameDocument, FrameOwnedAxNode } from "./frame-document";
import { clearHover, waitForHover } from "./hover-perception";
import { stableIdentifierName } from "./semantic-graph/name-evidence";
import { frameBackendKey, type ResolvedSemanticGraph } from "./semantic-graph/types";
const MAX_TOOLTIP_CANDIDATES = 8;
const MAX_TOOLTIP_PROBE_MS = 2_400;
const TOOLTIP_SETTLE_MS = 250;
const CACHE_TTL_MS = 5 * 60_000;
const MAX_CACHE_ENTRIES = 500;
const FORM_CONTROL_TAGS = new Set(["input", "select", "textarea"]);
interface TooltipCacheEntry {
name: string;
expiresAt: number;
}
interface TooltipCandidate<T extends FrameOwnedAxNode> {
document: FrameDocument<T>;
backendNodeId: number;
x: number;
y: number;
stableIdentifier: boolean;
}
export interface TooltipNameProbeOptions {
signal?: AbortSignal;
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
}
const tooltipCache = new Map<string, TooltipCacheEntry>();
function cacheKey<T extends FrameOwnedAxNode>(
document: FrameDocument<T>,
backendNodeId: number,
): string {
return `${document.target.tabId}\u0000${document.frameId}\u0000${document.url ?? ""}\u0000${backendNodeId}`;
}
function cachedName<T extends FrameOwnedAxNode>(
document: FrameDocument<T>,
backendNodeId: number,
): string | undefined {
const key = cacheKey(document, backendNodeId);
const entry = tooltipCache.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
tooltipCache.delete(key);
return undefined;
}
return entry.name;
}
function storeName<T extends FrameOwnedAxNode>(
document: FrameDocument<T>,
backendNodeId: number,
name: string,
): void {
if (tooltipCache.size >= MAX_CACHE_ENTRIES) {
const oldest = tooltipCache.keys().next().value;
if (oldest !== undefined) tooltipCache.delete(oldest);
}
tooltipCache.set(cacheKey(document, backendNodeId), {
name,
expiresAt: Date.now() + CACHE_TTL_MS,
});
}
function tooltipCollectorFunction(): string {
return `function() {
const ownRoot = this && typeof this.getRootNode === "function" ? this.getRootNode() : null;
const roots = ownRoot && ownRoot !== document ? [ownRoot, document] : [document];
const items = [];
const seen = new Set();
for (const root of roots) {
for (const el of Array.from(root.querySelectorAll("[role='tooltip']"))) {
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
const text = String(el.getAttribute("aria-label") || el.getAttribute("title") || el.textContent || "")
.replace(/\\s+/g, " ")
.trim();
if (!text || text.length > 160 || seen.has(text)) continue;
seen.add(text);
items.push(text);
}
}
return items.slice(0, 20);
}`;
}
function cleanTooltipDiff(before: string[], after: string[]): string | undefined {
const existing = new Set(before.map((value) => value.toLowerCase()));
const added = [...new Set(after.map((value) => value.replace(/\s+/g, " ").trim()))].filter(
(value) => value && !existing.has(value.toLowerCase()),
);
return added.length === 1 ? added[0] : undefined;
}
function candidates<T extends FrameOwnedAxNode>(
documents: FrameDocument<T>[],
graph: ResolvedSemanticGraph,
): TooltipCandidate<T>[] {
const documentByFrame = new Map(documents.map((document) => [document.frameId, document]));
const byFrame = new Map<string, TooltipCandidate<T>[]>();
for (const node of graph.nodes.values()) {
if (
node.vom.name ||
FORM_CONTROL_TAGS.has(node.dom?.tag.toLowerCase() ?? "") ||
!node.referenceable ||
node.backendNodeId === undefined ||
!isVomReferenceNode({
...node.vom,
id: 0,
parentId: null,
backendNodeId: node.backendNodeId,
frameId: node.frameId,
referenceable: true,
})
) {
continue;
}
const document = documentByFrame.get(node.frameId);
const rect = node.dom?.rect;
if (!document || !rect || rect.w <= 0 || rect.h <= 0) continue;
if (
rect.x + rect.w <= 0 ||
rect.y + rect.h <= 0 ||
rect.x >= graph.viewport.width ||
rect.y >= graph.viewport.height
) {
continue;
}
if (
node.vom.disabled ||
node.vom.inert ||
node.vom.pointerEvents === "none" ||
node.vom.sensitive
) {
continue;
}
const frameCandidates = byFrame.get(node.frameId) ?? [];
frameCandidates.push({
document,
backendNodeId: node.backendNodeId,
x: (Math.max(0, rect.x) + Math.min(graph.viewport.width, rect.x + rect.w)) / 2,
y: (Math.max(0, rect.y) + Math.min(graph.viewport.height, rect.y + rect.h)) / 2,
stableIdentifier: stableIdentifierName(node.dom?.attrs ?? {}) !== undefined,
});
byFrame.set(node.frameId, frameCandidates);
}
const out: TooltipCandidate<T>[] = [];
for (const stableIdentifier of [true, false]) {
const queues = documents.map((document) =>
(byFrame.get(document.frameId) ?? []).filter(
(candidate) => candidate.stableIdentifier === stableIdentifier,
),
);
for (let index = 0; queues.some((queue) => index < queue.length); index += 1) {
for (const queue of queues) {
const candidate = queue[index];
if (candidate) out.push(candidate);
}
}
}
return out;
}
async function executionContextId<T extends FrameOwnedAxNode>(
cdp: CdpRunner,
document: FrameDocument<T>,
): Promise<number | undefined> {
const result = await sendToCdpTarget<{ executionContextId?: number }>(
cdp,
document.target,
"Page.createIsolatedWorld",
{
frameId: document.frameId,
worldName: "bsk-vom-tooltip",
grantUniveralAccess: false,
},
);
return result.executionContextId;
}
async function resolvedObjectId<T extends FrameOwnedAxNode>(
cdp: CdpRunner,
document: FrameDocument<T>,
contextId: number,
backendNodeId: number,
): Promise<string | undefined> {
const result = await sendToCdpTarget<{
object?: { objectId?: string };
}>(cdp, document.target, "DOM.resolveNode", {
backendNodeId,
executionContextId: contextId,
});
return result.object?.objectId;
}
async function tooltipTexts<T extends FrameOwnedAxNode>(
cdp: CdpRunner,
document: FrameDocument<T>,
objectId: string,
): Promise<string[]> {
const result = await sendToCdpTarget<{
result?: { value?: string[] };
}>(cdp, document.target, "Runtime.callFunctionOn", {
functionDeclaration: tooltipCollectorFunction(),
objectId,
returnByValue: true,
});
return result.result?.value ?? [];
}
async function releaseObject<T extends FrameOwnedAxNode>(
cdp: CdpRunner,
document: FrameDocument<T>,
objectId: string,
): Promise<void> {
await sendToCdpTarget(cdp, document.target, "Runtime.releaseObject", { objectId }).catch(
() => undefined,
);
}
export async function probeTooltipNames<T extends FrameOwnedAxNode>(
cdp: CdpRunner,
tabId: number,
documents: FrameDocument<T>[],
graph: ResolvedSemanticGraph,
options: TooltipNameProbeOptions = {},
): Promise<Map<string, string>> {
const names = new Map<string, string>();
const pending: TooltipCandidate<T>[] = [];
for (const candidate of candidates(documents, graph)) {
const cached = cachedName(candidate.document, candidate.backendNodeId);
if (cached) {
names.set(frameBackendKey(candidate.document.frameId, candidate.backendNodeId), cached);
} else if (pending.length < MAX_TOOLTIP_CANDIDATES) {
pending.push(candidate);
}
}
if (pending.length === 0) return names;
const started = Date.now();
const contexts = new Map<string, number>();
await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined);
try {
for (const candidate of pending) {
if (options.signal?.aborted) throw new DOMException("observation aborted", "AbortError");
if (Date.now() - started >= MAX_TOOLTIP_PROBE_MS) break;
let objectId: string | undefined;
try {
let contextId = contexts.get(candidate.document.frameId);
if (contextId === undefined) {
contextId = await executionContextId(cdp, candidate.document);
if (contextId === undefined) continue;
contexts.set(candidate.document.frameId, contextId);
}
objectId = await resolvedObjectId(
cdp,
candidate.document,
contextId,
candidate.backendNodeId,
);
if (!objectId) continue;
await clearHover(cdp, tabId);
const before = await tooltipTexts(cdp, candidate.document, objectId);
await cdp.send(tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: candidate.x,
y: candidate.y,
});
await waitForHover(TOOLTIP_SETTLE_MS, options.signal);
const after = await tooltipTexts(cdp, candidate.document, objectId);
const name = cleanTooltipDiff(before, after);
if (!name) continue;
names.set(frameBackendKey(candidate.document.frameId, candidate.backendNodeId), name);
storeName(candidate.document, candidate.backendNodeId, name);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw error;
} finally {
if (objectId) await releaseObject(cdp, candidate.document, objectId);
await clearHover(cdp, tabId);
}
}
} finally {
await clearHover(cdp, tabId);
await options.hoverProbeBypassOverlay?.(tabId, false).catch(() => undefined);
}
return names;
}
@@ -1,154 +0,0 @@
import type { Viewport } from "@browser-skill/vom";
import type { FrameDocument } from "../frame-document";
import {
frameAxKey,
frameBackendKey,
type SemanticAxNode,
type SemanticFrame,
type SemanticGraph,
type SemanticGraphNode,
type SemanticNodeId,
} from "./types";
export interface BuildSemanticGraphInput {
documents: FrameDocument<SemanticAxNode>[];
viewport: Viewport;
rootFrameId?: string;
excludedBackendNodeIds?: ReadonlySet<number>;
}
function domNodeId(frameId: string, backendNodeId: number): SemanticNodeId {
return JSON.stringify([frameId, "dom", backendNodeId]);
}
function axNodeId(frameId: string, nodeId: string): SemanticNodeId {
return JSON.stringify([frameId, "ax", nodeId]);
}
export function buildSemanticGraph(input: BuildSemanticGraphInput): SemanticGraph {
const rootFrameId =
input.rootFrameId ??
input.documents.find((document) => !document.parentFrameId)?.frameId ??
input.documents[0]?.frameId ??
"root";
const frames = new Map<string, SemanticFrame>();
const nodes = new Map<SemanticNodeId, SemanticGraphNode>();
const nodeIdsByFrame = new Map<string, SemanticNodeId[]>();
const nodeByFrameBackend = new Map<string, SemanticNodeId>();
const nodeByFrameAx = new Map<string, SemanticNodeId>();
for (const [frameOrder, document] of input.documents.entries()) {
frames.set(document.frameId, {
frameId: document.frameId,
...(document.parentFrameId ? { parentFrameId: document.parentFrameId } : {}),
...(document.ownerBackendNodeId !== undefined
? { ownerBackendNodeId: document.ownerBackendNodeId }
: {}),
contextScopeId: document.contextScopeId,
target: document.target,
...(document.url ? { url: document.url } : {}),
order: frameOrder,
});
const frameNodeIds: SemanticNodeId[] = [];
nodeIdsByFrame.set(document.frameId, frameNodeIds);
for (const [index, dom] of document.domNodes.entries()) {
const id = domNodeId(document.frameId, dom.backendNodeId);
const node: SemanticGraphNode = {
id,
frameId: document.frameId,
backendNodeId: dom.backendNodeId,
dom,
sourceOrder: index,
excluded:
document.frameId === rootFrameId &&
input.excludedBackendNodeIds?.has(dom.backendNodeId) === true,
};
nodes.set(id, node);
frameNodeIds.push(id);
nodeByFrameBackend.set(frameBackendKey(document.frameId, dom.backendNodeId), id);
}
for (const [index, ax] of document.axNodes.entries()) {
const joinedId =
typeof ax.backendDOMNodeId === "number"
? nodeByFrameBackend.get(frameBackendKey(document.frameId, ax.backendDOMNodeId))
: undefined;
const id = joinedId ?? axNodeId(document.frameId, ax.nodeId);
const existing = nodes.get(id);
if (existing) {
existing.ax = ax;
existing.axNodeId = ax.nodeId;
} else {
nodes.set(id, {
id,
frameId: document.frameId,
...(typeof ax.backendDOMNodeId === "number"
? { backendNodeId: ax.backendDOMNodeId }
: {}),
axNodeId: ax.nodeId,
ax,
sourceOrder: document.domNodes.length + index,
excluded:
document.frameId === rootFrameId &&
typeof ax.backendDOMNodeId === "number" &&
input.excludedBackendNodeIds?.has(ax.backendDOMNodeId) === true,
});
frameNodeIds.push(id);
}
nodeByFrameAx.set(frameAxKey(document.frameId, ax.nodeId), id);
}
}
for (const document of input.documents) {
for (const dom of document.domNodes) {
const id = nodeByFrameBackend.get(frameBackendKey(document.frameId, dom.backendNodeId));
const node = id ? nodes.get(id) : undefined;
if (!node || dom.parentBackendNodeId === null) continue;
node.domParentId = nodeByFrameBackend.get(
frameBackendKey(document.frameId, dom.parentBackendNodeId),
);
}
for (const ax of document.axNodes) {
const id = nodeByFrameAx.get(frameAxKey(document.frameId, ax.nodeId));
const node = id ? nodes.get(id) : undefined;
if (!node || !ax.parentId) continue;
node.axParentId = nodeByFrameAx.get(frameAxKey(document.frameId, ax.parentId));
}
}
const axChildren = new Map<SemanticNodeId, SemanticNodeId[]>();
const excludedQueue: SemanticNodeId[] = [];
for (const node of nodes.values()) {
if (node.axParentId) {
const children = axChildren.get(node.axParentId) ?? [];
children.push(node.id);
axChildren.set(node.axParentId, children);
}
if (node.excluded) excludedQueue.push(node.id);
}
for (let index = 0; index < excludedQueue.length; index += 1) {
for (const childId of axChildren.get(excludedQueue[index]) ?? []) {
const child = nodes.get(childId);
if (!child || child.excluded) continue;
child.excluded = true;
excludedQueue.push(childId);
}
}
for (const frame of frames.values()) {
if (!frame.parentFrameId || frame.ownerBackendNodeId === undefined) continue;
frame.ownerNodeId = nodeByFrameBackend.get(
frameBackendKey(frame.parentFrameId, frame.ownerBackendNodeId),
);
}
return {
viewport: input.viewport,
rootFrameId,
frames,
nodes,
nodeIdsByFrame,
nodeByFrameBackend,
};
}
@@ -1,59 +0,0 @@
import type { Viewport, VomScene } from "@browser-skill/vom";
import type { FrameDocument } from "../frame-document";
import { buildSemanticGraph as buildGraph } from "./build";
import { projectSemanticGraph as projectGraph } from "./project";
import { resolveSemanticGraph as resolveGraph } from "./resolve";
import { normalizeSemanticStructure as normalizeStructure } from "./structure";
import type {
ResolvedSemanticGraph,
SemanticAxNode,
SemanticGraph,
StructuredSemanticGraph,
} from "./types";
export type { SemanticAxNode } from "./types";
export interface SemanticVomInput {
documents: FrameDocument<SemanticAxNode>[];
viewport: Viewport;
rootFrameId?: string;
excludedBackendNodeIds?: ReadonlySet<number>;
}
export function buildSemanticGraph(input: SemanticVomInput): SemanticGraph {
return buildGraph(input);
}
export function resolveSemanticGraph(
graph: SemanticGraph,
options: {
supplementalNames?: ReadonlyMap<string, string>;
identifierFallback?: boolean;
} = {},
): ResolvedSemanticGraph {
return resolveGraph(graph, options);
}
export function normalizeSemanticStructure(graph: ResolvedSemanticGraph): StructuredSemanticGraph {
return normalizeStructure(graph);
}
export function projectSemanticGraph(graph: StructuredSemanticGraph): VomScene {
return projectGraph(graph);
}
export function buildSemanticVomScene(
input: SemanticVomInput & {
supplementalNames?: ReadonlyMap<string, string>;
identifierFallback?: boolean;
},
): VomScene {
return projectSemanticGraph(
normalizeSemanticStructure(
resolveSemanticGraph(buildSemanticGraph(input), {
supplementalNames: input.supplementalNames,
identifierFallback: input.identifierFallback,
}),
),
);
}
@@ -1,34 +0,0 @@
const IDENTIFIER_ATTRS = ["data-testid", "data-test-id", "id"] as const;
const GENERIC_TOKENS = new Set(["btn", "button", "control", "icon", "item", "test", "tool"]);
const GENERATED_PREFIXES = new Set(["ember", "headlessui", "mui", "radix", "react", "rc"]);
function identifierTokens(value: string): string[] {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.split(/[^a-zA-Z0-9]+/)
.map((token) => token.toLowerCase())
.filter(Boolean);
}
function readableIdentifier(value: string | undefined): string | undefined {
const raw = value?.trim();
if (!raw || raw.length < 3 || raw.length > 80) return undefined;
if (/^[a-f0-9]{8,}$/i.test(raw)) return undefined;
const tokens = identifierTokens(raw);
if (GENERATED_PREFIXES.has(tokens[0])) return undefined;
while (tokens.length > 1 && GENERIC_TOKENS.has(tokens[0])) tokens.shift();
while (tokens.length > 1 && GENERIC_TOKENS.has(tokens[tokens.length - 1])) tokens.pop();
if (tokens.length === 0 || !tokens.some((token) => /[a-z]{3,}/.test(token))) return undefined;
return tokens
.map((token, index) => (index === 0 ? `${token[0].toUpperCase()}${token.slice(1)}` : token))
.join(" ");
}
export function stableIdentifierName(attrs: Readonly<Record<string, string>>): string | undefined {
for (const name of IDENTIFIER_ATTRS) {
const label = readableIdentifier(attrs[name]);
if (label) return label;
}
return undefined;
}
@@ -1,85 +0,0 @@
import type { VomNode, VomScene } from "@browser-skill/vom";
import type { SemanticNodeId, StructuredSemanticGraph, StructuredSemanticNode } from "./types";
function retained(node: StructuredSemanticNode): boolean {
return node.structure.kind === "keep" || node.structure.kind === "inferred-group";
}
function connectedFrameIds(graph: StructuredSemanticGraph): Set<string> {
const connected = new Set<string>([graph.rootFrameId]);
const childrenByParent = new Map<string, string[]>();
for (const frame of graph.frames.values()) {
if (!frame.parentFrameId) continue;
const children = childrenByParent.get(frame.parentFrameId) ?? [];
children.push(frame.frameId);
childrenByParent.set(frame.parentFrameId, children);
}
const queue = [graph.rootFrameId];
for (let index = 0; index < queue.length; index += 1) {
for (const frameId of childrenByParent.get(queue[index]) ?? []) {
if (connected.has(frameId)) continue;
const frame = graph.frames.get(frameId);
if (!frame?.ownerNodeId) continue;
const owner = graph.nodes.get(frame.ownerNodeId);
if (!owner || !retained(owner)) continue;
connected.add(frame.frameId);
queue.push(frame.frameId);
}
}
return connected;
}
function domAncestors(
graph: StructuredSemanticGraph,
node: StructuredSemanticNode,
numericId: ReadonlyMap<SemanticNodeId, number>,
): number[] {
const ancestors: number[] = [];
const seen = new Set<SemanticNodeId>();
let current = node.domParentId;
while (current && !seen.has(current)) {
seen.add(current);
const id = numericId.get(current);
if (id !== undefined) ancestors.push(id);
current = graph.nodes.get(current)?.domParentId;
}
return ancestors;
}
export function projectSemanticGraph(graph: StructuredSemanticGraph): VomScene {
const connectedFrames = connectedFrameIds(graph);
const orderedNodes = [...graph.frames.values()]
.filter((frame) => connectedFrames.has(frame.frameId))
.sort((a, b) => a.order - b.order)
.flatMap((frame) =>
(graph.nodeIdsByFrame.get(frame.frameId) ?? [])
.map((id) => graph.nodes.get(id))
.filter((node): node is StructuredSemanticNode => node !== undefined && retained(node))
.sort((a, b) => a.sourceOrder - b.sourceOrder),
);
const numericId = new Map<SemanticNodeId, number>();
orderedNodes.forEach((node, index) => numericId.set(node.id, index + 1));
const nodes: VomNode[] = orderedNodes.map((node) => ({
...node.vom,
id: numericId.get(node.id) as number,
parentId: node.semanticParentId ? (numericId.get(node.semanticParentId) ?? null) : null,
...(node.backendNodeId !== undefined ? { backendNodeId: node.backendNodeId } : {}),
frameId: node.frameId,
contextScopeId: graph.frames.get(node.frameId)?.contextScopeId ?? node.frameId,
referenceable: node.referenceable,
...(node.dom
? {
domParentId:
node.domParentId === undefined ? null : (numericId.get(node.domParentId) ?? null),
domAncestorIds: domAncestors(graph, node, numericId),
}
: {}),
}));
return {
viewport: graph.viewport,
nodes,
rootFrameId: graph.rootFrameId,
};
}
@@ -1,521 +0,0 @@
import type { VomNode } from "@browser-skill/vom";
import { stableIdentifierName } from "./name-evidence";
import type {
ResolvedSemanticGraph,
ResolvedSemanticNode,
SemanticAxNode,
SemanticGraph,
SemanticGraphNode,
SemanticNodeId,
} from "./types";
import { frameBackendKey } from "./types";
const FORM_TAGS = new Set(["input", "textarea", "select"]);
const NATIVE_CONTROL_TAGS = new Set(["button", "input", "select", "textarea"]);
const TEXT_AX_ROLES = new Set(["statictext", "inlinetextbox", "labeltext", "text"]);
const INTERACTIVE_ROLES = new Set([
"button",
"checkbox",
"combobox",
"link",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"radio",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"textbox",
"treeitem",
]);
const SENSITIVE_INPUT_TYPES = new Set([
"password",
"credit-card",
"one-time-code",
"current-password",
"new-password",
]);
function clean(value: string | undefined): string | undefined {
const normalized = value?.replace(/\s+/g, " ").trim();
return normalized || undefined;
}
function axValue(field?: { value?: string | number | boolean }): string | undefined {
return field?.value === undefined ? undefined : clean(String(field.value));
}
function axProperty(node: SemanticAxNode | undefined, name: string): string | undefined {
return axValue(node?.properties?.find((property) => property.name === name)?.value);
}
function normalizeRole(value: string | undefined): string | undefined {
const role = clean(value)?.toLowerCase();
return role || undefined;
}
function nativeRole(tag: string, attrs: Record<string, string>): string | undefined {
if (tag === "button") return "button";
if (tag === "textarea") return "textbox";
if (tag === "select") return attrs.multiple === undefined ? "combobox" : "listbox";
if (tag === "a" && attrs.href) return "link";
if (tag === "iframe") return "Iframe";
if (tag === "dialog") return "dialog";
if (tag === "img") return "img";
if (tag === "table") return "table";
if (tag === "tr") return "row";
if (tag === "th") return "columnheader";
if (tag === "td") return "cell";
if (tag === "ul" || tag === "ol") return "list";
if (tag === "li") return "listitem";
if (/^h[1-6]$/.test(tag)) return "heading";
if (tag === "nav") return "navigation";
if (tag === "main") return "main";
if (tag === "form") return "form";
if (tag === "input") {
const type = (attrs.type ?? "text").toLowerCase();
if (type === "hidden") return undefined;
if (type === "checkbox") return "checkbox";
if (type === "radio") return "radio";
if (type === "range") return "slider";
if (type === "number") return "spinbutton";
if (type === "search") return "searchbox";
if (["button", "submit", "reset", "image"].includes(type)) return "button";
return "textbox";
}
return undefined;
}
function domRole(tag: string, attrs: Record<string, string>): string | undefined {
return normalizeRole(attrs.role) ?? nativeRole(tag, attrs);
}
function resolvedRole(node: SemanticGraphNode): {
value?: string;
source: ResolvedSemanticNode["roleSource"];
} {
const rawAxRole = clean(axValue(node.ax?.role));
const axRole = normalizeRole(rawAxRole);
const attrs = node.dom?.attrs ?? {};
const explicitDomRole = normalizeRole(attrs.role);
const nativeDomRole = nativeRole(node.dom?.tag.toLowerCase() ?? "", attrs);
const usableAxRole = node.ax?.ignored !== true;
if (usableAxRole && axRole && !["generic", "none", "presentation"].includes(axRole)) {
return { value: rawAxRole, source: "ax" };
}
if (explicitDomRole) return { value: explicitDomRole, source: "dom-explicit" };
if (nativeDomRole) return { value: nativeDomRole, source: "dom-native" };
if (rawAxRole) {
return { value: rawAxRole, source: usableAxRole ? "ax" : "ax-ignored" };
}
return { source: "none" };
}
function sensitive(node: SemanticGraphNode): boolean {
const attrs = node.dom?.attrs ?? {};
const type = (attrs.type ?? "").toLowerCase();
const autocomplete = (attrs.autocomplete ?? "").toLowerCase();
return (
type === "password" ||
autocomplete.startsWith("cc-") ||
SENSITIVE_INPUT_TYPES.has(autocomplete) ||
SENSITIVE_INPUT_TYPES.has(axProperty(node.ax, "inputType") ?? "")
);
}
function inputState(
node: SemanticGraphNode,
role: string | undefined,
value: string | undefined,
isSensitive: boolean,
): VomNode["inputState"] {
if (node.dom?.formState) return node.dom.formState;
const tag = node.dom?.tag.toLowerCase() ?? "";
if (!FORM_TAGS.has(tag) && !["textbox", "searchbox"].includes(role ?? "")) return undefined;
if (isSensitive) return value ? "filled" : "empty";
if (node.dom?.formValue !== undefined) {
if (node.dom.formValue === "") return "empty";
return node.dom.formValue === (node.dom.formDefaultValue ?? "") ? "default" : "filled";
}
return value ? "filled" : "empty";
}
function externalHrefHost(
href: string | undefined,
pageUrl: string | undefined,
): string | undefined {
if (!href || !pageUrl) return undefined;
try {
const page = new URL(pageUrl);
const target = new URL(href, page);
return target.origin === page.origin ? undefined : target.hostname;
} catch {
return undefined;
}
}
interface ResolveIndexes {
domChildren: Map<SemanticNodeId, SemanticNodeId[]>;
axChildren: Map<SemanticNodeId, SemanticNodeId[]>;
domIdIndex: Map<string, SemanticNodeId>;
labelForIndex: Map<string, SemanticNodeId[]>;
domText: (nodeId: SemanticNodeId) => string | undefined;
axText: (nodeId: SemanticNodeId) => string | undefined;
hasNativeDescendant: (nodeId: SemanticNodeId) => boolean;
}
function buildIndexes(graph: SemanticGraph): ResolveIndexes {
const domChildren = new Map<SemanticNodeId, SemanticNodeId[]>();
const axChildren = new Map<SemanticNodeId, SemanticNodeId[]>();
const domIdIndex = new Map<string, SemanticNodeId>();
const labelForIndex = new Map<string, SemanticNodeId[]>();
for (const node of graph.nodes.values()) {
if (node.domParentId) {
const children = domChildren.get(node.domParentId) ?? [];
children.push(node.id);
domChildren.set(node.domParentId, children);
}
if (node.axParentId) {
const children = axChildren.get(node.axParentId) ?? [];
children.push(node.id);
axChildren.set(node.axParentId, children);
}
const id = clean(node.dom?.attrs.id);
if (id) domIdIndex.set(`${node.frameId}\u0000${id}`, node.id);
const targetId = clean(node.dom?.attrs.for);
if (node.dom?.tag.toLowerCase() === "label" && targetId) {
const key = `${node.frameId}\u0000${targetId}`;
const labels = labelForIndex.get(key) ?? [];
labels.push(node.id);
labelForIndex.set(key, labels);
}
}
for (const [parentId, children] of axChildren) {
const order = graph.nodes.get(parentId)?.ax?.childIds ?? [];
const orderByAxId = new Map(order.map((nodeId, index) => [nodeId, index]));
children.sort((a, b) => {
const aIndex = orderByAxId.get(graph.nodes.get(a)?.axNodeId ?? "") ?? -1;
const bIndex = orderByAxId.get(graph.nodes.get(b)?.axNodeId ?? "") ?? -1;
return (
(aIndex < 0 ? Number.MAX_SAFE_INTEGER : aIndex) -
(bIndex < 0 ? Number.MAX_SAFE_INTEGER : bIndex)
);
});
}
const textMemo = new Map<SemanticNodeId, string | undefined>();
const resolvingText = new Set<SemanticNodeId>();
const domText = (nodeId: SemanticNodeId): string | undefined => {
if (textMemo.has(nodeId)) return textMemo.get(nodeId);
if (resolvingText.has(nodeId)) return undefined;
resolvingText.add(nodeId);
const node = graph.nodes.get(nodeId);
const parts: string[] = [];
const own = clean(node?.dom?.textContent);
if (own) parts.push(own);
for (const childId of domChildren.get(nodeId) ?? []) {
const child = graph.nodes.get(childId);
const childRole = child
? domRole(child.dom?.tag.toLowerCase() ?? "", child.dom?.attrs ?? {})
: undefined;
if (childRole && INTERACTIVE_ROLES.has(childRole.toLowerCase())) continue;
const childText = domText(childId);
if (childText) parts.push(childText);
if (parts.join(" ").length >= 240) break;
}
resolvingText.delete(nodeId);
const result = clean(parts.join(" ").slice(0, 240));
textMemo.set(nodeId, result);
return result;
};
const axTextMemo = new Map<SemanticNodeId, string | undefined>();
const resolvingAxText = new Set<SemanticNodeId>();
const axText = (nodeId: SemanticNodeId): string | undefined => {
if (axTextMemo.has(nodeId)) return axTextMemo.get(nodeId);
if (resolvingAxText.has(nodeId)) return undefined;
resolvingAxText.add(nodeId);
const complete = (value: string | undefined): string | undefined => {
resolvingAxText.delete(nodeId);
axTextMemo.set(nodeId, value);
return value;
};
const node = graph.nodes.get(nodeId);
const role = normalizeRole(axValue(node?.ax?.role)) ?? "";
if (TEXT_AX_ROLES.has(role)) {
const own = clean(axValue(node?.ax?.name) ?? axValue(node?.ax?.value));
if (own) return complete(own);
}
const parts: string[] = [];
for (const childId of axChildren.get(nodeId) ?? []) {
const child = graph.nodes.get(childId);
const childRole = normalizeRole(axValue(child?.ax?.role)) ?? "";
if (INTERACTIVE_ROLES.has(childRole)) continue;
const childText = axText(childId);
if (childText) parts.push(childText);
if (parts.join(" ").length >= 240) break;
}
return complete(clean(parts.join(" ").slice(0, 240)));
};
const nativeMemo = new Map<SemanticNodeId, boolean>();
const hasNativeDescendant = (nodeId: SemanticNodeId): boolean => {
const cached = nativeMemo.get(nodeId);
if (cached !== undefined) return cached;
nativeMemo.set(nodeId, false);
for (const childId of domChildren.get(nodeId) ?? []) {
const child = graph.nodes.get(childId);
const childRole =
(child?.ax?.ignored === true ? undefined : normalizeRole(axValue(child?.ax?.role))) ??
(child ? domRole(child.dom?.tag.toLowerCase() ?? "", child.dom?.attrs ?? {}) : undefined);
if (
(child?.dom && NATIVE_CONTROL_TAGS.has(child.dom.tag.toLowerCase())) ||
INTERACTIVE_ROLES.has(childRole ?? "") ||
hasNativeDescendant(childId)
) {
nativeMemo.set(nodeId, true);
return true;
}
}
return false;
};
return {
domChildren,
axChildren,
domIdIndex,
labelForIndex,
domText,
axText,
hasNativeDescendant,
};
}
function ariaLabelledText(node: SemanticGraphNode, indexes: ResolveIndexes): string | undefined {
const ids = (node.dom?.attrs["aria-labelledby"] ?? "").split(/\s+/).filter(Boolean);
const parts = ids.flatMap((id) => {
const target = indexes.domIdIndex.get(`${node.frameId}\u0000${id}`);
return target ? (indexes.domText(target) ?? []) : [];
});
return clean(parts.join(" "));
}
function nativeLabelText(
node: SemanticGraphNode,
graph: SemanticGraph,
indexes: ResolveIndexes,
): string | undefined {
const id = clean(node.dom?.attrs.id);
if (id) {
const labels = indexes.labelForIndex.get(`${node.frameId}\u0000${id}`) ?? [];
const explicit = clean(
labels
.map((labelId) => indexes.domText(labelId))
.filter(Boolean)
.join(" "),
);
if (explicit) return explicit;
}
let parentId = node.domParentId;
let guard = 0;
while (parentId && guard <= graph.nodes.size) {
const parent = graph.nodes.get(parentId);
if (!parent) break;
if (parent.dom?.tag.toLowerCase() === "label") return indexes.domText(parentId);
parentId = parent.domParentId;
guard += 1;
}
return undefined;
}
function precedingAxText(
node: SemanticGraphNode,
graph: SemanticGraph,
indexes: ResolveIndexes,
): string | undefined {
if (!node.axParentId) return undefined;
const siblings = indexes.axChildren.get(node.axParentId) ?? [];
const index = siblings.indexOf(node.id);
for (let offset = 1; offset <= 4 && index - offset >= 0; offset += 1) {
const sibling = graph.nodes.get(siblings[index - offset]);
const role = normalizeRole(axValue(sibling?.ax?.role)) ?? "";
if (INTERACTIVE_ROLES.has(role)) break;
if (!TEXT_AX_ROLES.has(role) && role !== "generic") continue;
const text = clean(axValue(sibling?.ax?.name) ?? axValue(sibling?.ax?.value))?.replace(
/[::]\s*$/,
"",
);
if (text && text.length <= 80) return text;
}
return undefined;
}
function precedingDomText(
node: SemanticGraphNode,
graph: SemanticGraph,
indexes: ResolveIndexes,
): string | undefined {
if (!node.domParentId) return undefined;
const siblings = indexes.domChildren.get(node.domParentId) ?? [];
const index = siblings.indexOf(node.id);
for (let offset = 1; offset <= 4 && index - offset >= 0; offset += 1) {
const sibling = graph.nodes.get(siblings[index - offset]);
const siblingRole = sibling
? domRole(sibling.dom?.tag.toLowerCase() ?? "", sibling.dom?.attrs ?? {})
: undefined;
if (siblingRole && INTERACTIVE_ROLES.has(siblingRole.toLowerCase())) break;
const text = indexes.domText(siblings[index - offset])?.replace(/[::]\s*$/, "");
if (text && text.length <= 80) return text;
}
return undefined;
}
function resolvedName(
node: SemanticGraphNode,
role: string | undefined,
graph: SemanticGraph,
indexes: ResolveIndexes,
supplementalNames: ReadonlyMap<string, string>,
identifierFallback: boolean,
): string | undefined {
const attrs = node.dom?.attrs ?? {};
const formControl = FORM_TAGS.has(node.dom?.tag.toLowerCase() ?? "");
const descendantText = indexes.domText(node.id);
const interactive =
INTERACTIVE_ROLES.has(role?.toLowerCase() ?? "") ||
NATIVE_CONTROL_TAGS.has(node.dom?.tag.toLowerCase() ?? "");
const candidates = [
node.ax?.ignored === true ? undefined : axValue(node.ax?.name),
ariaLabelledText(node, indexes),
clean(attrs["aria-label"]),
nativeLabelText(node, graph, indexes),
clean(attrs.title),
clean(attrs.alt),
node.ax?.ignored === true ? undefined : indexes.axText(node.id),
descendantText,
formControl ? precedingAxText(node, graph, indexes) : undefined,
formControl ? precedingDomText(node, graph, indexes) : undefined,
formControl ? clean(node.dom?.formPlaceholder ?? attrs.placeholder) : undefined,
formControl || !interactive || node.backendNodeId === undefined
? undefined
: clean(supplementalNames.get(frameBackendKey(node.frameId, node.backendNodeId))),
identifierFallback && interactive ? stableIdentifierName(attrs) : undefined,
];
let name = candidates.find((candidate) => candidate !== undefined);
const hasPopup = axProperty(node.ax, "hasPopup");
if (name && hasPopup && hasPopup !== "false") {
name =
axProperty(node.ax, "expanded") === "true" ? `${name} [expanded]` : `${name} [has-submenu]`;
}
if (!name && role?.toLowerCase() === "iframe") return clean(attrs.id);
return name;
}
function insideNative(node: SemanticGraphNode, graph: SemanticGraph): boolean {
let parentId = node.domParentId;
let guard = 0;
while (parentId && guard <= graph.nodes.size) {
const parent = graph.nodes.get(parentId);
if (!parent) break;
if (parent.dom && NATIVE_CONTROL_TAGS.has(parent.dom.tag.toLowerCase())) return true;
parentId = parent.domParentId;
guard += 1;
}
return false;
}
function nearbyText(
node: SemanticGraphNode,
graph: SemanticGraph,
indexes: ResolveIndexes,
): string | undefined {
if (!node.domParentId) return undefined;
const siblings = indexes.domChildren.get(node.domParentId) ?? [];
const index = siblings.indexOf(node.id);
if (index < 0) return undefined;
const texts = siblings
.slice(Math.max(0, index - 3), index + 4)
.filter((id) => id !== node.id)
.flatMap((id) => indexes.domText(id) ?? [])
.slice(0, 3);
return clean(texts.join(" "));
}
export function resolveSemanticGraph(
graph: SemanticGraph,
options: {
supplementalNames?: ReadonlyMap<string, string>;
identifierFallback?: boolean;
} = {},
): ResolvedSemanticGraph {
const indexes = buildIndexes(graph);
const supplementalNames = options.supplementalNames ?? new Map<string, string>();
const identifierFallback = options.identifierFallback ?? true;
const nodes = new Map<SemanticNodeId, ResolvedSemanticNode>();
for (const node of graph.nodes.values()) {
const roleResolution = resolvedRole(node);
const role = roleResolution.value;
const name = resolvedName(node, role, graph, indexes, supplementalNames, identifierFallback);
const isSensitive = sensitive(node);
const sourceValue = node.dom?.formValue ?? axValue(node.ax?.value);
const value = isSensitive ? undefined : sourceValue;
const attrs = { ...(node.dom?.attrs ?? {}) };
if (isSensitive) delete attrs.value;
const frame = graph.frames.get(node.frameId);
const tag = node.dom?.tag.toLowerCase() ?? "";
const vom: ResolvedSemanticNode["vom"] = {
tag,
rect: node.dom?.rect ?? null,
paintOrder: node.dom?.paintOrder ?? 0,
position: node.dom?.position || "static",
pointerEvents: node.dom?.pointerEvents || "auto",
...(node.dom?.cursor ? { cursor: node.dom.cursor } : {}),
...(role ? { role } : {}),
...(name ? { name } : {}),
...(value !== undefined ? { value } : {}),
...(clean(node.dom?.formPlaceholder ?? attrs.placeholder)
? { placeholder: clean(node.dom?.formPlaceholder ?? attrs.placeholder) }
: {}),
attrs,
...(clean(node.dom?.textContent) ? { text: clean(node.dom?.textContent) } : {}),
...(nearbyText(node, graph, indexes) ? { nearbyText: nearbyText(node, graph, indexes) } : {}),
inputState: inputState(node, role, sourceValue, isSensitive),
sensitive: isSensitive,
modal:
["dialog", "alertdialog"].includes(role?.toLowerCase() ?? "") ||
tag === "dialog" ||
(attrs["aria-modal"] ?? "").toLowerCase() === "true",
disabled:
Object.prototype.hasOwnProperty.call(attrs, "disabled") ||
(attrs["aria-disabled"] ?? "").toLowerCase() === "true",
inert: Object.prototype.hasOwnProperty.call(attrs, "inert"),
hasNativeDescendant: indexes.hasNativeDescendant(node.id),
insideNative: insideNative(node, graph),
...(role?.toLowerCase() === "link" ? { href: externalHrefHost(attrs.href, frame?.url) } : {}),
};
const selected = node.ax?.ignored === true ? undefined : axProperty(node.ax, "selected");
const expanded = node.ax?.ignored === true ? undefined : axProperty(node.ax, "expanded");
const controls = node.ax?.ignored === true ? undefined : axProperty(node.ax, "controls");
if (selected === "true" && !vom.attrs?.["aria-selected"]) {
vom.attrs = { ...(vom.attrs ?? {}), "aria-selected": "true" };
}
if (expanded === "true" && !vom.attrs?.["aria-expanded"]) {
vom.attrs = { ...(vom.attrs ?? {}), "aria-expanded": "true" };
}
if (controls && !vom.attrs?.["aria-controls"]) {
vom.attrs = { ...(vom.attrs ?? {}), "aria-controls": controls };
}
nodes.set(node.id, {
...node,
vom,
referenceable: node.backendNodeId !== undefined,
roleSource: roleResolution.source,
});
}
return { ...graph, nodes };
}
@@ -1,456 +0,0 @@
import {
applyVomInteractionRecovery,
isVomReferenceNode,
isVomStructuralRole,
type VomNode,
} from "@browser-skill/vom";
import type {
ResolvedSemanticGraph,
ResolvedSemanticNode,
SemanticNodeId,
StructureDecision,
StructuredSemanticGraph,
StructuredSemanticNode,
} from "./types";
const TRANSPARENT_ROLES = new Set(["", "generic", "none", "presentation", "inlinetextbox"]);
const ROOT_ROLES = new Set(["rootwebarea", "webarea"]);
const CONTENT_BOUNDARY_ROLES = new Set([
"article",
"dialog",
"document",
"feed",
"form",
"grid",
"heading",
"iframe",
"list",
"main",
"navigation",
"paragraph",
"region",
"section",
"table",
"tree",
]);
function hasMeaningfulRelations(node: ResolvedSemanticNode): boolean {
const attrs = node.vom.attrs ?? {};
return ["aria-controls", "aria-labelledby", "aria-owns", "aria-describedby"].some((name) =>
attrs[name]?.trim(),
);
}
function explicitStructural(node: ResolvedSemanticNode): boolean {
return (
isVomStructuralRole(node.vom.role) &&
node.roleSource !== "none" &&
node.roleSource !== "ax-ignored"
);
}
function hasUsableAxSemantics(node: ResolvedSemanticNode): boolean {
return node.ax !== undefined && node.ax.ignored !== true;
}
function isRenderedDomFallback(node: ResolvedSemanticNode): boolean {
if (!node.dom) return false;
const attrs = node.dom.attrs;
if (
Object.prototype.hasOwnProperty.call(attrs, "hidden") ||
Object.prototype.hasOwnProperty.call(attrs, "inert") ||
(attrs["aria-hidden"] ?? "").toLowerCase() === "true"
) {
return false;
}
return node.dom.rendered ?? (node.dom.localRect != null || node.dom.rect != null);
}
function initialParentId(
graph: ResolvedSemanticGraph,
node: ResolvedSemanticNode,
): SemanticNodeId | undefined {
let parentId = node.axParentId ?? node.domParentId;
if (node.axParentId && node.domParentId && node.axParentId !== node.domParentId) {
const axParent = graph.nodes.get(node.axParentId);
const axRole = axParent?.vom.role?.toLowerCase() ?? "";
const meaningfulAxParent =
axParent !== undefined &&
!ROOT_ROLES.has(axRole) &&
(explicitStructural(axParent) ||
!TRANSPARENT_ROLES.has(axRole) ||
Boolean(axParent.vom.name || axParent.vom.text || hasMeaningfulRelations(axParent)));
parentId = meaningfulAxParent ? node.axParentId : node.domParentId;
}
if (parentId) return parentId;
if (node.frameId === graph.rootFrameId) return undefined;
return graph.frames.get(node.frameId)?.ownerNodeId;
}
function orderedNodes(graph: ResolvedSemanticGraph): ResolvedSemanticNode[] {
return [...graph.frames.values()]
.sort((a, b) => a.order - b.order)
.flatMap((frame) =>
(graph.nodeIdsByFrame.get(frame.frameId) ?? [])
.map((id) => graph.nodes.get(id))
.filter((node): node is ResolvedSemanticNode => node !== undefined)
.sort((a, b) => a.sourceOrder - b.sourceOrder),
);
}
function recoveredNodes(
graph: ResolvedSemanticGraph,
nodes: ResolvedSemanticNode[],
): Map<SemanticNodeId, VomNode> {
const numericId = new Map(nodes.map((node, index) => [node.id, index + 1]));
const semanticId = new Map([...numericId].map(([id, numeric]) => [numeric, id]));
const provisional = nodes.map((node) => ({
...node.vom,
id: numericId.get(node.id) as number,
parentId: (() => {
const parent = initialParentId(graph, node);
return parent ? (numericId.get(parent) ?? null) : null;
})(),
...(node.backendNodeId !== undefined ? { backendNodeId: node.backendNodeId } : {}),
frameId: node.frameId,
contextScopeId: graph.frames.get(node.frameId)?.contextScopeId ?? node.frameId,
referenceable: node.referenceable,
}));
return new Map(
applyVomInteractionRecovery(provisional).flatMap((node) => {
const id = semanticId.get(node.id);
return id ? [[id, node] as const] : [];
}),
);
}
function redundantSourceNodes(
graph: ResolvedSemanticGraph,
nodes: ResolvedSemanticNode[],
recovered: ReadonlyMap<SemanticNodeId, VomNode>,
): Set<SemanticNodeId> {
const byKey = new Map<string, ResolvedSemanticNode[]>();
for (const node of nodes) {
const output = recovered.get(node.id) as VomNode;
const name = output.name?.trim().toLowerCase() ?? "";
if (!name || !isVomReferenceNode({ ...output, referenceable: true })) continue;
const key = `${node.frameId}\u0000${initialParentId(graph, node) ?? ""}\u0000${output.role?.toLowerCase() ?? ""}\u0000${name}\u0000${output.sensitive === true}`;
const matches = byKey.get(key) ?? [];
matches.push(node);
byKey.set(key, matches);
}
const redundant = new Set<SemanticNodeId>();
for (const matches of byKey.values()) {
const axOnly = matches.filter((node) => node.ax && !node.dom);
const domOnly = matches.filter((node) => node.dom && !node.ax);
if (matches.length === 2 && axOnly.length === 1 && domOnly.length === 1) {
redundant.add(domOnly[0].id);
}
}
return redundant;
}
function baseDecision(
graph: ResolvedSemanticGraph,
node: ResolvedSemanticNode,
output: VomNode,
frameOwnerIds: ReadonlySet<SemanticNodeId>,
redundant: ReadonlySet<SemanticNodeId>,
): StructureDecision {
if (node.excluded) return { kind: "excluded", reason: "excluded" };
if (redundant.has(node.id)) return { kind: "excluded", reason: "redundant-source" };
if (!hasUsableAxSemantics(node) && !isRenderedDomFallback(node)) {
return { kind: "excluded", reason: "non-rendered-dom-fallback" };
}
if (frameOwnerIds.has(node.id)) return { kind: "keep", reason: "frame-owner" };
if (node.frameId !== graph.rootFrameId && ROOT_ROLES.has(output.role?.toLowerCase() ?? "")) {
return { kind: "transparent", reason: "child-document-root" };
}
if (node.ax?.ignored === true && !node.dom) {
return { kind: "transparent", reason: "ax-ignored" };
}
const role = output.role?.toLowerCase() ?? "";
if (!TRANSPARENT_ROLES.has(role)) return { kind: "keep", reason: "semantic" };
if (
output.name ||
output.text ||
output.value ||
output.placeholder ||
hasMeaningfulRelations(node) ||
(output.rect && ["fixed", "sticky"].includes(output.position))
) {
return { kind: "keep", reason: "semantic" };
}
return { kind: "transparent", reason: "wrapper" };
}
function buildChildren(
nodes: ResolvedSemanticNode[],
relation: "axParentId" | "domParentId",
): Map<SemanticNodeId, SemanticNodeId[]> {
const children = new Map<SemanticNodeId, SemanticNodeId[]>();
for (const node of nodes) {
const parentId = node[relation];
if (!parentId) continue;
const siblings = children.get(parentId) ?? [];
siblings.push(node.id);
children.set(parentId, siblings);
}
return children;
}
function explicitDomAncestors(
graph: ResolvedSemanticGraph,
nodes: ResolvedSemanticNode[],
domChildren: ReadonlyMap<SemanticNodeId, SemanticNodeId[]>,
): Set<SemanticNodeId> {
const inside = new Set<SemanticNodeId>();
const roots = nodes.filter((node) => !node.domParentId);
const queue = roots.map((node) => node.id);
for (let index = 0; index < queue.length; index += 1) {
const parentId = queue[index];
const parent = graph.nodes.get(parentId);
const parentIsBoundary =
parent !== undefined &&
explicitStructural(parent) &&
!ROOT_ROLES.has(parent.vom.role?.toLowerCase() ?? "");
for (const childId of domChildren.get(parentId) ?? []) {
if (inside.has(parentId) || parentIsBoundary) inside.add(childId);
queue.push(childId);
}
}
return inside;
}
function reliableGroupCandidates(
graph: ResolvedSemanticGraph,
nodes: ResolvedSemanticNode[],
recovered: ReadonlyMap<SemanticNodeId, VomNode>,
decisions: ReadonlyMap<SemanticNodeId, StructureDecision>,
domChildren: ReadonlyMap<SemanticNodeId, SemanticNodeId[]>,
): Set<SemanticNodeId> {
const insideExplicitStructure = explicitDomAncestors(graph, nodes, domChildren);
const remainingChildren = new Map(
nodes.map((node) => [node.id, domChildren.get(node.id)?.length ?? 0]),
);
const summaries = new Map<SemanticNodeId, { blocked: boolean; interactions: number }>();
const queue = nodes
.filter((node) => (remainingChildren.get(node.id) ?? 0) === 0)
.map((node) => node.id);
for (let index = 0; index < queue.length; index += 1) {
const nodeId = queue[index];
const node = graph.nodes.get(nodeId);
const output = recovered.get(nodeId);
if (!node || !output) continue;
const role = output.role?.toLowerCase() ?? "";
const excluded = decisions.get(nodeId)?.kind === "excluded";
const blocked = excluded || explicitStructural(node) || CONTENT_BOUNDARY_ROLES.has(role);
const summary = excluded
? { blocked: true, interactions: 0 }
: isVomReferenceNode(output)
? { blocked: false, interactions: 1 }
: blocked
? { blocked: true, interactions: 0 }
: (domChildren.get(nodeId) ?? []).reduce(
(combined, childId) => {
const child = summaries.get(childId);
return {
blocked: combined.blocked || !child || child.blocked,
interactions: Math.min(2, combined.interactions + (child?.interactions ?? 0)),
};
},
{ blocked: false, interactions: 0 },
);
summaries.set(nodeId, summary);
const parentId = node.domParentId;
if (!parentId) continue;
const remaining = (remainingChildren.get(parentId) ?? 1) - 1;
remainingChildren.set(parentId, remaining);
if (remaining === 0) queue.push(parentId);
}
const candidates = new Set<SemanticNodeId>();
for (const node of nodes) {
const output = recovered.get(node.id) as VomNode;
const role = output.role?.toLowerCase() ?? "";
if (
!node.dom ||
node.dom.tag.toLowerCase() !== "div" ||
!TRANSPARENT_ROLES.has(role) ||
insideExplicitStructure.has(node.id) ||
decisions.get(node.id)?.kind === "excluded" ||
node.dom.attrs.hidden !== undefined ||
(node.dom.attrs["aria-hidden"] ?? "").toLowerCase() === "true" ||
node.dom.attrs.inert !== undefined
) {
continue;
}
const branches = (domChildren.get(node.id) ?? []).map((id) => summaries.get(id));
const blocked = branches.some((summary) => !summary || summary.blocked);
const contributingBranches = branches.filter(
(summary) => (summary?.interactions ?? 0) > 0,
).length;
const interactions = branches.reduce(
(count, summary) => Math.min(2, count + (summary?.interactions ?? 0)),
0,
);
if (!blocked && contributingBranches >= 2 && interactions >= 2) {
candidates.add(node.id);
}
}
return candidates;
}
function leafCandidates(
nodes: ResolvedSemanticNode[],
candidates: ReadonlySet<SemanticNodeId>,
domChildren: ReadonlyMap<SemanticNodeId, SemanticNodeId[]>,
): Set<SemanticNodeId> {
const parentById = new Map(
nodes.flatMap((node) => (node.domParentId ? ([[node.id, node.domParentId]] as const) : [])),
);
const childCount = new Map(nodes.map((node) => [node.id, domChildren.get(node.id)?.length ?? 0]));
const containsCandidate = new Set<SemanticNodeId>();
const queue = nodes.filter((node) => (childCount.get(node.id) ?? 0) === 0).map((node) => node.id);
const selected = new Set<SemanticNodeId>();
for (let index = 0; index < queue.length; index += 1) {
const nodeId = queue[index];
if (candidates.has(nodeId) && !containsCandidate.has(nodeId)) selected.add(nodeId);
const parentId = parentById.get(nodeId);
if (!parentId) continue;
if (candidates.has(nodeId) || containsCandidate.has(nodeId)) containsCandidate.add(parentId);
const remaining = (childCount.get(parentId) ?? 1) - 1;
childCount.set(parentId, remaining);
if (remaining === 0) queue.push(parentId);
}
return selected;
}
function nearestKept(
graph: ResolvedSemanticGraph,
startId: SemanticNodeId | undefined,
relation: "axParentId" | "domParentId",
kept: ReadonlySet<SemanticNodeId>,
memo: Map<SemanticNodeId, SemanticNodeId | undefined>,
): SemanticNodeId | undefined {
let currentId = startId;
const path: SemanticNodeId[] = [];
const seen = new Set<SemanticNodeId>();
let result: SemanticNodeId | undefined;
while (currentId && !seen.has(currentId)) {
if (kept.has(currentId)) {
result = currentId;
break;
}
if (memo.has(currentId)) {
result = memo.get(currentId);
break;
}
seen.add(currentId);
path.push(currentId);
currentId = graph.nodes.get(currentId)?.[relation];
}
for (const id of path) memo.set(id, result);
return result;
}
function finalParentId(
graph: ResolvedSemanticGraph,
node: ResolvedSemanticNode,
kept: ReadonlySet<SemanticNodeId>,
decisions: ReadonlyMap<SemanticNodeId, StructureDecision>,
axParentMemo: Map<SemanticNodeId, SemanticNodeId | undefined>,
domParentMemo: Map<SemanticNodeId, SemanticNodeId | undefined>,
): SemanticNodeId | undefined {
const axParent = nearestKept(graph, node.axParentId, "axParentId", kept, axParentMemo);
const domParent = nearestKept(graph, node.domParentId, "domParentId", kept, domParentMemo);
const axNode = axParent ? graph.nodes.get(axParent) : undefined;
const domNode = domParent ? graph.nodes.get(domParent) : undefined;
const axIsRoot = ROOT_ROLES.has(axNode?.vom.role?.toLowerCase() ?? "");
const axIsExplicit = axNode ? explicitStructural(axNode) && !axIsRoot : false;
const domIsStructural =
domNode !== undefined &&
(explicitStructural(domNode) || decisions.get(domNode.id)?.kind === "inferred-group");
if (axIsExplicit) return axParent;
if (domIsStructural) return domParent;
if (axParent && !axIsRoot) return axParent;
if (domParent) return domParent;
if (axParent) return axParent;
if (node.frameId === graph.rootFrameId) return undefined;
return graph.frames.get(node.frameId)?.ownerNodeId;
}
function recoveredVom(node: VomNode, inferredGroup: boolean): ResolvedSemanticNode["vom"] {
const {
id: _id,
parentId: _parentId,
backendNodeId: _backendNodeId,
frameId: _frameId,
contextScopeId: _contextScopeId,
referenceable: _referenceable,
...vom
} = node;
if (!inferredGroup) return vom;
const { name: _name, text: _text, nearbyText: _nearbyText, ...groupVom } = vom;
return { ...groupVom, role: "group" };
}
export function normalizeSemanticStructure(graph: ResolvedSemanticGraph): StructuredSemanticGraph {
const nodes = orderedNodes(graph);
const recovered = recoveredNodes(graph, nodes);
const redundant = redundantSourceNodes(graph, nodes, recovered);
const frameOwnerIds = new Set(
[...graph.frames.values()].flatMap((frame) => frame.ownerNodeId ?? []),
);
const decisions = new Map<SemanticNodeId, StructureDecision>();
for (const node of nodes) {
decisions.set(
node.id,
baseDecision(graph, node, recovered.get(node.id) as VomNode, frameOwnerIds, redundant),
);
}
const domChildren = buildChildren(nodes, "domParentId");
const candidates = reliableGroupCandidates(graph, nodes, recovered, decisions, domChildren);
for (const nodeId of leafCandidates(nodes, candidates, domChildren)) {
decisions.set(nodeId, {
kind: "inferred-group",
reason: "independent-interaction-branches",
});
}
const kept = new Set(
nodes
.filter((node) => {
const kind = decisions.get(node.id)?.kind;
return kind === "keep" || kind === "inferred-group";
})
.map((node) => node.id),
);
const axParentMemo = new Map<SemanticNodeId, SemanticNodeId | undefined>();
const domParentMemo = new Map<SemanticNodeId, SemanticNodeId | undefined>();
const structuredNodes = new Map<SemanticNodeId, StructuredSemanticNode>();
for (const node of nodes) {
const structure = decisions.get(node.id) as StructureDecision;
const inferredGroup = structure.kind === "inferred-group";
structuredNodes.set(node.id, {
...node,
vom: recoveredVom(recovered.get(node.id) as VomNode, inferredGroup),
structure,
...(kept.has(node.id)
? {
semanticParentId: finalParentId(
graph,
node,
kept,
decisions,
axParentMemo,
domParentMemo,
),
}
: {}),
});
}
return { ...graph, nodes: structuredNodes };
}
@@ -1,88 +0,0 @@
import type { Viewport, VomNode } from "@browser-skill/vom";
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { CapturedNode } from "../capture";
export interface SemanticAxNode {
nodeId: string;
frameId?: string;
parentId?: string;
backendDOMNodeId?: number;
ignored?: boolean;
role?: { type: string; value?: string };
name?: { type: string; value?: string };
description?: { value?: string };
value?: { value?: string | number | boolean };
properties?: Array<{ name?: string; value?: { value?: string | number | boolean } }>;
childIds?: string[];
}
export type SemanticNodeId = string;
export interface SemanticFrame {
frameId: string;
parentFrameId?: string;
ownerBackendNodeId?: number;
ownerNodeId?: SemanticNodeId;
contextScopeId: string;
target: CdpTarget;
url?: string;
order: number;
}
export interface SemanticGraphNode {
id: SemanticNodeId;
frameId: string;
backendNodeId?: number;
axNodeId?: string;
ax?: SemanticAxNode;
dom?: CapturedNode;
axParentId?: SemanticNodeId;
domParentId?: SemanticNodeId;
sourceOrder: number;
excluded: boolean;
}
export interface SemanticGraph {
viewport: Viewport;
rootFrameId: string;
frames: Map<string, SemanticFrame>;
nodes: Map<SemanticNodeId, SemanticGraphNode>;
nodeIdsByFrame: Map<string, SemanticNodeId[]>;
nodeByFrameBackend: Map<string, SemanticNodeId>;
}
export interface ResolvedSemanticNode extends SemanticGraphNode {
vom: Omit<VomNode, "id" | "parentId" | "backendNodeId" | "frameId" | "contextScopeId">;
referenceable: boolean;
roleSource: "ax" | "ax-ignored" | "dom-explicit" | "dom-native" | "none";
}
export interface ResolvedSemanticGraph extends Omit<SemanticGraph, "nodes"> {
nodes: Map<SemanticNodeId, ResolvedSemanticNode>;
}
export type StructureDecision =
| { kind: "keep"; reason: "semantic" | "frame-owner" }
| { kind: "inferred-group"; reason: "independent-interaction-branches" }
| { kind: "transparent"; reason: "wrapper" | "child-document-root" | "ax-ignored" }
| {
kind: "excluded";
reason: "excluded" | "redundant-source" | "non-rendered-dom-fallback";
};
export interface StructuredSemanticNode extends ResolvedSemanticNode {
structure: StructureDecision;
semanticParentId?: SemanticNodeId;
}
export interface StructuredSemanticGraph extends Omit<ResolvedSemanticGraph, "nodes"> {
nodes: Map<SemanticNodeId, StructuredSemanticNode>;
}
export function frameBackendKey(frameId: string, backendNodeId: number): string {
return `${frameId}\u0000${backendNodeId}`;
}
export function frameAxKey(frameId: string, axNodeId: string): string {
return `${frameId}\u0000${axNodeId}`;
}
-1
View File
@@ -44,7 +44,6 @@
"!packages/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css",
"!packages/dsh-plugin-browserskill/.bsk-ui.raw.css",
"!packages/dsh-plugin-browserskill/.bsk-ui.input.css",
"!evals/browser/results/**",
"!**/target/**",
"!**/.wxt/**",
"!**/.output/**",
+4 -4
View File
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
## Prerequisites
1. `bsk` on `PATH` (Rust CLI from browser-skill)
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
## Mandatory workflow
@@ -210,7 +210,7 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff
| Command | Summary |
|---------|---------|
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines). JS throw → stderr, **exit 0** (RPC success); use `--json` and check `.ok` to detect JS errors |
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines); JS throw → stderr, **exit 0** |
| `bsk wait-for-navigation` | Block until load/DOM idle/etc. (`--wait-until`, `--timeout`) |
| `bsk wait-ms <duration>` | Sleep (`500ms`, `2s`, `1m`; **no** `--session`) |
@@ -294,12 +294,12 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
| Code | Meaning | What to do |
|------|---------|------------|
| `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue; for `evaluate`, check `--json` `.ok` to distinguish JS success from RPC success |
| `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue |
| `1` | User error — bad args, unknown session, tab not in Agent Window, stale ref | Fix args; `bsk session list`; re-snapshot |
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. |
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
+18 -11
View File
@@ -351,10 +351,10 @@ fn check_version_compatible(status: Option<&StatusResult>) -> CheckResult {
}
}
/// `bsk doctor` check: every connected browser should use the same
/// protocol version as the daemon. Minor protocol drift is accepted by
/// the daemon but flagged here so the user can see who needs updating
/// (M10.4).
/// `bsk doctor` check: connected browsers should speak a protocol the
/// daemon accepts. A different protocol string is still a live
/// connection — report Ok so agents keep working. The detail is an
/// upgrade reminder, not a blocker.
///
/// Review M2 (round-1 minor): when no browsers are connected, the
/// check has nothing to compare against, so it now reports
@@ -396,13 +396,12 @@ fn check_browsers_protocol_compatible(status: Option<&StatusResult>) -> CheckRes
})
.collect::<Vec<_>>()
.join(", ");
CheckResult::fail(
CheckResult::ok(
name,
format!(
"{} browser(s) have protocol minor drift from the daemon: {stale}",
"{} browser(s) report a different protocol version (still usable — continue, and upgrade soon): {stale}",
status.version_skew_browsers.len()
),
"upgrade the browser-skill extension or bsk CLI so both sides use the same protocol version",
)
}
@@ -581,7 +580,7 @@ mod m2_tests {
}
#[test]
fn browsers_check_reports_fail_when_skew_present() {
fn browsers_check_reports_ok_when_skew_present() {
let status = fake_status(
vec![BrowserStatusEntry {
instance_id: "alpha".into(),
@@ -605,9 +604,17 @@ mod m2_tests {
}],
);
let check = check_browsers_protocol_compatible(Some(&status));
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(check.status, CheckStatus::Ok);
assert!(check.detail.contains("alpha"));
assert!(check.hint.is_some());
assert!(
check
.detail
.contains("still usable — continue, and upgrade soon")
);
assert!(
check.hint.is_none(),
"a protocol-version note must not hint to stop"
);
}
#[test]
@@ -635,7 +642,7 @@ mod m2_tests {
}],
);
let check = check_browsers_protocol_compatible(Some(&status));
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(check.status, CheckStatus::Ok);
assert!(
check
.detail
+2 -2
View File
@@ -65,7 +65,7 @@ fn render_human(s: &StatusResult) {
let bold = "\x1b[1m";
let reset = "\x1b[0m";
eprintln!(
"{bold}{yellow}warning:{reset} {} browser(s) have protocol version drift from the daemon{reset}",
"{bold}{yellow}note:{reset} {} browser(s) report a different protocol version; still usable — continue, and run `bsk update` soon{reset}",
s.version_skew_browsers.len()
);
for skew in &s.version_skew_browsers {
@@ -75,7 +75,7 @@ fn render_human(s: &StatusResult) {
skew.label.clone()
};
eprintln!(
" {} ({}) — protocol ext {} vs daemon {} (app ext v{}, daemon v{}) — please align protocol versions",
" {} ({}) — protocol ext {} vs CLI {} (app ext v{}, CLI v{}) — still usable; please upgrade",
skew.instance_id,
label,
display_protocol(&skew.client_protocol_version),
-259
View File
@@ -1,259 +0,0 @@
# Browser capability evaluation corpus
This directory is a deterministic, local browser evaluation environment for BrowserSkill, DSH,
and other command-line agents. Cases, fixture pages, direct `bsk` workflows, and assertions are
data-driven and discovered automatically. Adding a case does not require editing a central switch.
[中文说明](README.zh-CN.md)
## Design goals
- Agent-neutral: prompts describe observable page goals so the same cases can run through different
agents and adapters.
- Deterministic: pages run locally and use stable markers and run-scoped events.
- Extensible: manifests, fixture modules, and workflows are auto-discovered.
- Reproducible: generated cases record a seed and derive every DOM variation from it.
- Honest verification: page-observable results, response markers, and adapter evidence are reported
separately. Missing adapter evidence is `unverified`, never silently treated as passed.
- Privacy-safe: user badcases must be reduced to synthetic DOM and anonymized data before commit.
The corpus currently has six stable `core` cases and one seeded `matrix` case. The operation
inventory contains 28 browser operations; that count is capability coverage, not the number of
cases. Cases can grow independently and many cases may cover the same operation under different
page conditions.
## Quick start
These commands validate manifests and run unit/integration tests without launching a browser:
```sh
pnpm eval:browser validate
pnpm eval:browser list
pnpm eval:browser coverage
pnpm eval:browser:test
```
Build the repository CLI and exercise the real CLI, daemon, extension, Agent Window, and pages:
```sh
cargo build -p bsk
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite core --bsk ./target/debug/bsk
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk
```
Every smoke case starts a fresh browser session and stops it in a `finally` cleanup path. Reports
and screenshots are written to the ignored `evals/browser/results/` directory.
`pnpm run eval:browser -- coverage` is also supported. The CLI intentionally accepts pnpm's leading
`--` separator.
## Suites and selection
| Suite | Intended use | Default execution |
| --- | --- | --- |
| `core` | Small stable acceptance set for everyday smoke and CI | Yes |
| `matrix` | Seeded DOM, timing, and layout variations | Explicit |
| `regression` | Minimized user badcases tied to an issue or fix | Explicit |
| `stress` | Higher volume or slower boundary testing | Explicit |
| `manual` | Human-dependent capabilities that cannot be deterministic | Explicit |
`smoke` and `run-agent` default to `core`, so adding a stress or regression case cannot silently
slow down existing CI. `list`, `coverage`, and `validate` inspect the full corpus by default.
```sh
pnpm eval:browser list --suite core
pnpm eval:browser list --tag form
pnpm eval:browser smoke --case form-controls
pnpm eval:browser smoke --case form-controls,generated-form
pnpm eval:browser smoke --case all
```
Filters from different dimensions are combined. Repeated/comma-separated suites or case ids are
alternatives; every requested tag must be present. `--task` remains a backward-compatible alias for
`--case`.
## Directory and discovery model
```text
evals/browser/
cases/
core/*.case.json
matrix/*.case.json
regression/<case-id>/
<case-id>.case.json
<case-id>.fixture.mjs
README.md
fixtures/pages/*.fixture.mjs
schemas/case.schema.json
lib/
tests/
```
- Any `cases/**/*.case.json` file is loaded as a case manifest.
- Any `fixtures/**/*.fixture.mjs` or `cases/**/*.fixture.mjs` file is registered as a fixture.
- Manifests are ordered by suite, optional numeric `order`, then id.
- `validate` rejects duplicate case ids, duplicate routes, missing fixture routes, unknown
operations/actions, invalid workflow variables, and adapter assertions with no evidence producer.
## Case manifest
A case owns all information needed by both an agent evaluation and direct CLI smoke:
```json
{
"$schema": "../../schemas/case.schema.json",
"schemaVersion": 1,
"id": "example-case",
"title": "Example browser behavior",
"order": 10,
"suite": "core",
"tags": ["form", "keyboard"],
"seed": 17,
"fixture": { "startPath": "/example" },
"prompts": { "en": "Open {url} ...", "zh-CN": "打开 {url}……" },
"coverage": ["page.navigate", "interact.press"],
"assertions": { "site": [], "response": [], "adapter": [] },
"smoke": { "steps": [{ "action": "navigate" }] }
}
```
Prompt placeholders are `{url}`, `{baseUrl}`, `{runId}`, and `{seed}`. Agent adapter arguments also
support `{prompt}`, `{caseId}` / `{taskId}`, and `{variant}`.
Assertions have three independent sources:
- `site`: events emitted by the local page, such as submitted values or a history navigation.
- `response`: stable text that must appear in the agent or CLI output.
- `adapter`: facts outside page JavaScript, such as a non-empty screenshot or confirmed session
cleanup.
The declarative smoke workflow supports navigation/history, waits, observe/snapshot/HTML,
screenshot/console/network, click/hover/fill/select/press, tabs, resize/emulation, and the manual
ownership/help operations. A step may `saveAs` its JSON result and later reference values such as
`{child.tab_id}`. A step may set `evidence` for an adapter assertion. See existing manifests for
complete examples.
## Adding a normal case
1. Add a `*.fixture.mjs` module exporting `{ id, routes, render(context) }`. It can live in shared
`fixtures/pages/` or beside a self-contained regression case.
2. Add a `*.case.json` manifest with prompts, operation coverage, assertions, and smoke steps.
3. Make all page events run-scoped using the shared `browserEval.send(...)` client helper.
4. Run `pnpm eval:browser validate` and `pnpm eval:browser:test`.
5. Run the new case against the real local build with `smoke --case <id>`.
Prefer one behavior or failure mechanism per case. Stable synthetic markers make failures easier to
diagnose than assertions against incidental prose.
## Turning a user badcase into a regression
Create a self-contained starting point:
```sh
pnpm eval:browser scaffold reported-timeout \
--title "Navigation settles after a late frame" \
--source "issue-123"
```
The command creates a manifest, fixture, and reproduction notes under
`cases/regression/reported-timeout/`. Then reduce the report to the smallest relevant mechanism:
- Keep DOM structure, timing, redirects, frames, or browser state that triggers the bug.
- Replace names, account data, URLs, tokens, screenshots, and page copy with synthetic values.
- Do not commit HAR files, cookies, credentials, production HTML, or proprietary assets.
- Record the issue/PR reference, seed when relevant, expected result, and the fixing PR.
- Confirm the new case fails on the known-bad build and passes on the fixed build when practical.
## Seeded matrix cases
`generated-form` varies label association, nesting depth, hydration delay, decoy controls, field
order, and element ids while keeping the task and oracle stable. Inspect a seed without a browser:
```sh
pnpm eval:browser generate --seed user-badcase-42
pnpm eval:browser prompt generated-form --seed user-badcase-42 --run-id manual-42
pnpm eval:browser smoke --case generated-form --seed user-badcase-42
pnpm eval:browser smoke --case generated-form --seed 4,7,14
```
Repeated/comma-separated seeds run as independent results. Reports store every normalized seed, so
a failure can be replayed exactly and promoted into a named regression case if it represents a
distinct bug.
## Run any command-line agent
Copy `agents.example.json` to the ignored `agents.local.json` and adapt its command. Arguments are
passed directly to the executable without a shell.
```sh
pnpm eval:browser run-agent \
--config evals/browser/agents.local.json \
--agent dsh-local \
--suite core \
--repeat 3
```
An adapter may define `command`, `args`, `cwd`, `env`, `timeoutMs`, `variant`, free-form `metadata`,
and optional `metrics.errorPattern` / `metrics.toolCallPattern` regular expressions.
To compare 28 granular tools with domain tools, create two isolated adapter entries and run both
against the same selected cases and seeds:
```sh
pnpm eval:browser run-agent \
--config evals/browser/agents.local.json \
--agent dsh-granular,dsh-domain \
--suite core \
--repeat 10
```
Keep browser runs sequential to avoid session contention. Use fresh profiles and alternate adapter
order in larger experiments to reduce warm-cache bias.
## Reading results
```sh
pnpm eval:browser summarize evals/browser/results/*.json
```
The summary groups by adapter and variant and reports pass rate, fully verified rate, process
failures, matched error count, average tool calls, and average duration. The raw JSON retains case,
suite, tags, seed, prompt, command output, page event count, and individual oracle checks.
A healthy run has:
- no execution timeout or non-zero exit;
- no `executionError` in direct smoke;
- `verification.status` equal to `passed` (or intentionally `passed-with-unverified` for an agent
adapter that cannot expose external evidence);
- every session cleanup assertion verified;
- no unexpected increase in errors, tool calls, or duration versus the chosen baseline.
## Development baseline
Use these lanes as commit and PR gates:
| Change | Required checks |
| --- | --- |
| Any case, fixture, oracle, or harness change | `pnpm eval:browser:check` |
| CLI, daemon, extension, DSH plugin, or browser-operation change | The check above, `cargo build -p bsk`, then real-browser `smoke --suite core` |
| DOM discovery, form interaction, timing, or waiting change | The checks above, then `smoke --suite matrix --seed 4,7,14` |
| A bug fix with a known reproduction | Add a `regression` case and run that case on the fixed build |
Before committing this harness, the complete acceptance sequence is:
```sh
pnpm eval:browser:check
cargo build -p bsk
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case all --bsk ./target/debug/bsk
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk
```
All commands must exit zero. The smoke summary must have 100% pass and fully verified rates, zero
execution failures/errors, and `bsk session list --json` must be `[]` afterward. Do not weaken an
oracle or replace a stable marker merely to make a regression pass; behavior changes should update
the case and expected baseline in the same reviewed change.
The direct smoke lane covers 25 of 28 operations. `tabs.borrow`, `tabs.return`, and
`assist.request-help` stay manual because they require a real user tab or human interaction and
would make the default suite destructive or non-deterministic.
-197
View File
@@ -1,197 +0,0 @@
# 浏览器能力测试语料库
这里是一套供 BrowserSkill、DSH 以及其他命令行 Agent 共用的本地确定性浏览器测试环境。
case、测试页面、`bsk` 直连流程和断言都会自动发现;新增 case 不需要修改中央 `switch`。
[English](README.md)
## 核心原则
- 与 Agent 无关:prompt 只描述可观察的页面目标,同一套 case 可以被不同 Agent 和
adapter 使用。
- 本地且确定:页面只依赖本地服务,使用稳定 marker 和按 run 隔离的事件。
- 可扩展:manifest、fixture 和 workflow 都从目录自动发现。
- 可复现:生成式页面的所有变化由 seed 决定,报告会保留 seed。
- 如实验证:页面事件、回答文本和 adapter 外部证据分别统计;拿不到的证据标为
`unverified`,不会冒充通过。
- 隐私安全:用户 badcase 必须先缩减为合成 DOM 和匿名数据,再进入仓库。
当前有 6 个稳定的 `core` case 和 1 个 seed 驱动的 `matrix` case。28 表示浏览器操作能力
清单,并不是 case 数量;同一种操作可以在不同页面结构、时序和浏览器状态下由多个 case
重复覆盖。
## 快速使用
不启动浏览器的校验:
```sh
pnpm eval:browser validate
pnpm eval:browser list
pnpm eval:browser coverage
pnpm eval:browser:test
```
使用仓库当前代码编译 `bsk`,再跑真实 CLI、daemon、扩展、小窗和页面链路:
```sh
cargo build -p bsk
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite core --bsk ./target/debug/bsk
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk
```
每个 smoke case 都会创建独立 session,并在 `finally` 中关闭。JSON 报告和截图输出到已被
Git 忽略的 `evals/browser/results/`。
也可以写成 `pnpm run eval:browser -- coverage`;CLI 已兼容 pnpm 传入的前导 `--`。
## Suite 与筛选
| Suite | 用途 | 默认执行 |
| --- | --- | --- |
| `core` | 日常 smoke 和 CI 使用的小而稳定的验收集 | 是 |
| `matrix` | seed 驱动的 DOM、时序、布局组合 | 显式选择 |
| `regression` | 从用户 badcase 缩减并与 issue/修复关联的回归 | 显式选择 |
| `stress` | 更高次数或更慢的边界测试 | 显式选择 |
| `manual` | 必须依赖真人或真实用户标签页的能力 | 显式选择 |
`smoke` 和 `run-agent` 不带筛选时只跑 `core`,所以以后新增 stress/badcase 不会突然拖慢
已有 CI。`list`、`coverage`、`validate` 默认检查全部语料。
```sh
pnpm eval:browser list --suite core
pnpm eval:browser list --tag form
pnpm eval:browser smoke --case form-controls
pnpm eval:browser smoke --case form-controls,generated-form
pnpm eval:browser smoke --case all
```
不同维度的筛选会叠加;逗号分隔的 suite/case 是“或”,多个 tag 必须全部命中。旧参数
`--task` 仍作为 `--case` 的兼容别名。
## 自动发现结构
```text
evals/browser/
cases/
core/*.case.json
matrix/*.case.json
regression/<case-id>/
<case-id>.case.json
<case-id>.fixture.mjs
README.md
fixtures/pages/*.fixture.mjs
schemas/case.schema.json
lib/
tests/
```
- `cases/**/*.case.json` 都会作为 case manifest 加载。
- `fixtures/**/*.fixture.mjs` 和 `cases/**/*.fixture.mjs` 都会注册为页面 fixture。
- 顺序按 suite、可选的数字 `order`、id 排列。
- `validate` 会阻止重复 case id、重复路由、缺失 fixture、未知操作/action、无效 workflow
变量,以及没有任何步骤产出的 adapter evidence。
一个 manifest 同时描述:中英文 prompt、能力 coverage、页面事件/回答/adapter 三类断言,以及
`bsk` 直连 smoke 的声明式步骤。prompt 可用 `{url}`、`{baseUrl}`、`{runId}`、`{seed}`;
workflow 可以用 `saveAs` 保存 JSON 结果,并用 `{child.tab_id}` 之类的变量引用后续步骤。
完整写法可直接参考 `cases/core/` 和 `cases/matrix/`。
## 新增普通 case
1. 新增导出 `{ id, routes, render(context) }` 的 `*.fixture.mjs`,可放共享 fixture 目录,也可
和 regression case 放在一起。
2. 新增 `*.case.json`,写好 prompt、operation coverage、断言和 smoke steps。
3. 页面使用共享的 `browserEval.send(...)` 上报按 run 隔离的事件。
4. 执行 `pnpm eval:browser validate` 和 `pnpm eval:browser:test`。
5. 用 `smoke --case <id>` 验证真实本地编译链路。
建议一个 case 只表达一种行为或故障机制,并使用稳定的合成 marker,避免断言偶然文案。
## 把用户 badcase 固化成回归
先生成一个自包含模板:
```sh
pnpm eval:browser scaffold reported-timeout \
--title "Navigation settles after a late frame" \
--source "issue-123"
```
它会在 `cases/regression/reported-timeout/` 下生成 manifest、fixture 和复现说明。随后只保留
真正触发问题的 DOM 结构、时序、跳转、frame 或浏览器状态,并遵守:
- 所有姓名、账号、网址、token、截图和文案都替换为合成值。
- 不提交 HAR、cookie、凭据、生产 HTML 或私有素材。
- 记录 issue/PR 来源、相关 seed、期望行为和后续修复 PR。
- 条件允许时,证明新 case 在旧版本失败、修复版本通过。
## Seed 矩阵
`generated-form` 会确定性改变 label 关联方式、DOM 嵌套、hydration 延迟、干扰控件、字段
顺序和 element id,但任务和 oracle 保持不变:
```sh
pnpm eval:browser generate --seed user-badcase-42
pnpm eval:browser prompt generated-form --seed user-badcase-42 --run-id manual-42
pnpm eval:browser smoke --case generated-form --seed user-badcase-42
pnpm eval:browser smoke --case generated-form --seed 4,7,14
```
逗号分隔或重复的 seed 会生成相互独立的结果。任何失败都能按报告中的规范化 seed 精确重放;
若它代表独立 bug,再提升为命名 regression case。
## 测任意 Agent 与比较 28/6 工具
把 `agents.example.json` 复制为已被 Git 忽略的 `agents.local.json`,分别配置两个隔离的 Agent
profile/build。参数以数组直接传给可执行程序,不经过 shell。
```sh
pnpm eval:browser run-agent \
--config evals/browser/agents.local.json \
--agent dsh-granular,dsh-domain \
--suite core \
--repeat 10
```
浏览器任务应串行运行,避免 session 竞争;更大实验中使用干净 profile 并轮换 adapter 顺序,
降低缓存偏差。
## 如何看结果
```sh
pnpm eval:browser summarize evals/browser/results/*.json
```
汇总按 adapter/variant 展示通过率、完全验证率、进程失败、错误数、平均工具调用数和耗时。
原始 JSON 会保留 case、suite、tag、seed、prompt、命令输出、页面事件数和每条 oracle 结果。
健康运行应满足:没有 timeout/非零退出,direct smoke 没有 `executionError`,验证状态为
`passed`(无法暴露外部证据的 Agent adapter 可明确为 `passed-with-unverified`),session cleanup
通过,并且错误数、调用数和耗时没有相对基线异常上涨。
## 开发基准线
后续开发统一按下面的门禁执行:
| 改动范围 | 必跑检查 |
| --- | --- |
| 任意 case、fixture、oracle 或 harness 改动 | `pnpm eval:browser:check` |
| CLI、daemon、扩展、DSH 插件或浏览器操作改动 | 上述检查、`cargo build -p bsk`、真实浏览器 `smoke --suite core` |
| DOM 发现、表单交互、时序或等待逻辑改动 | 上述检查,再跑 `smoke --suite matrix --seed 4,7,14` |
| 修复已有明确复现的 bug | 新增 `regression` case,并在修复版本单独运行该 case |
提交这套 harness 前,完整验收命令为:
```sh
pnpm eval:browser:check
cargo build -p bsk
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case all --bsk ./target/debug/bsk
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk
```
所有命令必须以 0 退出;smoke 汇总必须达到 100% pass、100% fully verified、0 execution
failure/error,结束后 `bsk session list --json` 必须为 `[]`。不能为了让回归通过而削弱 oracle
或替换稳定 marker;如果产品行为确实改变,case 与预期基线必须在同一个受审查改动中更新。
直连 smoke 自动覆盖 28 项中的 25 项。`tabs.borrow`、`tabs.return`、`assist.request-help` 需要
真实用户标签页或真人交互,因此保留在 manual lane,避免默认测试具有破坏性或不确定性。
-17
View File
@@ -1,17 +0,0 @@
{
"agents": {
"dsh-local": {
"variant": "granular-28",
"command": "dsh",
"args": ["--profile", "headless", "{prompt}"],
"cwd": "../..",
"timeoutMs": 300000,
"metadata": {
"toolCount": 28
},
"metrics": {
"toolCallPattern": "browser[_-][a-z_-]+"
}
}
}
}
-9
View File
@@ -1,9 +0,0 @@
# Browser evaluation cases
Cases are discovered recursively from `*.case.json`; colocated `*.fixture.mjs` files are discovered
as fixtures. Use `core` for small deterministic acceptance checks, `matrix` for seeded variations,
and `regression/<case-id>/` for privacy-safe minimized user badcases.
Run `pnpm eval:browser validate` after every case change. See the parent
[`README.md`](../README.md) for the manifest contract, workflow actions, scaffolding, and acceptance
criteria.
@@ -1,48 +0,0 @@
{
"$schema": "../../schemas/case.schema.json",
"schemaVersion": 1,
"id": "diagnostics",
"title": "Console and network inspection",
"order": 50,
"suite": "core",
"tags": ["console", "network", "diagnostics"],
"fixture": { "startPath": "/diagnostics" },
"prompts": {
"en": "Open {url}. Use browser diagnostics to inspect the page console and recent network activity. Report the console marker and the token returned by the ping request, then close the browser session.",
"zh-CN": "打开 {url}。使用浏览器诊断能力检查页面 console 和最近的 network 活动,报告 console marker 以及 ping 请求返回的 token,最后关闭浏览器会话。"
},
"coverage": [
"session.start",
"session.stop",
"page.navigate",
"inspect.console",
"inspect.network"
],
"assertions": {
"site": [
{
"label": "diagnostic ping completed",
"type": "network.ping",
"minCount": 1
}
],
"response": [
{ "label": "reported console marker", "includes": "CONSOLE-61" },
{ "label": "reported network token", "includes": "NETWORK-73" }
],
"adapter": [
{ "label": "console inspection was executed", "key": "consoleRead" },
{ "label": "network inspection was executed", "key": "networkRead" },
{ "label": "browser session was closed", "key": "sessionStopped" }
]
},
"smoke": {
"steps": [
{ "action": "navigate" },
{ "action": "wait", "duration": "300ms" },
{ "action": "console", "evidence": "consoleRead" },
{ "action": "network", "evidence": "networkRead" },
{ "action": "observe" }
]
}
}
@@ -1,82 +0,0 @@
{
"$schema": "../../schemas/case.schema.json",
"schemaVersion": 1,
"id": "form-controls",
"title": "Form, select, keyboard, and screenshot",
"order": 20,
"suite": "core",
"tags": ["form", "keyboard", "screenshot", "select"],
"fixture": { "startPath": "/form" },
"prompts": {
"en": "Open {url}. Fill Text input with \"agent-parity\" and Textarea with \"BrowserSkill works\". Select the visible option \"Two\" from Dropdown. Capture a screenshot of the form, focus Submit, and press Enter to submit. Confirm that the result says \"Received!\", then close the browser session.",
"zh-CN": "打开 {url}。把 Text input 填成“agent-parity”,Textarea 填成“BrowserSkill works”,Dropdown 选择可见选项“Two”。截取表单截图,然后聚焦 Submit 并按 Enter 提交。确认结果页出现“Received!”,最后关闭浏览器会话。"
},
"coverage": [
"session.start",
"session.stop",
"page.navigate",
"interact.fill",
"interact.select",
"interact.press",
"inspect.screenshot"
],
"assertions": {
"site": [
{
"label": "Enter was pressed on Submit",
"type": "form.enter_pressed",
"minCount": 1
},
{
"label": "form values were submitted",
"type": "form.submitted",
"where": {
"data.text": "agent-parity",
"data.notes": "BrowserSkill works",
"data.choice": "two"
},
"minCount": 1
},
{
"label": "result page was displayed",
"type": "page.shown",
"where": { "data.path": "/result" },
"minCount": 1
}
],
"response": [{ "label": "confirmed result", "includes": "Received!" }],
"adapter": [
{ "label": "screenshot artifact was created", "key": "screenshotCreated" },
{ "label": "browser session was closed", "key": "sessionStopped" }
]
},
"smoke": {
"steps": [
{ "action": "navigate" },
{
"action": "fill",
"selector": "#text-input",
"value": "agent-parity"
},
{
"action": "fill",
"selector": "#notes",
"value": "BrowserSkill works"
},
{ "action": "select", "selector": "#choice", "values": ["two"] },
{
"action": "screenshot",
"artifactName": "form.png",
"evidence": "screenshotCreated"
},
{ "action": "press", "key": "Enter", "selector": "#submit" },
{
"action": "wait-site-event",
"type": "page.shown",
"where": { "path": "/result" }
},
{ "action": "wait", "duration": "200ms" },
{ "action": "observe" }
]
}
}
@@ -1,59 +0,0 @@
{
"$schema": "../../schemas/case.schema.json",
"schemaVersion": 1,
"id": "hover-inspect",
"title": "Hover, local HTML, and screenshot",
"order": 30,
"suite": "core",
"tags": ["hover", "html", "screenshot"],
"fixture": { "startPath": "/hover-inspect" },
"prompts": {
"en": "Open {url}. Hover over Account, then choose Profile beta from the revealed menu. Inspect the local HTML around the Inspection card and capture a screenshot. Report both marker codes you find, then close the browser session.",
"zh-CN": "打开 {url}。把鼠标悬停在“Account”上,然后点击展开菜单中的“Profile beta”。检查 Inspection card 附近的局部 HTML,并截取页面截图。报告找到的两个标记代码,最后关闭浏览器会话。"
},
"coverage": [
"session.start",
"session.stop",
"page.navigate",
"interact.hover",
"interact.click",
"inspect.html",
"inspect.screenshot"
],
"assertions": {
"site": [
{
"label": "Account menu was hovered",
"type": "hover.menu_opened",
"minCount": 1
},
{
"label": "Profile beta was selected",
"type": "hover.profile_clicked",
"minCount": 1
}
],
"response": [
{ "label": "reported hover marker", "includes": "HOVER-17" },
{ "label": "reported HTML marker", "includes": "INSPECT-27" }
],
"adapter": [
{ "label": "HTML inspection was executed", "key": "htmlInspected" },
{ "label": "screenshot artifact was created", "key": "screenshotCreated" },
{ "label": "browser session was closed", "key": "sessionStopped" }
]
},
"smoke": {
"steps": [
{ "action": "navigate" },
{ "action": "hover", "selector": "#account-menu" },
{ "action": "click", "selector": "#profile-link" },
{ "action": "html", "evidence": "htmlInspected" },
{
"action": "screenshot",
"artifactName": "hover-inspect.png",
"evidence": "screenshotCreated"
}
]
}
}
@@ -1,43 +0,0 @@
{
"$schema": "../../schemas/case.schema.json",
"schemaVersion": 1,
"id": "mobile-emulation",
"title": "Mobile emulation",
"order": 60,
"suite": "core",
"tags": ["emulation", "mobile", "viewport"],
"fixture": { "startPath": "/responsive" },
"prompts": {
"en": "Open {url}, emulate an iPhone 14, and report the viewport marker shown on the page. Close the browser session when finished.",
"zh-CN": "打开 {url},模拟 iPhone 14,并报告页面显示的 viewport marker。完成后关闭浏览器会话。"
},
"coverage": ["session.start", "session.stop", "page.navigate", "assist.emulate"],
"assertions": {
"site": [
{
"label": "iPhone-sized viewport was observed",
"type": "viewport.sample",
"where": { "data.width": 390, "data.height": 844 },
"minCount": 1
}
],
"response": [{ "label": "reported viewport marker", "includes": "VIEWPORT-84" }],
"adapter": [
{ "label": "device emulation was executed", "key": "emulatedDevice" },
{ "label": "browser session was closed", "key": "sessionStopped" }
]
},
"smoke": {
"steps": [
{ "action": "navigate" },
{ "action": "resize", "width": 900, "height": 700 },
{
"action": "emulate",
"device": "iphone-14",
"evidence": "emulatedDevice"
},
{ "action": "wait", "duration": "300ms" },
{ "action": "html" }
]
}
}
@@ -1,67 +0,0 @@
{
"$schema": "../../schemas/case.schema.json",
"schemaVersion": 1,
"id": "navigation-history",
"title": "Navigation, history, and reload",
"order": 10,
"suite": "core",
"tags": ["navigation", "history", "reload"],
"fixture": { "startPath": "/navigation/start" },
"prompts": {
"en": "Open {url}. Read the main heading, follow the Details link, and report the code shown there. Then go back, go forward, reload the page, and close the browser session. Use only the browser capability for page operations.",
"zh-CN": "打开 {url}。读取页面主标题,点击“详情”,并报告详情页上的代码。然后依次后退、前进、刷新页面,最后关闭浏览器会话。页面操作只使用浏览器能力。"
},
"coverage": [
"session.start",
"session.stop",
"page.navigate",
"page.back",
"page.forward",
"page.reload",
"interact.click"
],
"assertions": {
"site": [
{
"label": "details link was clicked",
"type": "navigation.detail.clicked",
"minCount": 1
},
{
"label": "back returned to the start page",
"type": "page.shown",
"where": { "data.path": "/navigation/start" },
"minCount": 2
},
{
"label": "forward returned to the detail page",
"type": "page.shown",
"where": { "data.path": "/navigation/detail" },
"minCount": 2
},
{
"label": "detail page was reloaded",
"type": "page.shown",
"where": {
"data.path": "/navigation/detail",
"data.navigationType": "reload"
},
"minCount": 1
}
],
"response": [{ "label": "reported detail code", "includes": "NAV-42" }],
"adapter": [{ "label": "browser session was closed", "key": "sessionStopped" }]
},
"smoke": {
"steps": [
{ "action": "navigate" },
{ "action": "observe" },
{ "action": "snapshot" },
{ "action": "click", "selector": "#details-link" },
{ "action": "html" },
{ "action": "back" },
{ "action": "forward" },
{ "action": "reload" }
]
}
}
-71
View File
@@ -1,71 +0,0 @@
{
"$schema": "../../schemas/case.schema.json",
"schemaVersion": 1,
"id": "tabs",
"title": "Create, select, and close a tab",
"order": 40,
"suite": "core",
"tags": ["tabs", "ownership", "lifecycle"],
"fixture": { "startPath": "/tabs" },
"prompts": {
"en": "Open {url}. Create a new browser tab at {baseUrl}/tabs/child?run={runId}, read its token, click \"Mark child visited\", close the child tab, return to the original tab, and close the browser session.",
"zh-CN": "打开 {url}。新建一个浏览器标签页并访问 {baseUrl}/tabs/child?run={runId},读取其中的 token,点击“Mark child visited”,关闭子标签页,回到原标签页,最后关闭浏览器会话。"
},
"coverage": [
"session.start",
"session.stop",
"page.navigate",
"interact.click",
"tabs.create",
"tabs.select",
"tabs.close"
],
"assertions": {
"site": [
{
"label": "child tab page was displayed",
"type": "page.shown",
"where": { "data.path": "/tabs/child" },
"minCount": 1
},
{
"label": "child tab action ran",
"type": "tabs.child_marked",
"minCount": 1
}
],
"response": [{ "label": "reported child token", "includes": "TAB-29" }],
"adapter": [
{ "label": "child tab was closed", "key": "childTabClosed" },
{ "label": "browser session was closed", "key": "sessionStopped" }
]
},
"smoke": {
"steps": [
{ "action": "navigate", "saveAs": "original" },
{ "action": "tab-list" },
{
"action": "tab-create",
"url": "{baseUrl}/tabs/child?run={runId}",
"saveAs": "child"
},
{
"action": "navigate",
"url": "{baseUrl}/tabs/child?run={runId}",
"tabId": "{child.tab_id}"
},
{
"action": "click",
"selector": "#mark-child",
"tabId": "{child.tab_id}"
},
{ "action": "html", "tabId": "{child.tab_id}" },
{
"action": "tab-close",
"tabId": "{child.tab_id}",
"evidence": "childTabClosed"
},
{ "action": "tab-select", "tabId": "{original.tab_id}" }
]
}
}
-5
View File
@@ -1,5 +0,0 @@
# Seeded matrix cases
Matrix cases vary page structure or timing deterministically from a recorded seed. The task,
assertions, and success marker remain stable so a failed seed can be replayed exactly. A seed that
exposes a distinct product bug should be minimized and promoted to a named regression case.
@@ -1,77 +0,0 @@
{
"$schema": "../../schemas/case.schema.json",
"schemaVersion": 1,
"id": "generated-form",
"title": "Seeded form structure and hydration variants",
"order": 10,
"suite": "matrix",
"tags": ["dynamic-dom", "form", "hydration", "seeded"],
"seed": 20260827,
"fixture": { "startPath": "/matrix/generated-form" },
"prompts": {
"en": "Open {url}. Wait for the seeded form to finish hydrating. Fill Text input with \"seeded-agent\", select the visible option \"Two\", submit with Enter, and report the result marker and seed. Close the browser session.",
"zh-CN": "打开 {url}。等待 seeded form 完成 hydration,把 Text input 填成“seeded-agent”,选择可见选项“Two”,用 Enter 提交,并报告结果 marker 和 seed。最后关闭浏览器会话。"
},
"coverage": [
"session.start",
"session.stop",
"page.navigate",
"page.wait",
"inspect.observe",
"interact.fill",
"interact.select",
"interact.press"
],
"assertions": {
"site": [
{ "label": "generated form hydrated", "type": "matrix.ready", "minCount": 1 },
{
"label": "Enter was pressed on generated form",
"type": "matrix.enter_pressed",
"minCount": 1
},
{
"label": "generated values were submitted",
"type": "matrix.submitted",
"where": { "data.text": "seeded-agent", "data.choice": "two" },
"minCount": 1
},
{
"label": "matrix result page was displayed",
"type": "page.shown",
"where": { "data.path": "/matrix/generated-result" },
"minCount": 1
}
],
"response": [
{ "label": "reported matrix result", "includes": "MATRIX-OK" },
{ "label": "reported selected seed", "includes": "SEED-" }
],
"adapter": [{ "label": "browser session was closed", "key": "sessionStopped" }]
},
"smoke": {
"steps": [
{ "action": "navigate" },
{
"action": "wait-site-event",
"type": "matrix.ready",
"timeoutMs": 5000
},
{ "action": "observe" },
{ "action": "fill", "selector": "[name=text]", "value": "seeded-agent" },
{ "action": "select", "selector": "[name=choice]", "values": ["two"] },
{
"action": "press",
"key": "Enter",
"selector": "button[type=submit]"
},
{
"action": "wait-site-event",
"type": "page.shown",
"where": { "path": "/matrix/generated-result" }
},
{ "action": "wait", "duration": "200ms" },
{ "action": "observe" }
]
}
}
-12
View File
@@ -1,12 +0,0 @@
# Regression cases
Each user-reported badcase should live in its own directory with a manifest, synthetic fixture, and
README describing the symptom, minimal reproduction, expected behavior, source reference, and fix.
Never commit credentials, cookies, HAR files, production HTML, private screenshots, or proprietary
assets.
Create a starting point with:
```sh
pnpm eval:browser scaffold <case-id> --title "..." --source "issue-or-pr"
```
-467
View File
@@ -1,467 +0,0 @@
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { loadAgentConfig, runCommandTask } from "./lib/agent-runner.mjs";
import { runBskSmokeTask } from "./lib/bsk-runner.mjs";
import { buildOperationCoverage } from "./lib/coverage.mjs";
import { loadFixtureRegistry } from "./lib/fixture-registry.mjs";
import { verifyTask } from "./lib/oracle.mjs";
import { resultsDirectory } from "./lib/paths.mjs";
import { repositorySummary, validateRepositoryCases } from "./lib/repository-validation.mjs";
import { scaffoldCase } from "./lib/scaffold-case.mjs";
import { describeSeed, normalizeSeed } from "./lib/seeded-generator.mjs";
import { createEvalServer } from "./lib/server.mjs";
import { printSummary, summarizeReports } from "./lib/summary.mjs";
import { allTasks, getTask, listTasks, renderPrompt } from "./lib/tasks.mjs";
const booleanOptions = new Set(["help", "json"]);
function parseArguments(argv) {
const normalized = argv[0] === "--" ? argv.slice(1) : argv;
let [command, ...rest] = normalized;
const options = new Map();
const positionals = [];
if (command === "--help" || command === "-h") {
command = undefined;
options.set("help", [true]);
}
for (let index = 0; index < rest.length; index += 1) {
const argument = rest[index];
if (!argument.startsWith("--")) {
positionals.push(argument);
continue;
}
const equals = argument.indexOf("=");
const name = argument.slice(2, equals === -1 ? undefined : equals);
let value = equals === -1 ? undefined : argument.slice(equals + 1);
if (value === undefined && !booleanOptions.has(name)) {
const candidate = rest[index + 1];
if (candidate === undefined || candidate.startsWith("--")) {
throw new Error(`--${name} requires a value`);
}
value = candidate;
index += 1;
}
const values = options.get(name) ?? [];
values.push(value ?? true);
options.set(name, values);
}
return { command, options, positionals };
}
function option(parsed, name, fallback) {
return parsed.options.get(name)?.at(-1) ?? fallback;
}
function optionList(parsed, name) {
return (parsed.options.get(name) ?? [])
.flatMap((value) => String(value).split(","))
.map((value) => value.trim())
.filter(Boolean);
}
function numericOption(parsed, name, fallback) {
const value = Number(option(parsed, name, fallback));
if (!Number.isFinite(value) || value < 0) throw new Error(`--${name} must be non-negative`);
return value;
}
function seedOptions(parsed, fallback) {
const requested = optionList(parsed, "seed");
return requested.length > 0 ? unique(requested.map(normalizeSeed)) : [fallback];
}
function seedOption(parsed, fallback) {
const seeds = seedOptions(parsed, fallback);
if (seeds.length > 1) throw new Error("this command accepts only one --seed value");
return seeds[0];
}
function unique(values) {
return [...new Set(values)];
}
function selectCases(cases, parsed, { defaultSuite } = {}) {
const requestedIds = unique([...optionList(parsed, "case"), ...optionList(parsed, "task")]);
const requestedSuites = unique(optionList(parsed, "suite"));
const requestedTags = unique(optionList(parsed, "tag"));
const hasSelector =
requestedIds.length > 0 || requestedSuites.length > 0 || requestedTags.length > 0;
if (requestedIds.includes("all") && requestedIds.length > 1) {
throw new Error("--case all cannot be combined with named cases");
}
if (requestedSuites.includes("all") && requestedSuites.length > 1) {
throw new Error("--suite all cannot be combined with named suites");
}
const caseIds = requestedIds.includes("all") ? [] : requestedIds;
const suites = requestedSuites.includes("all")
? []
: requestedSuites.length > 0
? requestedSuites
: !hasSelector && defaultSuite
? [defaultSuite]
: [];
const knownIds = new Set(cases.map(({ id }) => id));
const missingIds = caseIds.filter((id) => !knownIds.has(id));
if (missingIds.length > 0) throw new Error(`unknown case(s): ${missingIds.join(", ")}`);
const knownSuites = new Set(cases.map(({ suite }) => suite));
const missingSuites = suites.filter((suite) => !knownSuites.has(suite));
if (missingSuites.length > 0) throw new Error(`unknown suite(s): ${missingSuites.join(", ")}`);
const knownTags = new Set(cases.flatMap(({ tags }) => tags));
const missingTags = requestedTags.filter((tag) => !knownTags.has(tag));
if (missingTags.length > 0) throw new Error(`unknown tag(s): ${missingTags.join(", ")}`);
const selected = cases.filter(
(testCase) =>
(caseIds.length === 0 || caseIds.includes(testCase.id)) &&
(suites.length === 0 || suites.includes(testCase.suite)) &&
requestedTags.every((tag) => testCase.tags.includes(tag)),
);
if (selected.length === 0) throw new Error("case selectors matched no cases");
return selected;
}
function reportFilename(mode) {
return `${new Date().toISOString().replaceAll(/[:.]/g, "-")}-${mode}.json`;
}
async function saveReport(report, outputDirectory) {
await mkdir(outputDirectory, { recursive: true });
const path = join(outputDirectory, reportFilename(report.mode));
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`);
return path;
}
async function loadValidatedCorpus() {
const [cases, fixtureRegistry] = await Promise.all([allTasks(), loadFixtureRegistry()]);
const errors = validateRepositoryCases(cases, fixtureRegistry);
if (errors.length > 0) throw new Error(`browser eval validation failed:\n${errors.join("\n")}`);
return { cases, fixtureRegistry };
}
function selectionMetadata(cases) {
return {
cases: cases.map(({ id }) => id),
suites: unique(cases.map(({ suite }) => suite)),
tags: unique(cases.flatMap(({ tags }) => tags)).sort(),
};
}
function usage() {
console.log(`BrowserSkill local browser evaluation
Usage:
node evals/browser/cli.mjs validate [--json]
node evals/browser/cli.mjs list [--suite NAME] [--tag NAME] [--case ID] [--json]
node evals/browser/cli.mjs coverage [--suite NAME] [--tag NAME] [--case ID] [--json]
node evals/browser/cli.mjs serve [--host 127.0.0.1] [--port 4173]
node evals/browser/cli.mjs prompt <case> [--base-url URL] [--run-id ID] [--seed VALUE]
node evals/browser/cli.mjs verify <case> --base-url URL --run-id ID [--response TEXT]
node evals/browser/cli.mjs smoke [--bsk bsk] [--suite core] [--case ID] [--seed VALUE]
node evals/browser/cli.mjs run-agent --config FILE --agent NAME [--suite core] [--case ID]
node evals/browser/cli.mjs generate --seed VALUE [--json]
node evals/browser/cli.mjs scaffold <case-id> [--suite regression] [--title TEXT]
node evals/browser/cli.mjs summarize <report.json> [more reports...]
Selection:
--case ID[,ID] Select case ids; --task is a backward-compatible alias
--suite NAME[,NAME] Select suites
--tag NAME[,NAME] Require every selected tag
--case all Explicitly include every suite (run commands default to core)
--seed VALUE[,VALUE] Run one or more reproducible matrix seeds
run-agent options:
--agent NAME[,NAME] One or more entries from the config file
--variant NAME Override the report label (does not switch plugin code)
--locale en|zh-CN Prompt language (default zh-CN)
--repeat N Repeat every selected case (default 1)
--timeout MS Per-agent timeout (default 300000)
--out DIRECTORY Report/artifact directory
`);
}
async function commandValidate(parsed) {
const { cases, fixtureRegistry } = await loadValidatedCorpus();
const summary = { ok: true, ...repositorySummary(cases, fixtureRegistry) };
if (option(parsed, "json", false)) console.log(JSON.stringify(summary, null, 2));
else {
console.log(`Validated ${summary.cases} cases and ${summary.fixtureRoutes} fixture routes.`);
console.log(
`Suites: ${Object.entries(summary.suites)
.map(([suite, count]) => `${suite}=${count}`)
.join(", ")}`,
);
}
}
async function commandList(parsed) {
const cases = selectCases(await allTasks(), parsed);
const tasks = listTasks(cases);
if (option(parsed, "json", false)) console.log(JSON.stringify(tasks, null, 2));
else {
for (const task of tasks) {
console.log(`${task.id}\t${task.suite}\t${task.tags.join(",")}\t${task.title}`);
}
}
}
async function commandCoverage(parsed) {
const cases = selectCases(await allTasks(), parsed);
const coverage = buildOperationCoverage(cases);
if (option(parsed, "json", false)) {
console.log(JSON.stringify(coverage, null, 2));
} else {
console.table(
coverage.map(({ operation, agentCases, smokeCases, directSmoke, note }) => ({
operation,
agent_cases: agentCases.join(", ") || "-",
smoke_cases: smokeCases.join(", ") || "-",
direct_smoke: directSmoke ? "yes" : "manual",
note: note ?? "",
})),
);
}
}
async function commandServe(parsed) {
const { fixtureRegistry } = await loadValidatedCorpus();
const server = createEvalServer({
host: option(parsed, "host", "127.0.0.1"),
port: numericOption(parsed, "port", 4173),
fixtureRegistry,
});
const info = await server.start();
console.log(`Browser eval server listening at ${info.baseUrl}`);
console.log("Press Ctrl-C to stop.");
await new Promise((resolveSignal) => {
process.once("SIGINT", resolveSignal);
process.once("SIGTERM", resolveSignal);
});
await server.stop();
}
async function commandPrompt(parsed) {
const cases = await allTasks();
const task = getTask(parsed.positionals[0], cases);
const prompt = renderPrompt(task, {
baseUrl: option(parsed, "base-url", "http://127.0.0.1:4173"),
runId: option(parsed, "run-id", randomUUID()),
locale: option(parsed, "locale", "zh-CN"),
seed: seedOption(parsed, task.seed),
});
console.log(prompt);
}
async function commandVerify(parsed) {
const task = getTask(parsed.positionals[0], await allTasks());
const baseUrl = option(parsed, "base-url");
const runId = option(parsed, "run-id");
if (!baseUrl || !runId) throw new Error("verify requires --base-url and --run-id");
let responseText = option(parsed, "response", "");
const responseFile = option(parsed, "response-file");
if (responseFile) responseText = await readFile(resolve(responseFile), "utf8");
const stateResponse = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runId)}`);
if (!stateResponse.ok) throw new Error(`oracle request failed with ${stateResponse.status}`);
const state = await stateResponse.json();
const verification = verifyTask(task, { events: state.events, responseText });
console.log(JSON.stringify(verification, null, 2));
if (verification.status === "failed") process.exitCode = 1;
}
async function commandRunAgent(parsed) {
const configPath = option(parsed, "config");
const agentNames = optionList(parsed, "agent");
if (!configPath || agentNames.length === 0) {
throw new Error("run-agent requires --config and at least one --agent");
}
const { cases, fixtureRegistry } = await loadValidatedCorpus();
const selected = selectCases(cases, parsed, { defaultSuite: "core" });
const repeat = numericOption(parsed, "repeat", 1);
if (!Number.isInteger(repeat) || repeat < 1) throw new Error("--repeat must be at least 1");
const outputDirectory = resolve(option(parsed, "out", resultsDirectory));
const server = createEvalServer({ fixtureRegistry });
const serverInfo = await server.start();
const results = [];
const startedAt = new Date().toISOString();
try {
for (const agentName of agentNames) {
const { adapter, configDirectory } = await loadAgentConfig(configPath, agentName);
for (let iteration = 1; iteration <= repeat; iteration += 1) {
for (const task of selected) {
for (const seed of seedOptions(parsed, task.seed)) {
const seedLabel = seed === undefined ? "" : ` seed=${seed}`;
console.log(`[run] ${agentName} ${task.id}${seedLabel} (${iteration}/${repeat})`);
const result = await runCommandTask({
server,
serverInfo,
task,
adapter,
adapterName: agentName,
configDirectory,
iteration,
locale: option(parsed, "locale", "zh-CN"),
seed,
timeoutMs: numericOption(parsed, "timeout", 300_000),
variantOverride: option(parsed, "variant"),
});
results.push(result);
console.log(` ${result.verification.status} in ${result.execution.durationMs}ms`);
}
}
}
}
} finally {
await server.stop();
}
const report = {
schemaVersion: 1,
mode: "agent",
startedAt,
finishedAt: new Date().toISOString(),
selection: selectionMetadata(selected),
seeds: unique(results.map(({ seed }) => seed).filter((seed) => seed !== undefined)),
results,
};
const path = await saveReport(report, outputDirectory);
printSummary(summarizeReports([report]));
console.log(`Report: ${path}`);
if (
results.some(
(result) =>
result.verification.status === "failed" ||
result.execution.timedOut ||
result.execution.exitCode !== 0,
)
) {
process.exitCode = 1;
}
}
async function commandSmoke(parsed) {
const { cases, fixtureRegistry } = await loadValidatedCorpus();
const selected = selectCases(cases, parsed, { defaultSuite: "core" });
const outputDirectory = resolve(option(parsed, "out", resultsDirectory));
const server = createEvalServer({ fixtureRegistry });
const serverInfo = await server.start();
const results = [];
const startedAt = new Date().toISOString();
try {
for (const task of selected) {
for (const seed of seedOptions(parsed, task.seed)) {
const runId = `bsk-${task.id}-${randomUUID().slice(0, 8)}`;
const seedLabel = seed === undefined ? "" : ` seed=${seed}`;
console.log(`[smoke] ${task.id}${seedLabel}`);
const result = await runBskSmokeTask({
bskCommand: option(parsed, "bsk", "bsk"),
outputDirectory,
server,
serverInfo,
task,
runId,
seed,
timeoutMs: numericOption(parsed, "timeout", 60_000),
});
results.push(result);
console.log(
` ${result.verification.status}${result.executionError ? `: ${result.executionError}` : ""}`,
);
}
}
} finally {
await server.stop();
}
const report = {
schemaVersion: 1,
mode: "bsk-smoke",
startedAt,
finishedAt: new Date().toISOString(),
selection: selectionMetadata(selected),
seeds: unique(results.map(({ seed }) => seed).filter((seed) => seed !== undefined)),
results,
};
const path = await saveReport(report, outputDirectory);
printSummary(summarizeReports([report]));
console.log(`Report: ${path}`);
if (results.some((result) => result.executionError || result.verification.status === "failed")) {
process.exitCode = 1;
}
}
async function commandGenerate(parsed) {
if (!parsed.options.has("seed")) throw new Error("generate requires --seed");
const dimensions = unique(optionList(parsed, "seed").map(normalizeSeed)).map(describeSeed);
const output = dimensions.length === 1 ? dimensions[0] : dimensions;
if (option(parsed, "json", false)) console.log(JSON.stringify(output, null, 2));
else console.table(dimensions);
}
async function commandScaffold(parsed) {
const id = parsed.positionals[0];
if (!id) throw new Error("scaffold requires a case id");
const result = await scaffoldCase({
id,
title: option(parsed, "title", id),
suite: option(parsed, "suite", "regression"),
source: option(parsed, "source"),
});
if (option(parsed, "json", false)) console.log(JSON.stringify(result, null, 2));
else {
console.log(`Created ${result.directory}`);
console.log(
"Edit the fixture, prompt, workflow, and assertions, then run eval:browser validate.",
);
}
}
async function commandSummarize(parsed) {
const paths = [...parsed.positionals, ...optionList(parsed, "input")];
if (paths.length === 0) throw new Error("summarize requires at least one report path");
const reports = await Promise.all(
paths.map(async (path) => JSON.parse(await readFile(resolve(path), "utf8"))),
);
const rows = summarizeReports(reports);
if (option(parsed, "json", false)) console.log(JSON.stringify(rows, null, 2));
else printSummary(rows);
}
async function main() {
const parsed = parseArguments(process.argv.slice(2));
if (!parsed.command || option(parsed, "help", false)) return usage();
switch (parsed.command) {
case "validate":
return commandValidate(parsed);
case "list":
return commandList(parsed);
case "coverage":
return commandCoverage(parsed);
case "serve":
return commandServe(parsed);
case "prompt":
return commandPrompt(parsed);
case "verify":
return commandVerify(parsed);
case "run-agent":
return commandRunAgent(parsed);
case "smoke":
return commandSmoke(parsed);
case "generate":
return commandGenerate(parsed);
case "scaffold":
return commandScaffold(parsed);
case "summarize":
return commandSummarize(parsed);
default:
throw new Error(`unknown command ${JSON.stringify(parsed.command)}`);
}
}
main().catch((error) => {
console.error(`browser eval: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
@@ -1,18 +0,0 @@
import { page } from "../../lib/fixtures.mjs";
export default {
id: "diagnostics",
routes: ["/diagnostics"],
render() {
return page({
title: "Diagnostics Lab",
body: `<section class="card"><h1>Diagnostics Lab</h1><p>Inspect console and network buffers.</p><p id="network-status" class="muted">Ping pending</p></section>`,
script: `
console.log("Browser eval console marker: CONSOLE-61");
fetch("/api/ping?run=" + encodeURIComponent(browserEval.runId))
.then((response) => response.json())
.then(({ token }) => { document.querySelector("#network-status").textContent = "Ping completed: " + token; });
`,
});
},
};
@@ -1,25 +0,0 @@
import { escapeHtml, page } from "../../lib/fixtures.mjs";
export default {
id: "form",
routes: ["/form", "/result"],
render({ pathname, runId, query, record }) {
if (pathname === "/form") {
return page({
title: "Browser Eval Form",
body: `<section class="card"><h1>Web form</h1><form action="/result" method="get"><input type="hidden" name="run" value="${escapeHtml(runId)}"><label>Text input<input id="text-input" name="text" autocomplete="off"></label><label>Textarea<textarea id="notes" name="notes"></textarea></label><label>Dropdown<select id="choice" name="choice"><option value="one">One</option><option value="two">Two</option><option value="three">Three</option></select></label><button id="submit" type="submit">Submit</button></form></section>`,
script: `document.querySelector("#submit").addEventListener("keydown", (event) => { if (event.key === "Enter") browserEval.send("form.enter_pressed"); });`,
});
}
const values = {
text: query.get("text") ?? "",
notes: query.get("notes") ?? "",
choice: query.get("choice") ?? "",
};
record("form.submitted", values, { path: pathname });
return page({
title: "Form Result",
body: `<section class="card"><h1>Received!</h1><p id="submitted-values" class="marker">${escapeHtml(values.text)} | ${escapeHtml(values.notes)} | ${escapeHtml(values.choice)}</p></section>`,
});
},
};
@@ -1,71 +0,0 @@
import { escapeHtml, page } from "../../lib/fixtures.mjs";
import { describeSeed } from "../../lib/seeded-generator.mjs";
function nest(content, depth) {
let nested = content;
for (let index = 0; index < depth; index += 1) {
nested = `<div data-depth="${index + 1}">${nested}</div>`;
}
return nested;
}
function textField(dimensions) {
const id = `matrix-text-${dimensions.idSuffix}`;
const input = `<input id="${id}" name="text" autocomplete="off">`;
if (dimensions.labelMode === "wrapped") return `<label>Text input${input}</label>`;
if (dimensions.labelMode === "for") return `<label for="${id}">Text input</label>${input}`;
return `<span id="${id}-label">Text input</span><input id="${id}" name="text" aria-labelledby="${id}-label" autocomplete="off">`;
}
function formMarkup(dimensions, runId) {
const text = textField(dimensions);
const select = `<label>Dropdown<select name="choice"><option value="one">One</option><option value="two">Two</option><option value="three">Three</option></select></label>`;
const fields = dimensions.fieldOrder === "text-first" ? `${text}${select}` : `${select}${text}`;
const decoy =
dimensions.decoy === "disabled-input"
? `<label>Text input<input disabled value="decoy"></label>`
: dimensions.decoy === "duplicate-text"
? `<p class="muted">Text input is intentionally repeated in helper copy.</p>`
: "";
return nest(
`<form action="/matrix/generated-result" method="get"><input type="hidden" name="run" value="${escapeHtml(runId)}"><input type="hidden" name="seed" value="${dimensions.seed}">${decoy}${fields}<button type="submit">Submit matrix form</button></form>`,
dimensions.nestingDepth,
);
}
export default {
id: "generated-form",
routes: ["/matrix/generated-form", "/matrix/generated-result"],
render({ pathname, runId, query, record }) {
const dimensions = describeSeed(query.get("seed") ?? 1);
if (pathname === "/matrix/generated-form") {
const markup = JSON.stringify(formMarkup(dimensions, runId));
const dimensionsJson = JSON.stringify(dimensions);
return page({
title: "Seeded Matrix Form",
body: `<section class="card"><h1>Seeded Matrix Form</h1><p class="marker">SEED-${dimensions.seed}</p><div id="matrix-root">Hydrating…</div></section>`,
script: `
const hydrate = () => {
const root = document.querySelector("#matrix-root");
root.innerHTML = ${markup};
root.querySelector("button[type=submit]").addEventListener("keydown", (event) => {
if (event.key === "Enter") browserEval.send("matrix.enter_pressed");
});
browserEval.send("matrix.ready", ${dimensionsJson});
};
setTimeout(hydrate, ${dimensions.hydrationDelayMs});
`,
});
}
const values = {
seed: dimensions.seed,
text: query.get("text") ?? "",
choice: query.get("choice") ?? "",
};
record("matrix.submitted", values, { path: pathname });
return page({
title: "Matrix Result",
body: `<section class="card"><h1>Matrix Result</h1><p class="marker">MATRIX-OK</p><p>${escapeHtml(values.text)} | ${escapeHtml(values.choice)} | seed ${values.seed}</p></section>`,
});
},
};
@@ -1,17 +0,0 @@
import { page } from "../../lib/fixtures.mjs";
export default {
id: "hover-inspect",
routes: ["/hover-inspect"],
render() {
return page({
title: "Hover and Inspect",
body: `<section class="card"><h1>Hover and Inspect</h1><div id="account-menu" tabindex="0"><button id="account-button" type="button">Account</button><div id="hover-panel"><button id="profile-link" type="button">Profile beta</button></div></div><p id="hover-result" class="marker" hidden>HOVER-17</p><article id="inspect-card" class="card" data-build="2026.08" data-marker="INSPECT-27"><h2>Inspection card</h2><p>Local HTML marker: <code>INSPECT-27</code></p></article></section>`,
script: `
const menu = document.querySelector("#account-menu");
menu.addEventListener("mouseenter", () => { menu.classList.add("open"); browserEval.send("hover.menu_opened"); });
document.querySelector("#profile-link").addEventListener("click", () => { document.querySelector("#hover-result").hidden = false; browserEval.send("hover.profile_clicked"); });
`,
});
},
};
@@ -1,12 +0,0 @@
import { page } from "../../lib/fixtures.mjs";
export default {
id: "fixture-index",
routes: ["/"],
render() {
return page({
title: "Browser Eval Fixtures",
body: `<section class="card"><h1>Browser Eval Fixtures</h1><p class="muted">Deterministic local pages for agent-neutral browser evaluation.</p></section>`,
});
},
};
@@ -1,19 +0,0 @@
import { page, withRun } from "../../lib/fixtures.mjs";
export default {
id: "navigation",
routes: ["/navigation/start", "/navigation/detail"],
render({ pathname, runId }) {
if (pathname === "/navigation/start") {
return page({
title: "Navigation Start",
body: `<section class="card"><h1>Navigation Start</h1><p>Use the link to open the deterministic detail page.</p><a id="details-link" class="button" href="${withRun("/navigation/detail", runId)}">Details</a></section>`,
script: `document.querySelector("#details-link").addEventListener("click", () => browserEval.send("navigation.detail.clicked"));`,
});
}
return page({
title: "Navigation Detail",
body: `<section class="card"><h1>Navigation Detail</h1><p>Stable detail code:</p><p id="navigation-code" class="marker">NAV-42</p></section>`,
});
},
};
@@ -1,21 +0,0 @@
import { page } from "../../lib/fixtures.mjs";
export default {
id: "responsive",
routes: ["/responsive"],
render() {
return page({
title: "Responsive Lab",
body: `<section class="card"><h1>Responsive Lab</h1><p id="viewport-marker" class="marker">VIEWPORT-84</p><p id="viewport-value"></p></section>`,
script: `
const sample = () => {
const data = { width: innerWidth, height: innerHeight, touchPoints: navigator.maxTouchPoints, userAgent: navigator.userAgent };
document.querySelector("#viewport-value").textContent = data.width + "x" + data.height;
browserEval.send("viewport.sample", data);
};
window.addEventListener("resize", sample);
sample();
`,
});
},
};
@@ -1,19 +0,0 @@
import { escapeHtml, page, withRun } from "../../lib/fixtures.mjs";
export default {
id: "tabs",
routes: ["/tabs", "/tabs/child"],
render({ pathname, runId }) {
if (pathname === "/tabs") {
return page({
title: "Tab Lab",
body: `<section class="card"><h1>Tab Lab</h1><p>Keep this original tab open while visiting the child page in a new tab.</p><p class="muted">Child URL: <code>${escapeHtml(withRun("/tabs/child", runId))}</code></p></section>`,
});
}
return page({
title: "Child Tab",
body: `<section class="card"><h1>Child Tab</h1><p id="tab-token" class="marker">TAB-29</p><button id="mark-child" type="button">Mark child visited</button><p id="child-status" hidden>Marked</p></section>`,
script: `document.querySelector("#mark-child").addEventListener("click", () => { document.querySelector("#child-status").hidden = false; browserEval.send("tabs.child_marked"); });`,
});
},
};
-99
View File
@@ -1,99 +0,0 @@
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { verifyTask } from "./oracle.mjs";
import { countPattern, runProcess, substituteArgs } from "./process.mjs";
import { renderPrompt } from "./tasks.mjs";
function safeRunPart(value) {
return value.replaceAll(/[^A-Za-z0-9._-]/g, "-").slice(0, 48);
}
export async function loadAgentConfig(configPath, agentName) {
const absolute = resolve(configPath);
const config = JSON.parse(await readFile(absolute, "utf8"));
const adapter = config.agents?.[agentName];
if (!adapter) {
const names = Object.keys(config.agents ?? {}).join(", ");
throw new Error(`unknown agent ${JSON.stringify(agentName)}; configured agents: ${names}`);
}
if (typeof adapter.command !== "string" || !Array.isArray(adapter.args)) {
throw new Error(`agent ${JSON.stringify(agentName)} must define command and args`);
}
return { adapter, configDirectory: dirname(absolute) };
}
export async function runCommandTask({
server,
serverInfo,
task,
adapter,
adapterName,
configDirectory,
iteration,
locale,
seed = task.seed,
timeoutMs,
variantOverride,
}) {
const variant = variantOverride ?? adapter.variant ?? "unspecified";
const runId = [
safeRunPart(adapterName),
safeRunPart(variant),
safeRunPart(task.id),
iteration,
randomUUID().slice(0, 8),
].join("-");
server.reset(runId);
const prompt = renderPrompt(task, { baseUrl: serverInfo.baseUrl, runId, locale, seed });
const values = {
prompt,
caseId: task.id,
taskId: task.id,
runId,
baseUrl: serverInfo.baseUrl,
seed: seed ?? "",
variant,
};
const args = substituteArgs(adapter.args, values);
const execution = await runProcess(adapter.command, args, {
cwd: adapter.cwd ? resolve(configDirectory, adapter.cwd) : configDirectory,
env: adapter.env,
timeoutMs: adapter.timeoutMs ?? timeoutMs,
});
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
const responseText = execution.stdout;
const combinedOutput = `${execution.stdout}\n${execution.stderr}`;
const verification = verifyTask(task, {
events: server.snapshot(runId).events,
responseText,
});
const defaultErrorPattern = "(?:\\bError:|timed out|unfinished command|session already has)";
return {
runId,
caseId: task.id,
taskId: task.id,
suite: task.suite,
tags: task.tags,
seed,
iteration,
adapter: adapterName,
variant,
adapterMetadata: adapter.metadata ?? {},
locale,
prompt,
execution,
metrics: {
errorCount: countPattern(
combinedOutput,
adapter.metrics?.errorPattern ?? defaultErrorPattern,
),
toolCallCount: countPattern(combinedOutput, adapter.metrics?.toolCallPattern),
responseChars: responseText.length,
},
eventCount: server.snapshot(runId).events.length,
verification,
};
}
-309
View File
@@ -1,309 +0,0 @@
import { mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { verifyTask } from "./oracle.mjs";
import { runProcess } from "./process.mjs";
import { taskUrl } from "./tasks.mjs";
function parseJsonOutput(step) {
try {
return JSON.parse(step.stdout);
} catch (error) {
throw new Error(`could not parse bsk JSON output: ${error.message}`);
}
}
function renderStepError(step) {
const raw = step.stderr.trim() || step.stdout.trim();
if (!raw) return `exit ${step.exitCode}`;
try {
const parsed = JSON.parse(raw);
return parsed.hint ? `${parsed.message} (hint: ${parsed.hint})` : parsed.message;
} catch {
return raw;
}
}
async function hasNonemptyFile(path) {
try {
return (await stat(path)).size > 0;
} catch {
return false;
}
}
function readPath(value, path) {
return path.split(".").reduce((current, part) => current?.[part], value);
}
function resolveValue(value, variables) {
if (typeof value !== "string") return value;
const exact = value.match(/^\{([^{}]+)}$/);
if (exact) {
const resolved = readPath(variables, exact[1]);
if (resolved === undefined) throw new Error(`unknown workflow variable ${exact[1]}`);
return resolved;
}
return value.replaceAll(/\{([^{}]+)\}/g, (_match, path) => {
const resolved = readPath(variables, path);
if (resolved === undefined) throw new Error(`unknown workflow variable ${path}`);
return String(resolved);
});
}
function targetArgs(step, variables) {
if (step.selector) return ["--selector", String(resolveValue(step.selector, variables))];
if (step.ref) return ["--ref", String(resolveValue(step.ref, variables))];
return [];
}
function tabArgs(step, variables) {
return step.tabId === undefined ? [] : ["--tab-id", String(resolveValue(step.tabId, variables))];
}
function matchesWhere(value, where = {}) {
return Object.entries(where).every(([path, expected]) => readPath(value, path) === expected);
}
export async function runBskSmokeTask({
bskCommand,
outputDirectory,
server,
serverInfo,
task,
runId,
seed = task.seed,
timeoutMs = 60_000,
}) {
const steps = [];
const evidence = {};
const variables = {
baseUrl: serverInfo.baseUrl,
runId,
seed: seed ?? "",
url: taskUrl(task, serverInfo.baseUrl, runId, seed),
};
let sessionId;
let executionError;
await mkdir(outputDirectory, { recursive: true });
async function bsk(args, { timeout = timeoutMs } = {}) {
const execution = await runProcess(bskCommand, [...args, "--json"], { timeoutMs: timeout });
steps.push(execution);
if (execution.exitCode !== 0 || execution.timedOut || execution.error) {
throw new Error(
`bsk ${args.join(" ")} failed: ${execution.error ?? renderStepError(execution)}`,
);
}
return parseJsonOutput(execution);
}
async function waitForSiteEvent(step) {
const timeout = step.timeoutMs ?? 3_000;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (
server
.snapshot(runId)
.events.some(
(event) => event.type === step.type && matchesWhere(event.data ?? {}, step.where ?? {}),
)
) {
return { observed: true };
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
}
throw new Error(`local fixture did not report ${step.type} within ${timeout}ms`);
}
async function executeWorkflowStep(step) {
const session = ["--session", sessionId];
let result;
switch (step.action) {
case "navigate":
result = await bsk([
"navigate",
String(resolveValue(step.url ?? "{url}", variables)),
...session,
...tabArgs(step, variables),
]);
break;
case "back":
case "forward":
result = await bsk([`navigate-${step.action}`, ...session, ...tabArgs(step, variables)]);
break;
case "reload":
result = await bsk(["reload", ...session, ...tabArgs(step, variables)]);
break;
case "wait":
result = await bsk(["wait-ms", String(resolveValue(step.duration, variables))]);
break;
case "wait-site-event":
result = await waitForSiteEvent(step);
break;
case "observe":
case "snapshot":
case "html": {
const command = step.action === "html" ? "get-html" : step.action;
result = await bsk([command, ...session, ...tabArgs(step, variables)]);
break;
}
case "screenshot": {
const name = resolveValue(step.artifactName ?? "screenshot.png", variables);
const path = join(outputDirectory, `${runId}-${name}`);
result = await bsk(["screenshot", ...session, ...tabArgs(step, variables), "--out", path]);
if (step.evidence) evidence[step.evidence] = await hasNonemptyFile(path);
break;
}
case "console":
case "network":
result = await bsk([step.action, ...session, ...tabArgs(step, variables)]);
break;
case "click":
case "hover":
result = await bsk([
step.action,
...targetArgs(step, variables),
...session,
...tabArgs(step, variables),
]);
break;
case "fill":
result = await bsk([
"fill",
...targetArgs(step, variables),
"--value",
String(resolveValue(step.value, variables)),
...session,
...tabArgs(step, variables),
]);
break;
case "select":
result = await bsk([
"select",
...targetArgs(step, variables),
...step.values.flatMap((value) => ["--value", String(resolveValue(value, variables))]),
...session,
...tabArgs(step, variables),
]);
break;
case "press":
result = await bsk([
"press",
String(resolveValue(step.key, variables)),
...targetArgs(step, variables),
...session,
...tabArgs(step, variables),
]);
break;
case "tab-list":
result = await bsk(["tab", "list", ...session]);
break;
case "tab-create":
result = await bsk([
"tab",
"create",
...session,
...(step.url ? ["--url", String(resolveValue(step.url, variables))] : []),
]);
break;
case "tab-select":
case "tab-close":
result = await bsk([
"tab",
step.action.slice(4),
String(resolveValue(step.tabId, variables)),
...session,
]);
break;
case "borrow":
case "return":
result = await bsk([
"tab",
step.action,
String(resolveValue(step.tabId, variables)),
...session,
]);
break;
case "resize":
result = await bsk([
"window",
"resize",
...session,
"--width",
String(step.width),
"--height",
String(step.height),
]);
break;
case "emulate":
result = await bsk([
"emulate",
...session,
...tabArgs(step, variables),
"--device",
String(resolveValue(step.device, variables)),
]);
break;
case "request-help":
result = await bsk([
"request-help",
...session,
"--prompt",
String(resolveValue(step.prompt, variables)),
...(step.title ? ["--title", String(resolveValue(step.title, variables))] : []),
...(step.timeout ? ["--timeout", String(step.timeout)] : []),
]);
break;
default:
throw new Error(`unsupported smoke workflow action ${step.action}`);
}
if (step.saveAs) variables[step.saveAs] = result;
if (step.evidence && step.action !== "screenshot") evidence[step.evidence] = true;
}
server.reset(runId);
try {
if (task.smokeSteps.length === 0) throw new Error(`case ${task.id} has no smoke workflow`);
const started = await bsk(["session", "start", "--no-focus"], { timeout: 90_000 });
sessionId = started.session_id;
variables.sessionId = sessionId;
await bsk(["session", "list"]);
for (const step of task.smokeSteps) await executeWorkflowStep(step);
} catch (error) {
executionError = error instanceof Error ? error.message : String(error);
} finally {
if (sessionId) {
try {
const stopped = await bsk(["session", "stop", sessionId], { timeout: 90_000 });
evidence.sessionStopped =
stopped.stopped?.includes(sessionId) === true && (stopped.failed?.length ?? 0) === 0;
if (!evidence.sessionStopped) {
executionError ??= `bsk did not confirm that session ${sessionId} stopped`;
}
} catch (error) {
evidence.sessionStopped = false;
executionError ??= error instanceof Error ? error.message : String(error);
}
}
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 350));
const responseText = steps.map(({ stdout }) => stdout).join("\n");
const events = server.snapshot(runId).events;
return {
runId,
caseId: task.id,
taskId: task.id,
suite: task.suite,
tags: task.tags,
seed,
adapter: "bsk-direct",
variant: "cli-direct",
executionError,
steps,
evidence,
eventCount: events.length,
verification: verifyTask(task, { events, responseText, adapterEvidence: evidence }),
};
}
-216
View File
@@ -1,216 +0,0 @@
import { readFile } from "node:fs/promises";
import { discoverFiles } from "./discovery.mjs";
import { OPERATION_NAMES, WORKFLOW_ACTIONS } from "./operations.mjs";
import { casesDirectory as defaultCasesDirectory } from "./paths.mjs";
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const EVIDENCE_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/;
const TOP_LEVEL_KEYS = new Set([
"$schema",
"schemaVersion",
"id",
"title",
"order",
"suite",
"tags",
"seed",
"source",
"fixture",
"prompts",
"coverage",
"assertions",
"smoke",
]);
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function checkString(errors, value, path) {
if (typeof value !== "string" || value.trim() === "") errors.push(`${path} must be a string`);
}
function validateAssertion(errors, assertion, path, kind) {
if (!isObject(assertion)) {
errors.push(`${path} must be an object`);
return;
}
checkString(errors, assertion.label, `${path}.label`);
if (kind === "site") {
checkString(errors, assertion.type, `${path}.type`);
if (!Number.isInteger(assertion.minCount) || assertion.minCount < 1) {
errors.push(`${path}.minCount must be a positive integer`);
}
if (assertion.where !== undefined && !isObject(assertion.where)) {
errors.push(`${path}.where must be an object`);
}
} else if (kind === "response") {
checkString(errors, assertion.includes, `${path}.includes`);
} else {
checkString(errors, assertion.key, `${path}.key`);
}
}
function validateWorkflowStep(errors, step, path) {
if (!isObject(step)) {
errors.push(`${path} must be an object`);
return;
}
if (!WORKFLOW_ACTIONS.has(step.action)) {
errors.push(`${path}.action is unsupported: ${JSON.stringify(step.action)}`);
}
if (step.saveAs !== undefined && !ID_PATTERN.test(step.saveAs)) {
errors.push(`${path}.saveAs must use lowercase kebab-case`);
}
if (step.evidence !== undefined && !EVIDENCE_PATTERN.test(step.evidence)) {
errors.push(`${path}.evidence must be an identifier`);
}
if (["click", "hover", "fill", "select"].includes(step.action) && !step.selector && !step.ref) {
errors.push(`${path} requires selector or ref`);
}
if (step.action === "fill") checkString(errors, step.value, `${path}.value`);
if (step.action === "select") {
if (!Array.isArray(step.values) || step.values.length === 0) {
errors.push(`${path}.values must be a non-empty array`);
} else if (step.values.some((value) => typeof value !== "string")) {
errors.push(`${path}.values must contain only strings`);
}
}
if (step.action === "press") checkString(errors, step.key, `${path}.key`);
if (step.action === "wait") checkString(errors, step.duration, `${path}.duration`);
if (step.action === "wait-site-event") checkString(errors, step.type, `${path}.type`);
if (["tab-select", "tab-close", "borrow", "return"].includes(step.action)) {
checkString(errors, step.tabId, `${path}.tabId`);
}
if (step.action === "resize") {
if (
!Number.isInteger(step.width) ||
step.width < 1 ||
!Number.isInteger(step.height) ||
step.height < 1
) {
errors.push(`${path}.width and height must be positive integers`);
}
}
if (step.action === "emulate") checkString(errors, step.device, `${path}.device`);
if (step.action === "request-help") checkString(errors, step.prompt, `${path}.prompt`);
if (step.timeoutMs !== undefined && (!Number.isInteger(step.timeoutMs) || step.timeoutMs < 1)) {
errors.push(`${path}.timeoutMs must be a positive integer`);
}
}
export function validateCaseManifest(manifest, { source = "case manifest" } = {}) {
const errors = [];
if (!isObject(manifest)) return [`${source} must contain a JSON object`];
for (const key of Object.keys(manifest)) {
if (!TOP_LEVEL_KEYS.has(key)) errors.push(`unknown top-level property ${key}`);
}
if (manifest.schemaVersion !== 1) errors.push("schemaVersion must equal 1");
if (!ID_PATTERN.test(manifest.id ?? "")) errors.push("id must use lowercase kebab-case");
checkString(errors, manifest.title, "title");
if (manifest.order !== undefined && (!Number.isInteger(manifest.order) || manifest.order < 0)) {
errors.push("order must be a non-negative integer");
}
if (!ID_PATTERN.test(manifest.suite ?? "")) errors.push("suite must use lowercase kebab-case");
if (!Array.isArray(manifest.tags) || manifest.tags.some((tag) => !ID_PATTERN.test(tag))) {
errors.push("tags must be an array of lowercase kebab-case strings");
} else if (new Set(manifest.tags).size !== manifest.tags.length) {
errors.push("tags must not contain duplicates");
}
if (!isObject(manifest.fixture)) errors.push("fixture must be an object");
else if (
typeof manifest.fixture.startPath !== "string" ||
!manifest.fixture.startPath.startsWith("/")
) {
errors.push("fixture.startPath must be an absolute URL path");
}
if (!isObject(manifest.prompts)) errors.push("prompts must be an object");
else {
checkString(errors, manifest.prompts.en, "prompts.en");
checkString(errors, manifest.prompts["zh-CN"], "prompts.zh-CN");
}
if (!Array.isArray(manifest.coverage)) errors.push("coverage must be an array");
else {
for (const operation of manifest.coverage) {
if (!OPERATION_NAMES.has(operation))
errors.push(`coverage contains unknown operation ${operation}`);
}
if (new Set(manifest.coverage).size !== manifest.coverage.length) {
errors.push("coverage must not contain duplicates");
}
}
if (!isObject(manifest.assertions)) errors.push("assertions must be an object");
else {
for (const kind of ["site", "response", "adapter"]) {
if (!Array.isArray(manifest.assertions[kind])) {
errors.push(`assertions.${kind} must be an array`);
} else {
manifest.assertions[kind].forEach((assertion, index) =>
validateAssertion(errors, assertion, `assertions.${kind}[${index}]`, kind),
);
}
}
}
if (!isObject(manifest.smoke) || !Array.isArray(manifest.smoke.steps)) {
errors.push("smoke.steps must be an array");
} else {
manifest.smoke.steps.forEach((step, index) =>
validateWorkflowStep(errors, step, `smoke.steps[${index}]`),
);
}
if (manifest.seed !== undefined && !Number.isSafeInteger(manifest.seed)) {
errors.push("seed must be a safe integer");
}
if (manifest.source !== undefined && !isObject(manifest.source)) {
errors.push("source must be an object");
}
return errors.map((error) => `${source}: ${error}`);
}
function normalizeCase(manifest, sourceFile) {
return Object.freeze({
...manifest,
startPath: manifest.fixture.startPath,
siteAssertions: manifest.assertions.site,
responseAssertions: manifest.assertions.response,
adapterAssertions: manifest.assertions.adapter,
smokeSteps: manifest.smoke.steps,
sourceFile,
});
}
export async function loadCaseManifests({ casesDirectory = defaultCasesDirectory } = {}) {
const files = await discoverFiles(casesDirectory, (path) => path.endsWith(".case.json"));
const cases = [];
const ids = new Map();
const errors = [];
for (const file of files) {
let manifest;
try {
manifest = JSON.parse(await readFile(file, "utf8"));
} catch (error) {
errors.push(`${file}: invalid JSON: ${error.message}`);
continue;
}
const manifestErrors = validateCaseManifest(manifest, { source: file });
errors.push(...manifestErrors);
if (typeof manifest?.id === "string") {
if (ids.has(manifest.id)) {
errors.push(`${file}: duplicate id also declared by ${ids.get(manifest.id)}`);
} else {
ids.set(manifest.id, file);
}
}
if (manifestErrors.length === 0) cases.push(normalizeCase(manifest, file));
}
if (files.length === 0) errors.push(`${casesDirectory}: no .case.json files found`);
if (errors.length > 0) throw new Error(`case validation failed:\n${errors.join("\n")}`);
cases.sort(
(left, right) =>
left.suite.localeCompare(right.suite) ||
(left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER) ||
left.id.localeCompare(right.id),
);
return Object.freeze(cases);
}
-26
View File
@@ -1,26 +0,0 @@
import { OPERATION_CATALOG, WORKFLOW_ACTION_OPERATIONS } from "./operations.mjs";
export function buildOperationCoverage(cases) {
return OPERATION_CATALOG.map(({ operation, manualNote }) => {
const agentCases = cases
.filter((testCase) => testCase.coverage.includes(operation))
.map(({ id }) => id);
let smokeCases;
if (["session.start", "session.stop", "session.list"].includes(operation)) {
smokeCases = cases.filter(({ smokeSteps }) => smokeSteps.length > 0).map(({ id }) => id);
} else {
smokeCases = cases
.filter(({ smokeSteps }) =>
smokeSteps.some((step) => WORKFLOW_ACTION_OPERATIONS[step.action] === operation),
)
.map(({ id }) => id);
}
return {
operation,
agentCases,
smokeCases,
directSmoke: smokeCases.length > 0,
note: smokeCases.length === 0 && manualNote ? `manual lane: ${manualNote}` : undefined,
};
});
}
-24
View File
@@ -1,24 +0,0 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
export async function discoverFiles(root, predicate) {
const files = [];
async function visit(directory) {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const path = join(directory, entry.name);
if (entry.isDirectory()) await visit(path);
else if (entry.isFile() && predicate(path)) files.push(path);
}
}
await visit(root);
return files;
}
-68
View File
@@ -1,68 +0,0 @@
import { pathToFileURL } from "node:url";
import { discoverFiles } from "./discovery.mjs";
import { casesDirectory, fixturesDirectory } from "./paths.mjs";
export async function loadFixtureRegistry({ roots = [fixturesDirectory, casesDirectory] } = {}) {
const files = (
await Promise.all(
roots.map((root) => discoverFiles(root, (path) => path.endsWith(".fixture.mjs"))),
)
).flat();
const routes = new Map();
const definitions = [];
const errors = [];
for (const file of files.sort()) {
let definition;
try {
definition = (await import(pathToFileURL(file).href)).default;
} catch (error) {
errors.push(`${file}: could not import fixture: ${error.message}`);
continue;
}
if (!definition || typeof definition.id !== "string") {
errors.push(`${file}: default export must define id`);
continue;
}
if (!Array.isArray(definition.routes) || definition.routes.length === 0) {
errors.push(`${file}: default export must define non-empty routes`);
continue;
}
if (typeof definition.render !== "function") {
errors.push(`${file}: default export must define render(context)`);
continue;
}
definitions.push({ ...definition, sourceFile: file });
for (const route of definition.routes) {
if (typeof route !== "string" || !route.startsWith("/")) {
errors.push(`${file}: invalid route ${JSON.stringify(route)}`);
} else if (routes.has(route)) {
errors.push(
`${file}: route ${route} is already registered by ${routes.get(route).sourceFile}`,
);
} else {
routes.set(route, { definition, sourceFile: file });
}
}
}
if (errors.length > 0) throw new Error(`fixture validation failed:\n${errors.join("\n")}`);
return Object.freeze({
definitions: Object.freeze(definitions),
routes: Object.freeze([...routes.keys()].sort()),
hasRoute(pathname) {
return routes.has(pathname);
},
render(pathname, context) {
const entry = routes.get(pathname);
return entry ? entry.definition.render({ ...context, pathname }) : null;
},
});
}
export function validateCaseFixtureLinks(cases, fixtureRegistry) {
return cases
.filter(({ startPath }) => !fixtureRegistry.hasRoute(startPath))
.map(({ id, startPath }) => `case ${id} references unregistered fixture route ${startPath}`);
}
-78
View File
@@ -1,78 +0,0 @@
const stylesheet = `
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
body { margin: 0; background: #f4f7fb; color: #182033; }
main { width: min(760px, calc(100% - 40px)); margin: 48px auto; }
.card { background: white; border: 1px solid #d9e1ef; border-radius: 14px; padding: 24px; box-shadow: 0 8px 28px #20304a12; }
h1 { margin-top: 0; }
a, button { font: inherit; }
button, .button { display: inline-block; border: 0; border-radius: 8px; padding: 10px 16px; background: #3157d5; color: white; cursor: pointer; text-decoration: none; }
label { display: block; margin: 16px 0; font-weight: 600; }
input, textarea, select { display: block; box-sizing: border-box; width: 100%; margin-top: 6px; border: 1px solid #9caac2; border-radius: 7px; padding: 9px; font: inherit; }
textarea { min-height: 90px; }
.marker { border-left: 4px solid #3157d5; padding: 8px 12px; background: #eef2ff; font-family: ui-monospace, monospace; }
.muted { color: #5c667a; }
#account-menu { position: relative; display: inline-block; padding-bottom: 12px; }
#hover-panel { display: none; position: absolute; z-index: 2; top: 34px; left: 0; width: 190px; padding: 10px; background: white; border: 1px solid #9caac2; border-radius: 8px; box-shadow: 0 8px 24px #20304a22; }
#account-menu:hover #hover-panel, #account-menu.open #hover-panel { display: block; }
#hover-panel button { width: 100%; }
#inspect-card { margin-top: 80px; }
code { font-family: ui-monospace, monospace; }
`;
const clientScript = `
(() => {
const runId = new URLSearchParams(location.search).get("run");
const navigation = performance.getEntriesByType("navigation")[0];
const send = (type, data = {}) => {
if (!runId) return Promise.resolve();
return fetch("/api/events", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ runId, type, path: location.pathname, data }),
keepalive: true,
}).catch(() => undefined);
};
window.browserEval = { runId, send };
window.addEventListener("pageshow", (event) => {
send("page.shown", {
path: location.pathname,
persisted: event.persisted,
navigationType: navigation?.type ?? "unknown",
});
});
})();
`;
export function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
export function withRun(path, runId, extra = {}) {
const url = new URL(path, "http://browser-eval.local");
url.searchParams.set("run", runId);
for (const [name, value] of Object.entries(extra)) {
if (value !== undefined) url.searchParams.set(name, String(value));
}
return `${url.pathname}${url.search}`;
}
export function page({ title, body, script = "" }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>${stylesheet}</style>
</head>
<body>
<main>${body}</main>
<script>${clientScript}</script>
${script ? `<script>${script}</script>` : ""}
</body>
</html>`;
}
-68
View File
@@ -1,68 +0,0 @@
export const OPERATION_CATALOG = [
{ operation: "session.start" },
{ operation: "session.stop" },
{ operation: "session.list" },
{ operation: "page.navigate" },
{ operation: "page.back" },
{ operation: "page.forward" },
{ operation: "page.reload" },
{ operation: "page.wait" },
{ operation: "inspect.observe" },
{ operation: "inspect.snapshot" },
{ operation: "inspect.html" },
{ operation: "inspect.screenshot" },
{ operation: "inspect.console" },
{ operation: "inspect.network" },
{ operation: "interact.click" },
{ operation: "interact.hover" },
{ operation: "interact.fill" },
{ operation: "interact.select" },
{ operation: "interact.press" },
{ operation: "tabs.list" },
{ operation: "tabs.create" },
{ operation: "tabs.select" },
{ operation: "tabs.close" },
{
operation: "tabs.borrow",
manualNote: "requires an existing user tab and ownership confirmation",
},
{ operation: "tabs.return", manualNote: "paired with borrowing a real user tab" },
{ operation: "assist.resize" },
{ operation: "assist.emulate" },
{ operation: "assist.request-help", manualNote: "intentionally waits for human interaction" },
];
export const WORKFLOW_ACTION_OPERATIONS = {
navigate: "page.navigate",
back: "page.back",
forward: "page.forward",
reload: "page.reload",
wait: "page.wait",
observe: "inspect.observe",
snapshot: "inspect.snapshot",
html: "inspect.html",
screenshot: "inspect.screenshot",
console: "inspect.console",
network: "inspect.network",
click: "interact.click",
hover: "interact.hover",
fill: "interact.fill",
select: "interact.select",
press: "interact.press",
"tab-list": "tabs.list",
"tab-create": "tabs.create",
"tab-select": "tabs.select",
"tab-close": "tabs.close",
borrow: "tabs.borrow",
return: "tabs.return",
resize: "assist.resize",
emulate: "assist.emulate",
"request-help": "assist.request-help",
};
export const WORKFLOW_ACTIONS = new Set([
...Object.keys(WORKFLOW_ACTION_OPERATIONS),
"wait-site-event",
]);
export const OPERATION_NAMES = new Set(OPERATION_CATALOG.map(({ operation }) => operation));
-63
View File
@@ -1,63 +0,0 @@
function readPath(value, path) {
return path.split(".").reduce((current, part) => current?.[part], value);
}
function eventMatches(event, assertion) {
if (event.type !== assertion.type) return false;
return Object.entries(assertion.where ?? {}).every(
([path, expected]) => readPath(event, path) === expected,
);
}
export function verifyTask(task, { events = [], responseText = "", adapterEvidence = {} } = {}) {
const site = task.siteAssertions.map((assertion) => {
const actual = events.filter((event) => eventMatches(event, assertion)).length;
return {
kind: "site",
label: assertion.label,
status: actual >= assertion.minCount ? "passed" : "failed",
expected: `at least ${assertion.minCount}`,
actual,
};
});
const response = task.responseAssertions.map((assertion) => ({
kind: "response",
label: assertion.label,
status: responseText.includes(assertion.includes) ? "passed" : "failed",
expected: `response includes ${JSON.stringify(assertion.includes)}`,
}));
const adapter = task.adapterAssertions.map((assertion) => {
if (!Object.hasOwn(adapterEvidence, assertion.key)) {
return {
kind: "adapter",
label: assertion.label,
status: "unverified",
expected: assertion.key,
};
}
return {
kind: "adapter",
label: assertion.label,
status: adapterEvidence[assertion.key] ? "passed" : "failed",
expected: assertion.key,
actual: adapterEvidence[assertion.key],
};
});
const checks = [...site, ...response, ...adapter];
const failed = checks.filter(({ status }) => status === "failed").length;
const unverified = checks.filter(({ status }) => status === "unverified").length;
return {
taskId: task.id,
status: failed > 0 ? "failed" : unverified > 0 ? "passed-with-unverified" : "passed",
summary: {
passed: checks.length - failed - unverified,
failed,
unverified,
total: checks.length,
},
checks,
};
}
-8
View File
@@ -1,8 +0,0 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export const evalDirectory = dirname(dirname(fileURLToPath(import.meta.url)));
export const casesDirectory = join(evalDirectory, "cases");
export const fixturesDirectory = join(evalDirectory, "fixtures");
export const schemasDirectory = join(evalDirectory, "schemas");
export const resultsDirectory = join(evalDirectory, "results");
-71
View File
@@ -1,71 +0,0 @@
import { spawn } from "node:child_process";
const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
function appendCaptured(current, chunk) {
if (current.length >= MAX_CAPTURE_BYTES) return current;
return `${current}${chunk}`.slice(0, MAX_CAPTURE_BYTES);
}
export function runProcess(command, args, { cwd, env, timeoutMs = 300_000 } = {}) {
return new Promise((resolve) => {
const startedAt = Date.now();
let stdout = "";
let stderr = "";
let timedOut = false;
let settled = false;
const child = spawn(command, args, {
cwd,
env: { ...process.env, ...env },
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => {
stdout = appendCaptured(stdout, chunk.toString());
});
child.stderr.on("data", (chunk) => {
stderr = appendCaptured(stderr, chunk.toString());
});
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 2_000).unref();
}, timeoutMs);
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve({
command,
args,
stdout,
stderr,
timedOut,
durationMs: Date.now() - startedAt,
...result,
});
};
child.once("error", (error) => finish({ exitCode: null, signal: null, error: error.message }));
child.once("close", (exitCode, signal) => finish({ exitCode, signal }));
});
}
export function substituteArgs(args, values) {
return args.map((argument) => {
let result = argument;
for (const [key, value] of Object.entries(values)) {
result = result.replaceAll(`{${key}}`, String(value));
}
return result;
});
}
export function countPattern(text, pattern) {
if (!pattern) return null;
const matches = text.match(new RegExp(pattern, "g"));
return matches?.length ?? 0;
}
@@ -1,64 +0,0 @@
import { relative, sep } from "node:path";
import { validateCaseFixtureLinks } from "./fixture-registry.mjs";
import { casesDirectory } from "./paths.mjs";
function templateRoots(value) {
if (typeof value === "string") {
return [...value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1].split(".")[0]);
}
if (Array.isArray(value)) return value.flatMap(templateRoots);
if (value && typeof value === "object") return Object.values(value).flatMap(templateRoots);
return [];
}
export function validateRepositoryCases(cases, fixtureRegistry) {
const errors = [...validateCaseFixtureLinks(cases, fixtureRegistry)];
for (const testCase of cases) {
const relativePath = relative(casesDirectory, testCase.sourceFile);
const suiteDirectory = relativePath.split(sep)[0];
if (suiteDirectory !== testCase.suite) {
errors.push(
`case ${testCase.id} declares suite ${testCase.suite} but is stored under ${suiteDirectory}`,
);
}
const availableVariables = new Set(["baseUrl", "runId", "seed", "sessionId", "url"]);
const evidence = new Set(["sessionStopped"]);
for (const [index, step] of testCase.smokeSteps.entries()) {
for (const root of templateRoots(step)) {
if (!availableVariables.has(root)) {
errors.push(
`case ${testCase.id} smoke.steps[${index}] references unavailable variable ${root}`,
);
}
}
if (step.saveAs) availableVariables.add(step.saveAs);
if (step.evidence) evidence.add(step.evidence);
}
for (const assertion of testCase.adapterAssertions) {
if (!evidence.has(assertion.key)) {
errors.push(
`case ${testCase.id} expects adapter evidence ${assertion.key} but no smoke step produces it`,
);
}
}
}
return errors;
}
export function repositorySummary(cases, fixtureRegistry) {
const suites = Object.fromEntries(
[...new Set(cases.map(({ suite }) => suite))]
.sort()
.map((suite) => [suite, cases.filter((testCase) => testCase.suite === suite).length]),
);
const tags = [...new Set(cases.flatMap(({ tags }) => tags))].sort();
return {
cases: cases.length,
suites,
tags: tags.length,
fixtureModules: fixtureRegistry.definitions.length,
fixtureRoutes: fixtureRegistry.routes.length,
};
}
-120
View File
@@ -1,120 +0,0 @@
import { mkdir, realpath, stat, writeFile } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { casesDirectory, evalDirectory } from "./paths.mjs";
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
async function exists(path) {
try {
await stat(path);
return true;
} catch (error) {
if (error?.code === "ENOENT") return false;
throw error;
}
}
async function moduleSpecifier(fromDirectory, target) {
const path = relative(await realpath(fromDirectory), await realpath(target))
.split(sep)
.join("/");
return path.startsWith(".") ? path : `./${path}`;
}
export async function scaffoldCase({
id,
title = id,
suite = "regression",
source,
rootCasesDirectory = casesDirectory,
}) {
if (!ID_PATTERN.test(id ?? "")) throw new Error("case id must use lowercase kebab-case");
if (!ID_PATTERN.test(suite ?? "")) throw new Error("suite must use lowercase kebab-case");
const directory = join(rootCasesDirectory, suite, id);
if (await exists(directory)) throw new Error(`case directory already exists: ${directory}`);
await mkdir(directory, { recursive: true });
const marker = `CASE-${id.toUpperCase().replaceAll("-", "_")}`;
const startPath = `/cases/${id}`;
const manifest = {
$schema: "../../../schemas/case.schema.json",
schemaVersion: 1,
id,
title,
order: 10,
suite,
tags: ["badcase", "regression"],
...(source ? { source: { type: "user-badcase", reference: source } } : {}),
fixture: { startPath },
prompts: {
en: `Open {url}, complete the minimal regression scenario, report ${marker}, and close the browser session.`,
"zh-CN": `打开 {url},完成最小回归场景,报告 ${marker},最后关闭浏览器会话。`,
},
coverage: ["session.start", "session.stop", "page.navigate", "inspect.observe"],
assertions: {
site: [
{
label: "regression fixture was displayed",
type: "page.shown",
where: { "data.path": startPath },
minCount: 1,
},
],
response: [{ label: "reported regression marker", includes: marker }],
adapter: [{ label: "browser session was closed", key: "sessionStopped" }],
},
smoke: { steps: [{ action: "navigate" }, { action: "observe" }] },
};
const fixtureHelper = await moduleSpecifier(
directory,
join(evalDirectory, "lib", "fixtures.mjs"),
);
const fixture = `import { escapeHtml, page } from ${JSON.stringify(fixtureHelper)};
const title = ${JSON.stringify(title)};
export default {
id: ${JSON.stringify(id)},
routes: [${JSON.stringify(startPath)}],
render() {
return page({
title,
body: \`<section class="card"><h1>\${escapeHtml(title)}</h1><p class="marker">${marker}</p><p>Replace this page with a privacy-safe minimal reproduction.</p></section>\`,
});
},
};
`;
const readme = `# ${title}
## Original symptom
Describe what the user observed without including private data or proprietary page content.
## Minimal reproduction
Explain the smallest DOM, timing, or browser-state condition that reproduces the failure.
## Expected behavior
Describe the observable success condition and update the case assertions accordingly.
## Regression history
- Source: ${source ?? "add issue, PR, or anonymized user report reference"}
- Fix: add the fixing PR when available
`;
const files = {
manifest: join(directory, `${id}.case.json`),
fixture: join(directory, `${id}.fixture.mjs`),
readme: join(directory, "README.md"),
};
await Promise.all([
writeFile(files.manifest, `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" }),
writeFile(files.fixture, fixture, { flag: "wx" }),
writeFile(files.readme, readme, { flag: "wx" }),
]);
return { directory, files };
}
-40
View File
@@ -1,40 +0,0 @@
function hashSeed(value) {
const text = String(value);
let hash = 2166136261;
for (const character of text) {
hash ^= character.codePointAt(0);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function mulberry32(seed) {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
};
}
export function normalizeSeed(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) ? numeric : hashSeed(value);
}
export function describeSeed(value) {
const seed = normalizeSeed(value);
const random = mulberry32(seed);
const choose = (values) => values[Math.floor(random() * values.length)];
return {
seed,
labelMode: choose(["wrapped", "for", "aria"]),
nestingDepth: 1 + Math.floor(random() * 4),
hydrationDelayMs: choose([0, 40, 120, 250]),
decoy: choose(["none", "disabled-input", "duplicate-text"]),
fieldOrder: choose(["text-first", "select-first"]),
idSuffix: hashSeed(`${seed}:id`).toString(36).slice(0, 6),
};
}
-152
View File
@@ -1,152 +0,0 @@
import { createServer } from "node:http";
import { loadFixtureRegistry } from "./fixture-registry.mjs";
const RUN_ID_PATTERN = /^[A-Za-z0-9._-]{1,120}$/;
function validRunId(value) {
return typeof value === "string" && RUN_ID_PATTERN.test(value);
}
function json(response, status, value) {
response.writeHead(status, {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
});
response.end(`${JSON.stringify(value, null, 2)}\n`);
}
function html(response, status, value) {
response.writeHead(status, {
"cache-control": "no-store",
"content-type": "text/html; charset=utf-8",
});
response.end(value);
}
async function readJson(request) {
let raw = "";
for await (const chunk of request) {
raw += chunk;
if (raw.length > 64 * 1024) {
throw new Error("request body exceeds 64 KiB");
}
}
return raw ? JSON.parse(raw) : {};
}
export function createEvalServer({ host = "127.0.0.1", port = 0, fixtureRegistry } = {}) {
const runs = new Map();
let sequence = 0;
let server;
let registry = fixtureRegistry;
function eventsFor(runId) {
if (!runs.has(runId)) runs.set(runId, []);
return runs.get(runId);
}
function record(runId, type, data = {}, metadata = {}) {
if (!validRunId(runId)) return null;
const event = {
sequence: ++sequence,
timestamp: new Date().toISOString(),
type,
data,
...metadata,
};
eventsFor(runId).push(event);
return event;
}
function snapshot(runId) {
return { runId, events: [...(runs.get(runId) ?? [])] };
}
function reset(runId) {
runs.set(runId, []);
}
async function handle(request, response) {
const origin = `http://${request.headers.host ?? `${host}:${port}`}`;
const url = new URL(request.url ?? "/", origin);
if (request.method === "GET" && url.pathname === "/healthz") {
return json(response, 200, { ok: true });
}
if (request.method === "POST" && url.pathname === "/api/events") {
const body = await readJson(request);
if (!validRunId(body.runId) || typeof body.type !== "string") {
return json(response, 400, { error: "runId and type are required" });
}
const event = record(body.runId, body.type, body.data ?? {}, {
path: typeof body.path === "string" ? body.path : undefined,
});
return json(response, 202, { accepted: true, sequence: event.sequence });
}
const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
if (runMatch) {
const runId = decodeURIComponent(runMatch[1]);
if (!validRunId(runId)) return json(response, 400, { error: "invalid run id" });
if (request.method === "GET") return json(response, 200, snapshot(runId));
if (request.method === "DELETE") {
reset(runId);
return json(response, 200, { runId, reset: true });
}
}
if (request.method === "GET" && url.pathname === "/api/ping") {
const runId = url.searchParams.get("run");
if (!validRunId(runId)) return json(response, 400, { error: "invalid run id" });
record(runId, "network.ping", { token: "NETWORK-73" }, { path: url.pathname });
return json(response, 200, { ok: true, token: "NETWORK-73" });
}
if (request.method !== "GET") return json(response, 405, { error: "method not allowed" });
const runId = url.searchParams.get("run") ?? "manual";
if (!validRunId(runId)) return json(response, 400, { error: "invalid run id" });
const fixture = registry.render(url.pathname, {
runId,
query: url.searchParams,
record: (type, data, metadata) => record(runId, type, data, metadata),
});
if (!fixture) return html(response, 404, "<!doctype html><h1>Not found</h1>");
record(runId, "page.request", { path: url.pathname }, { path: url.pathname });
return html(response, 200, fixture);
}
return {
record,
reset,
snapshot,
async start() {
if (server) throw new Error("eval server is already running");
registry ??= await loadFixtureRegistry();
server = createServer((request, response) => {
handle(request, response).catch((error) => {
json(response, 500, { error: error instanceof Error ? error.message : String(error) });
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, resolve);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("unexpected server address");
return { host, port: address.port, baseUrl: `http://${host}:${address.port}` };
},
async stop() {
if (!server) return;
const active = server;
server = undefined;
await new Promise((resolve, reject) => {
active.close((error) => (error ? reject(error) : resolve()));
active.closeAllConnections?.();
});
},
};
}
-73
View File
@@ -1,73 +0,0 @@
function durationFor(result) {
if (result.execution?.durationMs !== undefined) return result.execution.durationMs;
return (result.steps ?? []).reduce((sum, step) => sum + step.durationMs, 0);
}
export function summarizeReports(reports) {
const groups = new Map();
for (const report of reports) {
for (const result of report.results ?? []) {
const adapter = result.adapter ?? report.adapter ?? "unknown";
const variant = result.variant ?? report.variant ?? "unspecified";
const key = `${adapter}\u0000${variant}`;
if (!groups.has(key)) {
groups.set(key, {
adapter,
variant,
runs: 0,
passed: 0,
fullyVerified: 0,
executionFailures: 0,
errors: 0,
durationMs: 0,
toolCalls: 0,
toolCallSamples: 0,
});
}
const group = groups.get(key);
group.runs += 1;
group.durationMs += durationFor(result);
group.errors += result.metrics?.errorCount ?? (result.executionError ? 1 : 0);
if (result.metrics?.toolCallCount !== null && result.metrics?.toolCallCount !== undefined) {
group.toolCalls += result.metrics.toolCallCount;
group.toolCallSamples += 1;
}
const executionFailed =
Boolean(result.executionError) ||
result.execution?.timedOut ||
(result.execution?.exitCode !== undefined && result.execution.exitCode !== 0);
if (executionFailed) group.executionFailures += 1;
if (result.verification?.status !== "failed" && !executionFailed) group.passed += 1;
if (result.verification?.status === "passed" && !executionFailed) group.fullyVerified += 1;
}
}
return [...groups.values()].map((group) => ({
adapter: group.adapter,
variant: group.variant,
runs: group.runs,
passRate: group.runs ? group.passed / group.runs : 0,
fullyVerifiedRate: group.runs ? group.fullyVerified / group.runs : 0,
executionFailures: group.executionFailures,
errors: group.errors,
averageToolCalls: group.toolCallSamples
? Math.round((group.toolCalls / group.toolCallSamples) * 10) / 10
: null,
averageDurationMs: group.runs ? Math.round(group.durationMs / group.runs) : 0,
}));
}
export function printSummary(rows) {
const printable = rows.map((row) => ({
adapter: row.adapter,
variant: row.variant,
runs: row.runs,
pass: `${Math.round(row.passRate * 100)}%`,
verified: `${Math.round(row.fullyVerifiedRate * 100)}%`,
exec_failures: row.executionFailures,
errors: row.errors,
avg_tools: row.averageToolCalls ?? "-",
avg_ms: row.averageDurationMs,
}));
console.table(printable);
}
-48
View File
@@ -1,48 +0,0 @@
import { loadCaseManifests } from "./case-loader.mjs";
let casesPromise;
export function allTasks({ reload = false } = {}) {
if (reload || !casesPromise) casesPromise = loadCaseManifests();
return casesPromise;
}
export function listTasks(cases) {
return cases.map(({ id, title, suite, tags, seed }) => ({ id, title, suite, tags, seed }));
}
export function getTask(taskId, cases) {
const task = cases.find(({ id }) => id === taskId);
if (!task) {
const ids = cases.map(({ id }) => id).join(", ");
throw new Error(`unknown case ${JSON.stringify(taskId)}; expected one of: ${ids}`);
}
return task;
}
export function taskUrl(task, baseUrl, runId, seed = task.seed) {
const url = new URL(task.startPath, baseUrl);
url.searchParams.set("run", runId);
if (seed !== undefined) url.searchParams.set("seed", String(seed));
return url.toString();
}
function expandTemplate(template, values) {
return Object.entries(values).reduce(
(result, [name, value]) => result.replaceAll(`{${name}}`, String(value)),
template,
);
}
export function renderPrompt(task, { baseUrl, runId, locale = "zh-CN", seed = task.seed }) {
const template = task.prompts[locale];
if (!template) {
throw new Error(`unsupported locale ${JSON.stringify(locale)}; expected en or zh-CN`);
}
return expandTemplate(template, {
baseUrl,
runId: encodeURIComponent(runId),
seed: seed ?? "",
url: taskUrl(task, baseUrl, runId, seed),
});
}
-76
View File
@@ -1,76 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://browser-skill.local/schemas/browser-eval-case.schema.json",
"title": "BrowserSkill browser evaluation case",
"type": "object",
"required": [
"schemaVersion",
"id",
"title",
"suite",
"tags",
"fixture",
"prompts",
"coverage",
"assertions",
"smoke"
],
"properties": {
"$schema": { "type": "string" },
"schemaVersion": { "const": 1 },
"id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
"title": { "type": "string", "minLength": 1 },
"order": { "type": "integer", "minimum": 0 },
"suite": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
"tags": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
"uniqueItems": true
},
"fixture": {
"type": "object",
"required": ["startPath"],
"properties": { "startPath": { "type": "string", "pattern": "^/" } },
"additionalProperties": false
},
"prompts": {
"type": "object",
"required": ["en", "zh-CN"],
"properties": {
"en": { "type": "string", "minLength": 1 },
"zh-CN": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"coverage": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"assertions": {
"type": "object",
"required": ["site", "response", "adapter"],
"properties": {
"site": { "type": "array", "items": { "type": "object" } },
"response": { "type": "array", "items": { "type": "object" } },
"adapter": { "type": "array", "items": { "type": "object" } }
},
"additionalProperties": false
},
"smoke": {
"type": "object",
"required": ["steps"],
"properties": {
"steps": {
"type": "array",
"items": {
"type": "object",
"required": ["action"],
"properties": { "action": { "type": "string" } },
"additionalProperties": true
}
}
},
"additionalProperties": false
},
"seed": { "type": "integer" },
"source": { "type": "object" }
},
"additionalProperties": false
}
-328
View File
@@ -1,328 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
import { runCommandTask } from "../lib/agent-runner.mjs";
import { loadCaseManifests, validateCaseManifest } from "../lib/case-loader.mjs";
import { buildOperationCoverage } from "../lib/coverage.mjs";
import { loadFixtureRegistry } from "../lib/fixture-registry.mjs";
import { verifyTask } from "../lib/oracle.mjs";
import { runProcess } from "../lib/process.mjs";
import { repositorySummary, validateRepositoryCases } from "../lib/repository-validation.mjs";
import { scaffoldCase } from "../lib/scaffold-case.mjs";
import { describeSeed } from "../lib/seeded-generator.mjs";
import { createEvalServer } from "../lib/server.mjs";
import { summarizeReports } from "../lib/summary.mjs";
import { allTasks, getTask, renderPrompt } from "../lib/tasks.mjs";
const cases = await allTasks();
const fixtureRegistry = await loadFixtureRegistry();
async function withServer(run) {
const server = createEvalServer({ fixtureRegistry });
const info = await server.start();
try {
await run(server, info);
} finally {
await server.stop();
}
}
test("case manifests are discovered, ordered, and grouped into suites", () => {
assert.deepEqual(
cases.map(({ id }) => id),
[
"navigation-history",
"form-controls",
"hover-inspect",
"tabs",
"diagnostics",
"mobile-emulation",
"generated-form",
],
);
assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, { core: 6, matrix: 1 });
});
test("repository validation links every case to a fixture and valid workflow evidence", () => {
assert.deepEqual(validateRepositoryCases(cases, fixtureRegistry), []);
const summary = repositorySummary(cases, fixtureRegistry);
assert.equal(summary.cases, 7);
assert.equal(summary.fixtureModules, 8);
assert.equal(summary.fixtureRoutes, 12);
});
test("manifest validation rejects unknown operations and incomplete workflow steps", () => {
const invalid = {
schemaVersion: 1,
id: "invalid-case",
title: "Invalid",
suite: "regression",
tags: ["regression"],
fixture: { startPath: "/invalid" },
prompts: { en: "Open {url}", "zh-CN": "打开 {url}" },
coverage: ["page.teleport"],
assertions: { site: [], response: [], adapter: [] },
smoke: { steps: [{ action: "select", selector: "#choice", values: [] }] },
typo: true,
};
const errors = validateCaseManifest(invalid);
assert.ok(errors.some((error) => error.includes("unknown top-level property typo")));
assert.ok(errors.some((error) => error.includes("unknown operation page.teleport")));
assert.ok(errors.some((error) => error.includes("values must be a non-empty array")));
});
test("fixture server exposes deterministic task and seeded matrix markers", async () => {
await withServer(async (_server, { baseUrl }) => {
const fixtures = [
["/navigation/detail?run=test-markers", "NAV-42"],
["/form?run=test-markers", '<option value="two">Two</option>'],
["/hover-inspect?run=test-markers", "INSPECT-27"],
["/tabs/child?run=test-markers", "TAB-29"],
["/diagnostics?run=test-markers", "CONSOLE-61"],
["/responsive?run=test-markers", "VIEWPORT-84"],
["/matrix/generated-form?run=test-markers&seed=17", "SEED-17"],
];
for (const [path, marker] of fixtures) {
const response = await fetch(`${baseUrl}${path}`);
assert.equal(response.status, 200);
assert.match(await response.text(), new RegExp(marker));
}
});
});
test("server records and resets run-scoped events", async () => {
await withServer(async (server, { baseUrl }) => {
const response = await fetch(`${baseUrl}/api/events`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ runId: "event-test", type: "example", data: { value: 7 } }),
});
assert.equal(response.status, 202);
assert.equal(server.snapshot("event-test").events[0].data.value, 7);
const resetResponse = await fetch(`${baseUrl}/api/runs/event-test`, { method: "DELETE" });
assert.equal(resetResponse.status, 200);
assert.deepEqual(server.snapshot("event-test").events, []);
});
});
test("form case oracle checks submitted values and separates adapter evidence", () => {
const task = getTask("form-controls", cases);
const events = [
{ type: "form.enter_pressed", data: {} },
{
type: "form.submitted",
data: { text: "agent-parity", notes: "BrowserSkill works", choice: "two" },
},
{ type: "page.shown", data: { path: "/result" } },
];
const partial = verifyTask(task, { events, responseText: "The page says Received!" });
assert.equal(partial.status, "passed-with-unverified");
assert.equal(partial.summary.unverified, 2);
const complete = verifyTask(task, {
events,
responseText: "The page says Received!",
adapterEvidence: { screenshotCreated: true, sessionStopped: true },
});
assert.equal(complete.status, "passed");
});
test("navigation case fails when reload is missing", () => {
const task = getTask("navigation-history", cases);
const verification = verifyTask(task, {
events: [
{ type: "navigation.detail.clicked", data: {} },
{ type: "page.shown", data: { path: "/navigation/start" } },
{ type: "page.shown", data: { path: "/navigation/start" } },
{ type: "page.shown", data: { path: "/navigation/detail" } },
{ type: "page.shown", data: { path: "/navigation/detail" } },
],
responseText: "NAV-42",
});
assert.equal(verification.status, "failed");
assert.equal(
verification.checks.find(({ label }) => label === "detail page was reloaded").status,
"failed",
);
});
test("prompt rendering is run-scoped, localized, and seed-aware", () => {
const task = getTask("generated-form", cases);
const input = { baseUrl: "http://127.0.0.1:4173", runId: "prompt-test", seed: 17 };
assert.match(renderPrompt(task, { ...input, locale: "en" }), /seed=17/);
assert.match(renderPrompt(task, { ...input, locale: "zh-CN" }), /seeded form/);
});
test("command adapter carries a run from process launch through oracle verification", async () => {
await withServer(async (server, serverInfo) => {
const script = `
const [baseUrl, runId] = process.argv.slice(1);
const send = (type, data) => fetch(baseUrl + "/api/events", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ runId, type, data }),
});
await send("navigation.detail.clicked", {});
await send("page.shown", { path: "/navigation/start" });
await send("page.shown", { path: "/navigation/start" });
await send("page.shown", { path: "/navigation/detail" });
await send("page.shown", { path: "/navigation/detail" });
await send("page.shown", { path: "/navigation/detail", navigationType: "reload" });
console.log("NAV-42");
`;
const result = await runCommandTask({
server,
serverInfo,
task: getTask("navigation-history", cases),
adapter: {
command: process.execPath,
args: ["--input-type=module", "-e", script, "{baseUrl}", "{runId}"],
variant: "fake-test",
},
adapterName: "fake-agent",
configDirectory: process.cwd(),
iteration: 1,
locale: "en",
timeoutMs: 5_000,
});
assert.equal(result.execution.exitCode, 0);
assert.equal(result.verification.status, "passed-with-unverified");
assert.equal(result.eventCount, 6);
});
});
test("report summary keeps partial verification distinct from full verification", () => {
const rows = summarizeReports([
{
results: [
{
adapter: "agent-a",
variant: "granular-28",
execution: { exitCode: 0, durationMs: 100 },
metrics: { errorCount: 0, toolCallCount: 4 },
verification: { status: "passed-with-unverified" },
},
{
adapter: "agent-a",
variant: "granular-28",
execution: { exitCode: 0, durationMs: 300 },
metrics: { errorCount: 1, toolCallCount: 6 },
verification: { status: "passed" },
},
],
},
]);
assert.equal(rows[0].passRate, 1);
assert.equal(rows[0].fullyVerifiedRate, 0.5);
assert.equal(rows[0].averageDurationMs, 200);
assert.equal(rows[0].averageToolCalls, 5);
assert.equal(rows[0].errors, 1);
});
test("coverage inventory contains all 28 operations and three manual lanes", () => {
const coverage = buildOperationCoverage(cases);
const operations = coverage.map(({ operation }) => operation);
assert.equal(operations.length, 28);
assert.equal(new Set(operations).size, 28);
assert.deepEqual(
coverage.filter(({ directSmoke }) => !directSmoke).map(({ operation }) => operation),
["tabs.borrow", "tabs.return", "assist.request-help"],
);
assert.deepEqual(coverage.find(({ operation }) => operation === "interact.fill").smokeCases, [
"form-controls",
"generated-form",
]);
});
test("seeded dimensions are reproducible and vary across seeds", () => {
assert.deepEqual(describeSeed("badcase-17"), describeSeed("badcase-17"));
assert.notDeepEqual(describeSeed("badcase-17"), describeSeed("badcase-18"));
assert.equal(describeSeed(17).seed, 17);
});
test("badcase scaffold creates a loadable, escaped, self-contained case", async () => {
const temporaryRoot = await mkdtemp(join(tmpdir(), "browser-eval-scaffold-"));
try {
const title = "Quoted `title` ${notCode} <unsafe>";
const result = await scaffoldCase({
id: "reported-timeout",
title,
suite: "regression",
source: "issue-123",
rootCasesDirectory: temporaryRoot,
});
const loaded = await loadCaseManifests({ casesDirectory: temporaryRoot });
assert.equal(loaded[0].id, "reported-timeout");
assert.equal(loaded[0].source.reference, "issue-123");
const generatedRegistry = await loadFixtureRegistry({ roots: [temporaryRoot] });
const html = generatedRegistry.render("/cases/reported-timeout", {
runId: "scaffold-test",
query: new URLSearchParams(),
record() {},
});
assert.match(html, /Quoted `title` \$\{notCode\} &lt;unsafe&gt;/);
assert.match(await readFile(result.files.readme, "utf8"), /issue-123/);
} finally {
await rm(temporaryRoot, { recursive: true, force: true });
}
});
test("CLI accepts pnpm separator and supports suite and tag filters", async () => {
const cli = resolve("evals/browser/cli.mjs");
const coverageResult = await runProcess(process.execPath, [cli, "--", "coverage", "--json"], {
timeoutMs: 5_000,
});
assert.equal(coverageResult.exitCode, 0, coverageResult.stderr);
assert.equal(JSON.parse(coverageResult.stdout).length, 28);
const coreResult = await runProcess(
process.execPath,
[cli, "list", "--suite", "core", "--json"],
{
timeoutMs: 5_000,
},
);
assert.equal(coreResult.exitCode, 0, coreResult.stderr);
assert.equal(JSON.parse(coreResult.stdout).length, 6);
const formResult = await runProcess(process.execPath, [cli, "list", "--tag", "form", "--json"], {
timeoutMs: 5_000,
});
assert.equal(formResult.exitCode, 0, formResult.stderr);
assert.deepEqual(
JSON.parse(formResult.stdout).map(({ id }) => id),
["form-controls", "generated-form"],
);
});
test("CLI validation and seed generation are machine-readable", async () => {
const cli = resolve("evals/browser/cli.mjs");
const validation = await runProcess(process.execPath, [cli, "validate", "--json"], {
timeoutMs: 5_000,
});
assert.equal(validation.exitCode, 0, validation.stderr);
assert.equal(JSON.parse(validation.stdout).ok, true);
const generated = await runProcess(
process.execPath,
[cli, "generate", "--seed", "known-badcase", "--json"],
{ timeoutMs: 5_000 },
);
assert.equal(generated.exitCode, 0, generated.stderr);
assert.equal(typeof JSON.parse(generated.stdout).hydrationDelayMs, "number");
const generatedSet = await runProcess(
process.execPath,
[cli, "generate", "--seed", "4,7,14", "--json"],
{ timeoutMs: 5_000 },
);
assert.equal(generatedSet.exitCode, 0, generatedSet.stderr);
assert.deepEqual(
JSON.parse(generatedSet.stdout).map(({ seed }) => seed),
[4, 7, 14],
);
});
-3
View File
@@ -4,9 +4,6 @@
"packageManager": "pnpm@10.17.0",
"scripts": {
"cli:build": "cargo run -p bsk-protocol --bin dump-schema --locked && pnpm -r build",
"eval:browser": "node evals/browser/cli.mjs",
"eval:browser:check": "node evals/browser/cli.mjs validate && node --test evals/browser/tests/*.test.mjs",
"eval:browser:test": "node --test evals/browser/tests/*.test.mjs",
"ext:build": "pnpm --filter @browser-skill/extension build",
"ext:build:zip": "pnpm --filter @browser-skill/extension zip",
"ext:dev": "pnpm --filter @browser-skill/extension dev",
+41 -21
View File
@@ -5,20 +5,41 @@ npm: [`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wx
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) tool plugin that exposes
[BrowserSkill](https://github.com/Tencent/BrowserSkill) (`bsk`) browser automation to the model.
Each action maps to one `bsk <cmd> --json` invocation: the plugin spawns the bsk CLI, parses its
Each tool maps to one `bsk <cmd> --json` invocation: the plugin spawns the bsk CLI, parses its
structured JSON output, and returns a canonical typed value. The bsk daemon, browser, and browser
extension keep owning the actual browser control — this package is a thin, well-typed bridge.
## Tools
| Tool | Actions | Purpose |
| Tool | bsk command | Purpose |
| --- | --- | --- |
| `browser_session` | `start`, `stop`, `list` | Manage plugin-owned Agent Window sessions. |
| `browser_page` | `navigate`, `back`, `forward`, `reload`, `wait` | Navigate the active tab and wait for page lifecycle events. |
| `browser_inspect` | `observe`, `snapshot`, `html`, `screenshot`, `console`, `network` | Read semantic or diagnostic page state and capture screenshots. |
| `browser_interact` | `click`, `hover`, `fill`, `select`, `press` | Interact with controls using fresh refs or selectors. |
| `browser_tabs` | `list`, `create`, `select`, `close`, `borrow`, `return` | Manage Agent Window tabs and temporarily borrow user tabs. |
| `browser_assist` | `resize`, `emulate`, `request-help` | Resize or emulate the browser and pause for human-only steps. |
| `browser_session_start` | `bsk session start` | Open an Agent Window session; optional initial URL, window size, and device emulation preset. Returns the session id and makes it the current session. |
| `browser_session_stop` | `bsk session stop` | Stop a session (the current one by default) and close its Agent Window. Only plugin-created sessions can be stopped. |
| `browser_session_list` | — (registry only) | List the sessions this plugin created, marking the current one. Foreign daemon sessions are never shown. |
| `browser_navigate` | `bsk navigate` | Navigate the active tab, with `waitUntil` / timeout control. |
| `browser_snapshot` | `bsk snapshot` | Indented aria-tree snapshot with `@eN` refs for interaction tools. |
| `browser_observe` | `bsk observe` | Semantic VOM observation (read-only) with `@eN` refs. |
| `browser_click` | `bsk click` | Click a snapshot ref or CSS selector (button / click-count options). |
| `browser_hover` | `bsk hover` | Hover a ref or selector and allow hover-triggered UI to settle. |
| `browser_fill` | `bsk fill` | Fill an input / textarea / contenteditable (clears first by default). |
| `browser_select` | `bsk select` | Select one or more option values on a `<select>`. |
| `browser_press` | `bsk press` | Dispatch a key or combo, optionally focusing a target first. |
| `browser_screenshot` | `bsk screenshot` | PNG capture of the tab or a ref-cropped element; inlines the image when the deployment supports image input, otherwise returns a file path. |
| `browser_emulate` | `bsk emulate` | Apply or clear mobile device emulation on the active tab. |
| `browser_tab_list` | `bsk tab list` | List user and/or Agent Window tabs visible to an owned session. |
| `browser_tab_create` | `bsk tab create` | Create a foreground or background tab in the Agent Window. |
| `browser_tab_select` | `bsk tab select` | Focus an Agent Window tab. |
| `browser_tab_close` | `bsk tab close` | Close an Agent Window tab. |
| `browser_tab_borrow` | `bsk tab borrow` | Move a user tab into the Agent Window for controlled interaction. |
| `browser_tab_return` | `bsk tab return` | Return a borrowed tab to its original user window. |
| `browser_navigate_back` / `browser_navigate_forward` | `bsk navigate-back` / `bsk navigate-forward` | Traverse tab history and wait for a lifecycle phase. |
| `browser_reload` | `bsk reload` | Reload the active tab, optionally bypassing cache. |
| `browser_wait_for_navigation` | `bsk wait-for-navigation` | Wait explicitly for a page lifecycle event. |
| `browser_request_help` | `bsk request-help` | Pause for login, captcha, OTP, confirmation, or another human browser step. |
| `browser_get_html` | `bsk get-html` | Read raw document or ref-scoped HTML after semantic observation is insufficient. |
| `browser_console` | `bsk console` | Read buffered console/log/exception entries without evaluating JavaScript. |
| `browser_network` | `bsk network` | Read buffered network response/failure metadata. |
| `browser_window_resize` | `bsk window resize` | Resize the owned session's Agent Window. |
`bsk evaluate` and `bsk record` are intentionally not exposed by this plugin: arbitrary page
evaluation is a higher-risk capability, while recording is long-running and needs a dedicated
@@ -42,7 +63,7 @@ seam degrade silently.
One agent conversation can drive several browser sessions at once:
- `browser_session` with `action: start` returns the session id and makes it the **current session**.
- `browser_session_start` returns the session id and makes it the **current session**.
- Every operation tool accepts an optional `session` argument. When omitted, the call acts on the
current session (the one most recently started or used); when given, that session becomes current.
- Every tool result echoes the session it actually acted on, so the model never has to guess.
@@ -51,9 +72,9 @@ One agent conversation can drive several browser sessions at once:
**Ownership boundary**: the bsk daemon may be shared with other agents, terminals, or dsh
instances. The plugin therefore only ever sees and operates on sessions it created itself —
an explicit `session` argument naming a foreign or unknown id is rejected, the `list` action on
`browser_session` shows plugin-created sessions only (no daemon-wide view), and stop/unload cleanup
can never touch a session owned by another program.
an explicit `session` argument naming a foreign or unknown id is rejected, `browser_session_list`
shows plugin-created sessions only (no daemon-wide view), and stop/unload cleanup can never touch
a session owned by another program.
## Installation
@@ -88,7 +109,7 @@ All fields are optional and validated through the plugin's Schemastery `Config`:
# lazyTools: true # reveal browser_* tools only after the skill is invoked
```
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the six
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the 28
`browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the
`browser-skill` skill is actually invoked — the skill catalog entry is the only
advertisement. One successful invocation (model tool call, or a `/browser-skill` user
@@ -110,7 +131,7 @@ like the BrowserSkill extension without leaking a single selector into the host
shell's theme cannot bleed back in.
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
`browser_session` start action; disappears when all sessions stop (or the plugin unloads).
`browser_session_start`; disappears when all sessions stop (or the plugin unloads).
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
stage while the next one loads, and is kept on errors so the card does not flash),
@@ -152,11 +173,11 @@ shell's theme cannot bleed back in.
card). Screenshots additionally attach the image itself when the host mounts an attachment store
and the active model route declares image input; otherwise the PNG path is returned.
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_inspect`. The
custom view keeps a terminal block for every inspect action and, when a screenshot result carries
an image block, resolves the durable attachment through the client session's authorized
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_screenshot`. The
custom view keeps the terminal block (command + output) and, when the settled result carries an
image block, resolves the durable attachment through the client session's authorized
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
The other five tools keep the stock terminal card. The bundle follows the dsh client
Every other `browser_*` tool keeps the stock terminal card. The bundle follows the dsh client
contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules
(`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by
lightningcss.
@@ -177,9 +198,8 @@ pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin build # tsdown -> lib/
## Publishing
The GitHub Actions workflow **Release dsh plugin** publishes this package to npm
as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id, client
bundle registration, and `cordis.patch.yml` name are the same specifier so
dsh can import and materialize the plugin without a ModuleLoader id mismatch.
as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id stays
`dsh-plugin-browserskill`.
Trigger it by pushing a tag that matches `package.json`'s `version`:
@@ -50,10 +50,6 @@
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-tool",
"@deepseek-ai/dsh-client-ui-layout"
],
"external": [
"@deepseek-ai/dsh-client-ui-attachment",
"@deepseek-ai/dsh-client-ui-primitives"
]
}
},
@@ -111,8 +107,6 @@
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@deepseek-ai/schemastery": "^3.18.1",
"@tailwindcss/cli": "^4.3.3",
@@ -18,15 +18,37 @@ function stripFrontmatter(markdown) {
}
const SUPPORTED_BROWSER_TOOLS = [
"browser_session",
"browser_page",
"browser_inspect",
"browser_interact",
"browser_tabs",
"browser_assist",
"browser_session_start",
"browser_session_stop",
"browser_session_list",
"browser_navigate",
"browser_navigate_back",
"browser_navigate_forward",
"browser_reload",
"browser_wait_for_navigation",
"browser_snapshot",
"browser_observe",
"browser_get_html",
"browser_screenshot",
"browser_console",
"browser_network",
"browser_click",
"browser_hover",
"browser_fill",
"browser_select",
"browser_press",
"browser_tab_list",
"browser_tab_create",
"browser_tab_select",
"browser_tab_close",
"browser_tab_borrow",
"browser_tab_return",
"browser_request_help",
"browser_emulate",
"browser_window_resize",
];
function validateModelFacingSkill(markdown, supportedTools) {
function validateModelFacingSkill(markdown) {
if (/\bbsk\b/i.test(markdown)) {
throw new Error("DSH skill must not expose the internal CLI name");
}
@@ -35,9 +57,9 @@ function validateModelFacingSkill(markdown, supportedTools) {
}
const mentioned = new Set(markdown.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? []);
const supported = new Set(supportedTools);
const supported = new Set(SUPPORTED_BROWSER_TOOLS);
const unknown = [...mentioned].filter((name) => !supported.has(name));
const missing = supportedTools.filter((name) => !mentioned.has(name));
const missing = SUPPORTED_BROWSER_TOOLS.filter((name) => !mentioned.has(name));
if (unknown.length > 0) {
throw new Error(`DSH skill mentions unsupported browser tools: ${unknown.join(", ")}`);
}
@@ -53,11 +75,11 @@ const DESCRIPTION =
"debug page activity, or smoke-test UI changes in a managed Agent Window.";
const content = `${stripFrontmatter(source).trim()}\n`;
validateModelFacingSkill(content, SUPPORTED_BROWSER_TOOLS);
validateModelFacingSkill(content);
const banner =
"// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" +
"// Source: skill/SKILL.md (DeepSeek Harness six-tool instructions).\n";
"// Source: skill/SKILL.md (DeepSeek Harness browser_* tool instructions).\n";
writeFileSync(
join(pkg, "src", "skill-content.generated.ts"),
+218 -93
View File
@@ -1,146 +1,271 @@
---
name: browser-skill
description: Browser automation through six injected domain tools.
description: |
Use the injected browser_* tools to automate the user's logged-in Chromium
browser in a managed Agent Window.
---
# browser-skill for DeepSeek Harness
Drive the user's real Chromium browser through the six structured domain tools provided by this
Drive the user's real Chromium browser through the structured `browser_*` tools provided by this
plugin. The browser keeps the user's existing logins and cookies while isolating automation in an
Agent Window. Every call must include the tool's `action` field; other fields depend on that action.
Agent Window. Use the tool schemas exactly as exposed by DeepSeek Harness; arguments are structured
fields, not command-line flags.
## Tool availability
Loading this skill reveals the browser tools for the rest of the conversation. All browser work
must use the injected tools directly. Do not invoke another process to control the browser. Direct
tool calls preserve session ownership, the live observation window, cancellation, attachments, and
cleanup.
Loading this skill reveals the browser tools for the rest of the conversation. If the tools were
hidden before this skill was loaded, start browser work only after the skill invocation succeeds.
The six tools are:
All browser operations must use the injected tools directly. Do not translate the examples in this
document into terminal commands or invoke another process to control the browser. Direct tool calls
preserve session ownership, the live observation window, tool cards, cancellation, and cleanup.
- `browser_session`: start, stop, or list plugin-owned sessions.
- `browser_page`: navigate, back, forward, reload, or wait for navigation.
- `browser_inspect`: observe, snapshot, html, screenshot, console, or network.
- `browser_interact`: click, hover, fill, select, or press.
- `browser_tabs`: list, create, select, close, borrow, or return tabs.
- `browser_assist`: resize the Agent Window, emulate a device, or request help from the user.
## When to use
- Visit and read pages that require the user's existing browser session.
- Fill forms, select options, click through bounded flows, or smoke-test a web UI.
- Inspect tabs, page structure, screenshots, console messages, or network metadata.
- Work with a user tab after explicitly borrowing it into the Agent Window.
Do not use browser automation for tasks that need only local files, a purpose-built API, or an
explanation. Never extract credentials, cookies, tokens, or other secrets. Do not keep control of a
personal tab longer than the immediate task requires.
## Mandatory workflow
Every browser task follows this lifecycle:
1. Call `browser_session` with `action: "start"` and retain its returned `sessionId`.
2. Use that session for every later call. Pass `session` explicitly when more than one exists.
3. Call `browser_session` with `action: "stop"` when the goal is reached or an error ends the task.
Treat cleanup like a finally path unless the user explicitly asks to keep the session open.
1. Call `browser_session_start` and retain its returned `sessionId`.
2. Use that session for every browser operation. Pass `session` explicitly when more than one
session exists.
3. Call `browser_session_stop` when the bounded goal is reached or an error ends the task. Treat
cleanup like a `finally` path unless the user explicitly asks to keep the session open.
Conceptual sequence:
Conceptual tool-call sequence:
```text
browser_session({ action: "start", url: "https://example.com" })
browser_inspect({ action: "observe", session: sessionId })
browser_interact({ action: "click", session: sessionId, target: "@e4" })
browser_inspect({ action: "observe", session: sessionId })
browser_session({ action: "stop", session: sessionId })
browser_session_start({ url: "https://example.com" })
browser_observe({ session: sessionId })
browser_click({ session: sessionId, target: "@e4" })
browser_observe({ session: sessionId })
browser_session_stop({ session: sessionId })
```
The plugin owns only sessions it starts. Never guess or reuse a session id from another program.
When `session` is omitted, most actions use the current owned session, but explicit ids are safer.
The plugin owns only sessions created by `browser_session_start`. `browser_session_list` reports
only those owned sessions. Never guess or reuse a session id from another program. When `session`
is omitted, most tools use the most recently started or used owned session; explicit ids are safer
for multi-session work.
## Stop when the goal is met
Browser work is bounded. Define an observable success condition, take the shortest path, and stop
as soon as success is visible. Do not refresh or click after success. If an action fails twice
without progress, pause instead of brute-forcing. If state is ambiguous, make at most one fresh
observation and then ask the user.
Browser work is a bounded task, not open-ended browsing.
## Observe and interact
1. Define one observable success condition from the user's request.
2. Take the shortest path: observe, act, then perform at most one relevant check.
3. Stop immediately when success is visible. Do not refresh, navigate, click, or perform extra
verification after success.
4. If a human-only step appears, use `browser_request_help`. If an action fails twice without
progress, pause instead of brute-forcing.
5. When uncertain, make at most one fresh observation. If the state is still ambiguous, ask the
user rather than continuing to click.
Use `browser_inspect` with `action: "observe"` as the primary semantic page view. It returns roles,
states, text, and `@eN` refs. Use `action: "snapshot"` when a static accessibility tree is more useful.
Prefer a fresh ref over a raw CSS selector for interactions.
## Core interaction loop
Refs invalidate after navigation and may become stale after large DOM changes. Observe again after
navigation or a meaningful DOM update before using another ref. If an observation identifies a
hover-triggered surface, hover the trigger, observe again, then interact with the revealed control.
Use `browser_navigate` to open a destination in the active Agent Window tab, then inspect the page
before interacting:
Use `browser_interact` as follows:
1. `browser_observe` is the primary semantic view. It returns roles, states, text, and `@eN` refs,
including hints about conditional hover or focus surfaces.
2. `browser_snapshot` is the static accessibility-tree fallback when the semantic view is
insufficient or a stricter tree is useful.
3. Prefer a fresh `@eN` ref over a raw CSS selector for `browser_click`, `browser_hover`,
`browser_fill`, `browser_select`, and targeted `browser_press` calls.
4. Observe again after navigation or a meaningful DOM change before using another ref.
- `action: "click"`: requires `target`; optionally accepts `button` and `clickCount`.
- `action: "hover"`: requires `target`; optionally accepts `modifiers`, `settleMs`, and `timeoutMs`.
- `action: "fill"`: requires `target` and `value`; `noClear` appends instead of replacing.
- `action: "select"`: requires `target` and a non-empty `values` array. Values are option value
attributes, not necessarily the visible labels.
- `action: "press"`: requires `key`; optionally focus `target` first and set `holdMs`.
Refs invalidate after navigation and may also become stale after large DOM changes. Never carry a
ref to a new page. If an observation marks a control as hover-triggered, call `browser_hover` on the
trigger, observe again, and then act on the newly visible ref.
## Navigate and wait
Use interaction tools according to their schemas:
Use `browser_page` with these actions:
- `browser_click`: click a ref or selector; optionally choose mouse button and click count.
- `browser_hover`: move to a ref or selector and allow hover UI to settle; then observe again.
- `browser_fill`: clear and fill a text-capable element; set `noClear` only when appending is
intentional.
- `browser_select`: pass `values` as an array, including for a single option.
- `browser_press`: send a key or combo, optionally focusing `target` first.
- `navigate` requires `url` and optionally accepts `waitUntil` and `timeoutMs`.
- `back` and `forward` move one history entry and optionally accept lifecycle wait fields.
- `reload` refreshes the current tab; set `hard: true` only when bypassing cache is necessary.
- `wait` waits for an expected navigation after an interaction that did not already wait.
Actions may trigger a navigation after the action itself returns. Use
`browser_wait_for_navigation` only when that lifecycle wait is actually needed, then observe the
new page.
Avoid speculative waits when no navigation is expected. After any page change, discard old refs and
observe again.
## Reading priority
## Observation and page reading priority
Escalate page reading only as needed:
1. `browser_inspect` with `action: "observe"` for normal understanding and refs.
2. The same tool with `action: "snapshot"` for a stricter static tree.
3. `action: "html"` for exact markup that semantic views cannot provide; keep `maxBytes` bounded and
use only a fresh ref when scoping a subtree.
4. `action: "screenshot"` for layout, styling, canvas, or genuinely visual evidence. A fresh `ref`
crops to an element; omit it for the visible tab.
1. `browser_observe` for normal semantic understanding and interaction refs.
2. `browser_snapshot` for a stricter static accessibility tree.
3. `browser_get_html` for hidden DOM, metadata, or exact markup that the semantic views cannot
provide. A `ref` must come from a fresh observation; keep `maxBytes` bounded.
4. `browser_screenshot` for layout, styling, canvas, image content, or another genuinely visual
question. Pass a fresh `ref` to crop to one element, or omit it for the visible tab.
Do not start with raw HTML or a screenshot merely to discover ordinary controls.
Do not start with raw HTML or a screenshot merely to discover ordinary controls. Screenshots may
return an inline image or a saved path depending on the deployment.
## Tabs and the Agent Window boundary
Use `browser_tabs` with these actions:
Write actions normally affect only Agent Window tabs.
- `list` returns visible tabs; `scope: "user"` finds a user tab before borrowing.
- `create` makes an Agent Window tab and optionally accepts `url`, `active`, and `index`.
- `select` focuses an Agent Window tab and requires a returned `tabId`.
- `close` closes an Agent Window tab and requires a returned `tabId`.
- `borrow` moves a user tab into the Agent Window and requires a listed user `tabId`.
- `return` restores a borrowed tab and requires that borrowed `tabId`.
- `browser_tab_list` lists `agent`, `user`, or `all` tabs visible to the owned session. Use
`scope: "user"` to locate a user tab before borrowing it.
- `browser_tab_create` creates an Agent Window tab. It is active by default; set `active: false`
for a background tab.
- `browser_tab_select` focuses an Agent Window tab.
- `browser_tab_close` closes an Agent Window tab.
- `browser_tab_borrow` moves a chosen user tab into the Agent Window before any write action.
- `browser_tab_return` returns a borrowed tab to its original window and position. Return it as
soon as the immediate task is finished; stopping the session is only the fallback cleanup.
Return borrowed tabs as soon as the immediate task finishes. Stopping the session is only fallback
cleanup. Never invent a tab id.
Use only a `tabId` returned by `browser_tab_list` or `browser_tab_create`. Do not assume a tab id
from page position or title.
## Human help and display controls
## Navigation and lifecycle
Use `browser_assist` with `action: "request-help"` for login, captcha, OTP, payment confirmation,
consent, or another human-only step. `prompt` is required. Optional `targets` highlight fresh refs or
selectors. `completionCriteria` can detect explicit stable success through URL, selector, or text
conditions. After the user continues or criteria complete, observe again before reasoning about the
new state.
- `browser_navigate` opens a URL and can wait for a requested lifecycle phase.
- `browser_navigate_back` and `browser_navigate_forward` move one history entry.
- `browser_reload` reloads the current tab; use `hard: true` only when bypassing cache is necessary.
- `browser_wait_for_navigation` waits for a lifecycle event after an action that did not already
wait. Avoid speculative waits when no navigation is expected.
Use `action: "resize"` with `width` and `height` to resize the outer Agent Window. Use
`action: "emulate"` with a device preset or explicit width and height; `mobile: true` also requires
dimensions. Use `off: true` alone to clear emulation. Emulation is per tab.
After navigation, history traversal, or reload, discard old refs and call `browser_observe` or
`browser_snapshot` again.
## Read-only diagnostics
## Ask the user for browser help
Use `browser_inspect` with `action: "console"` for buffered logs and exceptions, and
`action: "network"` for response and failure metadata. Both accept `tabId`, `since`, `limit`, and
`maxTextChars`; continue with the returned sequence cursor rather than rereading the same entries.
Console additionally accepts `includeStack`.
Call `browser_request_help` for login, captcha, OTP, payment confirmation, consent, or another step
that requires the user. Give a clear `prompt`; when the prompt refers to a concrete control, pass
its fresh ref or selector in `targets` so the overlay can highlight it. Use `title` only when it
adds clarity.
Arbitrary page script evaluation and interaction recording are intentionally unsupported. Do not
invent tool names or route around that limitation.
Optional `completionCriteria` can finish the pause automatically when the page has an explicit,
stable success signal. It supports `any` or `all` conditions using `urlContains`, `urlMatches`,
`selectorExists`, `selectorMissing`, `textExists`, or `textMissing`, plus `stableForMs`.
```text
browser_request_help({
session: sessionId,
title: "Sign in required",
prompt: "Complete sign-in, then return control after the account page appears.",
targets: ["@e7"],
timeoutMs: 300000,
completionCriteria: {
any: [{ urlContains: "/account" }, { selectorExists: "[data-testid='account-menu']" }],
stableForMs: 1000
}
})
```
Interpret outcomes deliberately:
- `continued` or `completed`: the user returned control or the explicit detector matched. Observe
again before reasoning about the new state or using refs.
- `cancelled`: stop the requested flow.
- `timed_out`: report the timeout; do not repeatedly reopen the prompt.
- `disabled`: unattended mode does not allow help; proceed only if safe or stop gracefully.
- `navigated`: legacy outcome; do not treat navigation alone as proof of success.
## Debugging without page scripting
Use the read-only debugging tools only when relevant to the user's task:
- `browser_console` reads buffered console messages, browser logs, and JavaScript exceptions.
Set `includeStack: true` when stack frames are useful.
- `browser_network` reads buffered response and failure metadata. It does not return request or
response headers or bodies and does not modify traffic.
Both tools accept `tabId`, `since`, `limit`, and `maxTextChars`. Their result returns `nextSince`;
pass that value as the next `since` cursor instead of repeatedly requesting the same entries.
Arbitrary page script evaluation and interaction recording are intentionally unsupported in this
DeepSeek Harness plugin. Do not invent tool names or route around this limitation. If the task
strictly requires either capability, explain that it is not available in this phase.
## Window and device controls
- `browser_window_resize` changes the Agent Window's outer `width` and `height`; each dimension
must be from 100 through 7680 CSS pixels.
- `browser_emulate` applies a built-in device preset or explicit viewport settings to the active
tab. Explicit `width` and `height` must be supplied together. `mobile: true` also requires both
dimensions. Use `off: true` alone to clear emulation. Emulation is per-tab and new tabs do not
inherit it.
## Tool reference
### Sessions
| Tool | Purpose |
| --- | --- |
| `browser_session_start` | Start an owned Agent Window session, optionally with URL, size, browser instance, focus behavior, or device preset. |
| `browser_session_stop` | Stop an owned session and close its Agent Window. |
| `browser_session_list` | List owned sessions and identify the current one. |
### Navigation and reading
| Tool | Purpose |
| --- | --- |
| `browser_navigate` | Navigate the active tab to a URL. |
| `browser_navigate_back` | Go back one history entry. |
| `browser_navigate_forward` | Go forward one history entry. |
| `browser_reload` | Reload the active tab, optionally bypassing cache. |
| `browser_wait_for_navigation` | Wait for an expected page lifecycle event. |
| `browser_observe` | Read the primary semantic page representation with refs. |
| `browser_snapshot` | Read the static accessibility-tree fallback with refs. |
| `browser_get_html` | Read raw document or ref-scoped HTML as a last resort. |
| `browser_screenshot` | Capture the visible tab or a ref-cropped element. |
### Interaction
| Tool | Purpose |
| --- | --- |
| `browser_click` | Click a ref or CSS selector. |
| `browser_hover` | Hover a ref or selector to reveal conditional UI. |
| `browser_fill` | Fill a text-capable element. |
| `browser_select` | Select one or more option values. |
| `browser_press` | Send a keyboard key or combo. |
### Tabs
| Tool | Purpose |
| --- | --- |
| `browser_tab_list` | List visible user and Agent Window tabs. |
| `browser_tab_create` | Create an Agent Window tab. |
| `browser_tab_select` | Focus an Agent Window tab. |
| `browser_tab_close` | Close an Agent Window tab. |
| `browser_tab_borrow` | Move a user tab into the Agent Window. |
| `browser_tab_return` | Return a borrowed tab to its original window. |
### Assistance, diagnostics, and display
| Tool | Purpose |
| --- | --- |
| `browser_request_help` | Pause for an explicit user browser step. |
| `browser_console` | Read buffered console and exception entries. |
| `browser_network` | Read buffered response and failure metadata. |
| `browser_window_resize` | Resize the Agent Window. |
| `browser_emulate` | Apply or clear per-tab device emulation. |
## Error handling and final checklist
On a stale ref, observe again and retry once. On an unknown tab, list tabs instead of guessing. On
an unknown session, list owned sessions or start one; never try foreign ids. On timeout, decide
whether one longer purposeful wait is justified, and never immediately repeat a failed history
action without first checking page state.
On a stale ref, observe again and retry once with a fresh ref. On a closed or unknown tab, list
tabs instead of guessing. On an unknown session, list owned sessions or start a new one; never try
foreign ids. On timeout, decide whether one longer, purposeful wait is justified. Avoid repeated
actions with unchanged state.
Before finishing, verify that the observable goal is met or the blocker is clear, borrowed tabs are
returned when practical, the owned session is stopped, and no extra page action occurs after
success.
Before finishing a browser task, verify that:
- The user's observable goal is met, or the blocker is clearly reported.
- Any borrowed tab has been returned when practical.
- The owned session has been stopped unless the user explicitly asked to keep it open.
- No further page action is taken after success.
@@ -7,7 +7,7 @@
* are hidden from every surface, so their Agent Windows would otherwise
* linger with no way back to them.
*
* Ownership lineage: browser_session action=start records the calling agent's
* Ownership lineage: browser_session_start records the calling agent's
* session id PLUS every ancestor along `header.parentSession` (a subagent's
* browsers are reaped when any ancestor is archived, the root conversation
* included). Lineage is resolved through the host session store; an
@@ -1,334 +0,0 @@
/**
* The six model-facing BrowserSkill tools. Each action dispatches to a private
* operation handler, so the model receives only six schemas while every action
* retains the existing ownership, cancellation, queuing, observation,
* screenshot, and presentation behavior.
*/
import { defineTool, type ParameterSchemaSpec, type ToolDefinition } from "@deepseek-ai/dsh-tools";
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM, WAIT_UNTIL_PARAM } from "./tool-params";
import { createBrowserOperationDefinitions, type ToolDeps } from "./tools";
const DEVICE_PRESETS = [
"iphone-14",
"iphone-14-pro-max",
"iphone-se",
"pixel-7",
"galaxy-s23",
"ipad-mini",
"galaxy-tab-s8",
] as const;
const TARGET_PARAM = {
type: "string",
description: "Snapshot ref such as @e3, or a CSS selector.",
} as const;
const DOMAIN_RESULT = { type: "json" } as const;
interface BrowserToolSpec {
name: string;
description: string;
actions: Record<string, string>;
parameters: ParameterSchemaSpec;
}
function operationArgs(args: unknown): Record<string, unknown> {
const { action: _action, ...rest } = args as Record<string, unknown>;
return rest;
}
function definitionFor(
definitions: ReadonlyMap<string, ToolDefinition>,
actions: Record<string, string>,
action: unknown,
): ToolDefinition {
const operationName = typeof action === "string" ? actions[action] : undefined;
const definition = operationName === undefined ? undefined : definitions.get(operationName);
if (definition === undefined) throw new Error(`unsupported browser action: ${String(action)}`);
return definition;
}
function defineBrowserTool(
spec: BrowserToolSpec,
definitions: ReadonlyMap<string, ToolDefinition>,
): ToolDefinition {
const actionNames = Object.keys(spec.actions);
return defineTool({
name: spec.name,
description: spec.description,
parameters: {
action: {
type: "string",
required: true,
enum: actionNames,
description:
"Operation to perform. Other arguments are action-dependent and are validated by that operation.",
},
...spec.parameters,
},
output: {
schema: DOMAIN_RESULT,
render(args, value) {
const definition = definitionFor(definitions, spec.actions, args.action);
return definition.output.render(operationArgs(args), value);
},
},
isConcurrencySafe(args) {
const definition = definitionFor(definitions, spec.actions, args.action);
return definition.isConcurrencySafe?.(operationArgs(args)) === true;
},
async execute(args, exec) {
const definition = definitionFor(definitions, spec.actions, args.action);
return (await definition.execute(operationArgs(args), exec)) as never;
},
presentCall(args) {
const definition = definitionFor(definitions, spec.actions, args.action);
return definition.presentCall?.(operationArgs(args));
},
presentResult(args, result) {
const definition = definitionFor(definitions, spec.actions, args.action);
return definition.presentResult?.(operationArgs(args), result);
},
});
}
const BROWSER_TOOL_SPECS: BrowserToolSpec[] = [
{
name: "browser_session",
description:
"Manage plugin-owned browser sessions. Actions: start opens an Agent Window; stop closes an " +
"owned session; list returns owned sessions. For start, url/device/width/height/noFocus/browser " +
"are optional. For stop, session is optional and defaults to the current owned session.",
actions: {
start: "session.start",
stop: "session.stop",
list: "session.list",
},
parameters: {
session: SESSION_PARAM,
url: { type: "string", description: "Initial URL for start." },
width: { type: "integer", description: "Agent Window width; start requires height too." },
height: { type: "integer", description: "Agent Window height; start requires width too." },
noFocus: { type: "boolean", description: "Start the Agent Window in the background." },
browser: { type: "string", description: "Browser instance id for start." },
device: { type: "string", enum: DEVICE_PRESETS, description: "Device preset for start." },
},
},
{
name: "browser_page",
description:
"Navigate and wait on the active Agent Window tab. Actions: navigate, back, forward, reload, " +
"wait. navigate requires url; reload optionally accepts hard; navigation actions accept " +
"waitUntil/timeoutMs. Observe again after a meaningful page change before reusing refs.",
actions: {
navigate: "page.navigate",
back: "page.back",
forward: "page.forward",
reload: "page.reload",
wait: "page.wait",
},
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
url: { type: "string", description: "Destination URL; required for navigate." },
waitUntil: WAIT_UNTIL_PARAM,
timeoutMs: TIMEOUT_MS_PARAM,
hard: { type: "boolean", description: "Bypass cache for reload." },
},
},
{
name: "browser_inspect",
description:
"Read page state without arbitrary script execution. Actions: observe, snapshot, html, " +
"screenshot, console, network. Prefer observe, then snapshot, then bounded html; use screenshot " +
"for visual evidence. console/network support cursor fields since/limit/maxTextChars.",
actions: {
observe: "inspect.observe",
snapshot: "inspect.snapshot",
html: "inspect.html",
screenshot: "inspect.screenshot",
console: "inspect.console",
network: "inspect.network",
},
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
maxDepth: { type: "integer", description: "Tree depth cap for observe/snapshot." },
maxTokens: { type: "integer", description: "Token cap for observe/snapshot." },
ref: { type: "string", description: "Fresh ref for scoped html or cropped screenshot." },
maxBytes: { type: "integer", description: "HTML byte cap." },
since: { type: "integer", description: "Console/network sequence cursor." },
limit: { type: "integer", description: "Console/network entry cap." },
maxTextChars: { type: "integer", description: "Console/network per-entry text cap." },
includeStack: { type: "boolean", description: "Include console stack frames." },
},
},
{
name: "browser_interact",
description:
"Interact with an element in the active Agent Window tab. Actions: click, hover, fill, select, " +
"press. click/hover/fill/select require target; fill also requires value; select requires " +
"values; press requires key and may optionally focus target first.",
actions: {
click: "interact.click",
hover: "interact.hover",
fill: "interact.fill",
select: "interact.select",
press: "interact.press",
},
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
target: TARGET_PARAM,
button: { type: "string", enum: ["left", "middle", "right"], description: "Click button." },
clickCount: { type: "integer", description: "Click count." },
value: { type: "string", description: "Text for fill." },
noClear: { type: "boolean", description: "Append instead of clearing for fill." },
modifiers: {
type: "array",
items: { type: "string", enum: ["alt", "ctrl", "meta", "shift"] },
description: "Modifiers held during hover.",
},
settleMs: { type: "integer", description: "Hover settle delay." },
timeoutMs: TIMEOUT_MS_PARAM,
values: {
type: "array",
items: { type: "string" },
description: "Option values for select; at least one is required.",
},
key: { type: "string", description: "Key or combo for press." },
holdMs: { type: "integer", description: "Key hold duration for press." },
},
},
{
name: "browser_tabs",
description:
"Manage tabs visible to an owned session. Actions: list, create, select, close, borrow, return. " +
"select/close/borrow/return require tabId from list/create. Borrow moves a user tab into the " +
"Agent Window; return it as soon as the task finishes.",
actions: {
list: "tabs.list",
create: "tabs.create",
select: "tabs.select",
close: "tabs.close",
borrow: "tabs.borrow",
return: "tabs.return",
},
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
scope: {
type: "string",
enum: ["user", "agent", "all"],
description: "Tab scope for list.",
},
url: { type: "string", description: "Initial URL for create." },
active: { type: "boolean", description: "Focus the created tab (default true)." },
index: { type: "integer", description: "Insertion index for create." },
},
},
{
name: "browser_assist",
description:
"Display and human-assistance operations. Actions: resize, emulate, request-help. resize " +
"requires width/height; emulate accepts device or width/height/mobile, or off alone; " +
"request-help requires prompt and can wait for explicit completion criteria.",
actions: {
resize: "assist.resize",
emulate: "assist.emulate",
"request-help": "assist.request-help",
},
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
width: { type: "integer", description: "Window/viewport width." },
height: { type: "integer", description: "Window/viewport height." },
device: { type: "string", enum: DEVICE_PRESETS, description: "Emulation device preset." },
mobile: { type: "boolean", description: "Enable a mobile viewport with width/height." },
off: { type: "boolean", description: "Clear emulation; use alone." },
prompt: { type: "string", description: "Instructions shown to the user for request-help." },
title: { type: "string", description: "Optional request-help title." },
targets: {
type: "array",
items: { type: "string" },
description: "Refs/selectors highlighted for request-help.",
},
timeoutMs: TIMEOUT_MS_PARAM,
completionCriteria: {
type: "object",
additionalProperties: false,
description: "Automatic completion detector for request-help.",
properties: {
any: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
urlContains: { type: "string" },
urlMatches: { type: "string" },
selectorExists: { type: "string" },
selectorMissing: { type: "string" },
textExists: { type: "string" },
textMissing: { type: "string" },
},
},
},
all: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
urlContains: { type: "string" },
urlMatches: { type: "string" },
selectorExists: { type: "string" },
selectorMissing: { type: "string" },
textExists: { type: "string" },
textMissing: { type: "string" },
},
},
},
stableForMs: { type: "integer" },
},
},
},
},
];
function indexOperations(definitions: ToolDefinition[]): ReadonlyMap<string, ToolDefinition> {
const indexed = new Map<string, ToolDefinition>();
for (const definition of definitions) {
if (indexed.has(definition.name)) {
throw new Error(`duplicate browser operation definition: ${definition.name}`);
}
indexed.set(definition.name, definition);
}
const routed = new Set(BROWSER_TOOL_SPECS.flatMap((spec) => Object.values(spec.actions)));
const missing = [...routed].filter((name) => !indexed.has(name));
const unreachable = [...indexed.keys()].filter((name) => !routed.has(name));
if (missing.length > 0 || unreachable.length > 0) {
throw new Error(
[
missing.length > 0 ? `missing operations: ${missing.join(", ")}` : undefined,
unreachable.length > 0 ? `unreachable operations: ${unreachable.join(", ")}` : undefined,
]
.filter(Boolean)
.join("; "),
);
}
return indexed;
}
/** Register the complete six-tool browser suite; returns its combined disposer. */
export function registerBrowserTools(deps: ToolDeps): () => void {
const definitions = indexOperations(createBrowserOperationDefinitions(deps));
const disposers = BROWSER_TOOL_SPECS.map((spec) =>
deps.ctx.tools.register(defineBrowserTool(spec, definitions)),
).filter((dispose): dispose is () => void => typeof dispose === "function");
return () => {
for (const dispose of disposers.splice(0)) dispose();
};
}
@@ -1,4 +1,4 @@
/* browser_inspect screenshot toolview: disclosure row over a terminal block plus the
/* browser_screenshot toolview: disclosure row over a terminal block plus the
screenshot image. Semantic tokens only (--dsw-alias-*), no literal colors. */
.card {
@@ -1,6 +1,8 @@
// browser_inspect keyed toolview: all inspect actions retain a terminal card;
// screenshot results additionally render the image attachment. Pure function
// of the frozen call/result slice, so live and replay paths stay identical.
// browser_screenshot keyed toolview: the terminal card's command line and
// output stay exactly as the host renders them, and a result carrying an
// image block additionally renders the screenshot itself (thumbnail with the
// shared click-to-open lightbox). Pure function of the frozen call/result
// slice — live and replay paths render identically.
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
import {
@@ -17,13 +19,13 @@ import {
} from "@deepseek-ai/dsh-client-ui-primitives";
import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client";
import { useState } from "react";
import css from "./BrowserInspectToolView.module.css";
import css from "./ScreenshotToolView.module.css";
/** Resolved loader bound to the owning session at registration time. */
export type BrowserInspectImageLoader = (attachment: ImageAttachmentRef) => Promise<string>;
export type ScreenshotImageLoader = (attachment: ImageAttachmentRef) => Promise<string>;
export type BrowserInspectToolViewProps = ToolCallViewProps & {
loadImage: BrowserInspectImageLoader;
export type ScreenshotToolViewProps = ToolCallViewProps & {
loadImage: ScreenshotImageLoader;
};
type ViewState = "running" | "ok" | "error";
@@ -34,7 +36,6 @@ interface ViewModel {
readonly output: string | null;
readonly image: ImageAttachmentRef | null;
readonly summary: string;
readonly title: string;
}
const TERMINAL_LABELS: Partial<TerminalBlockLabels> = {
@@ -65,71 +66,18 @@ function firstLine(text: string): string {
}
/** Rebuild the command line from the logged arguments (mirrors the host presenter). */
const INSPECT_COMMANDS: Record<string, string> = {
observe: "observe",
snapshot: "snapshot",
html: "get-html",
screenshot: "screenshot",
console: "console",
network: "network",
};
function titleOf(action: string): string {
return action === "html" ? "HTML" : `${action.slice(0, 1).toUpperCase()}${action.slice(1)}`;
}
interface InspectArgs {
action?: unknown;
session?: unknown;
tabId?: unknown;
maxDepth?: unknown;
maxTokens?: unknown;
ref?: unknown;
maxBytes?: unknown;
since?: unknown;
limit?: unknown;
maxTextChars?: unknown;
includeStack?: unknown;
}
function appendNumber(parts: string[], flag: string, value: unknown): void {
if (typeof value === "number" && Number.isFinite(value)) parts.push(flag, String(value));
}
function commandOf(argsRaw: string, callId: string): { command: string; title: string } {
function commandOf(argsRaw: string, callId: string): string {
try {
const args = JSON.parse(argsRaw) as InspectArgs;
const action = typeof args.action === "string" ? args.action : "inspect";
const command = INSPECT_COMMANDS[action] ?? action;
const parts = ["bsk", command, "--session"];
const args = JSON.parse(argsRaw) as { session?: unknown; ref?: unknown };
const parts = ["bsk", "screenshot", "--session"];
parts.push(
typeof args.session === "string" && args.session !== "" ? args.session : "(current)",
);
if (action === "observe" || action === "snapshot") {
appendNumber(parts, "--max-depth", args.maxDepth);
appendNumber(parts, "--max-tokens", args.maxTokens);
}
if (action === "html" || action === "console" || action === "network") {
appendNumber(parts, "--tab-id", args.tabId);
}
if ((action === "html" || action === "screenshot") && typeof args.ref === "string") {
if (args.ref !== "") parts.push("--ref", args.ref);
}
if (action === "html") appendNumber(parts, "--max-bytes", args.maxBytes);
if (action === "console" || action === "network") {
appendNumber(parts, "--since", args.since);
appendNumber(parts, "--limit", args.limit);
appendNumber(parts, "--max-text-chars", args.maxTextChars);
}
if (action === "console" && args.includeStack === true) parts.push("--include-stack");
return { command: parts.join(" "), title: titleOf(action) };
if (typeof args.ref === "string" && args.ref !== "") parts.push("--ref", args.ref);
return parts.join(" ");
} catch {
// Streaming can expose a truncated JSON prefix; it is still the best label.
return {
command:
argsRaw === "" ? `browser_inspect (${callId})` : `browser_inspect ${firstLine(argsRaw)}`,
title: "Inspect",
};
return argsRaw === "" ? `bsk screenshot (${callId})` : `bsk screenshot ${firstLine(argsRaw)}`;
}
}
@@ -137,9 +85,9 @@ function commandOf(argsRaw: string, callId: string): { command: string; title: s
export function viewModelOf(block: ToolCallViewProps["block"]): ViewModel {
const settled = "kind" in block;
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? "";
const { command, title } = commandOf(argsRaw, block.callId);
const command = commandOf(argsRaw, block.callId);
if (!settled) {
return { state: "running", command, output: null, image: null, summary: command, title };
return { state: "running", command, output: null, image: null, summary: command };
}
const textBlock = block.content.find((item) => item.type === "text");
const imageBlock = block.content.find((item) => item.type === "image");
@@ -148,7 +96,7 @@ export function viewModelOf(block: ToolCallViewProps["block"]): ViewModel {
imageBlock !== undefined && imageBlock.type === "image" ? imageBlock.attachment : null;
const state: ViewState = block.isError ? "error" : "ok";
const summary = output !== null ? firstLine(output) : command;
return { state, command, output, image, summary, title };
return { state, command, output, image, summary };
}
function dotState(state: ViewState): StateDotState {
@@ -163,24 +111,19 @@ function dotState(state: ViewState): StateDotState {
}
/**
* Render one browser_inspect call: a disclosure row over a terminal block,
* plus the screenshot image when that action returns an image attachment.
* Render one browser_screenshot call: a disclosure row over a terminal block
* (command + output) plus the screenshot image when the result carries one.
*/
export function BrowserInspectToolView({
block,
cwd,
inspect,
loadImage,
}: BrowserInspectToolViewProps) {
export function ScreenshotToolView({ block, cwd, inspect, loadImage }: ScreenshotToolViewProps) {
const model = viewModelOf(block);
const [expanded, setExpanded] = useState(false);
const expandable = model.state === "running" || model.output !== null || model.image !== null;
const open = expanded && expandable;
return (
<div className={css.card} data-tool="browser_inspect" data-state={model.state}>
<div className={css.card} data-tool="browser_screenshot" data-state={model.state}>
<DisclosureRow
icon={<StateDot state={dotState(model.state)} />}
title={model.title}
title="Screenshot"
open={open}
expandable={expandable}
onToggle={() => setExpanded((value) => !value)}
@@ -1,6 +1,6 @@
/**
* dsh-plugin-browserskill browser half: the `browser_inspect` keyed toolview
* plus the observation overlay (live thumbnails + interrupt). The
* dsh-plugin-browserskill browser half: the `browser_screenshot` keyed
* toolview plus the observation overlay (live thumbnails + interrupt). The
* default carrier is a floating card on the `shell.overlay` seat; when the
* dsh-better-sidebar plugin provides its `betterSidebar` service, the view
* moves into a sidebar tab instead (see observation-sidebar.tsx).
@@ -16,10 +16,10 @@ import { createElement } from "react";
// <style> tags; selectors stay unhashed so `cn(..., "bsk-obs")` roots match).
import "./bsk-tokens.nomodule.css";
import "./bsk-ui.nomodule.css";
import { BrowserInspectToolView } from "./BrowserInspectToolView";
import { ObservationOverlay } from "./ObservationOverlay";
import { type BetterSidebarLike, registerObservationSidebar } from "./observation-sidebar";
import { type EventSourceLike, ObservationClientStore } from "./observation-store";
import { ScreenshotToolView } from "./ScreenshotToolView";
/** Required services: slots, session-scoped attachment reads, and the overlay seat. */
export const inject = ["slots", "sessions"];
@@ -65,9 +65,9 @@ export function apply(ctx: ClientContext): void {
const sessions = ctx.get("sessions") as unknown as ISessions;
ctx.slots.inject("tool.call.toolview", () =>
ctx.slots.register(
{ name: "tool.call.toolview", key: "browser_inspect" },
{ name: "tool.call.toolview", key: "browser_screenshot" },
(props: ToolCallViewProps) =>
createElement(BrowserInspectToolView, {
createElement(ScreenshotToolView, {
...props,
loadImage: (attachment: ImageAttachmentRef) =>
loadSessionImage(sessions, props.sessionId, attachment),
+9 -65
View File
@@ -1,11 +1,10 @@
/**
* Screenshot delivery. The canonical tool value stays plain JSON (a screenshot
* file path plus pixel metadata); when the host mounts a durable attachment
* store AND the calling route declares image input, the capture bytes are
* additionally committed through `ctx.attachments` so the render step can
* attach the image itself to the tool result. Any uncertainty (no store,
* unknown route, text-only model, unrecognized bytes) falls back to the
* path-only form.
* Screenshot delivery. The canonical tool value stays plain JSON (a PNG file
* path plus pixel metadata); when the host mounts a durable attachment store
* AND the calling route declares image input, the PNG bytes are additionally
* committed through `ctx.attachments` so the render step can attach the image
* itself to the tool result. Any uncertainty (no store, unknown route,
* text-only model) falls back to the path-only form.
*/
import type { Context } from "@deepseek-ai/cordis";
@@ -34,54 +33,6 @@ interface LlmLike {
): Promise<{ inputModalities?: readonly string[] }>;
}
/**
* Sniff the image media type from magic bytes. The capture path can hand back
* JPEG (e.g. `chrome.tabs.captureVisibleTab` on some Chromium/Edge builds
* returning JPEG regardless of the requested format), so the declared type can
* never be trusted from the file extension alone.
* @param data - first bytes of the capture.
* @returns the detected media type, or undefined when unrecognized.
*/
function sniffImageMediaType(data: Uint8Array): string | undefined {
if (data.length >= 4) {
// PNG signature: 89 50 4E 47 (0D 0A 1A 0A)
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
return "image/png";
}
// JPEG SOI: FF D8 FF
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
return "image/jpeg";
}
// WebP: "RIFF" (52 49 46 46) + "WEBP" at offset 8
if (
data[0] === 0x52 &&
data[1] === 0x49 &&
data[2] === 0x46 &&
data[3] === 0x46 &&
data.length >= 12 &&
data[8] === 0x57 &&
data[9] === 0x45 &&
data[10] === 0x42 &&
data[11] === 0x50
) {
return "image/webp";
}
// GIF: "GIF87a" or "GIF89a"
if (
data[0] === 0x47 &&
data[1] === 0x49 &&
data[2] === 0x46 &&
data[3] === 0x38 &&
data.length >= 6 &&
(data[4] === 0x37 || data[4] === 0x39) &&
data[5] === 0x61
) {
return "image/gif";
}
}
return undefined;
}
/**
* Commit screenshot bytes to the host attachment store when the composition
* supports durable images on the current model route.
@@ -95,14 +46,7 @@ export async function trySaveScreenshot(
): Promise<ImageAttachmentRef | undefined> {
const attachments = ctx.get("attachments") as AttachmentLike | undefined;
if (attachments === undefined) return undefined;
// Declare the real media type from the bytes, never a hard-coded "image/png".
// Declaring the wrong type trips the store's IMAGE_TYPE_MISMATCH check and
// silently degrades to path-only; sniffing first lets JPEG captures (returned
// by some Chromium builds for `captureVisibleTab({ format: "png" })`) still
// inline correctly.
const mediaType = sniffImageMediaType(data);
if (mediaType === undefined) return undefined;
if (!attachments.imageLimits.mediaTypes.includes(mediaType)) return undefined;
if (!attachments.imageLimits.mediaTypes.includes("image/png")) return undefined;
if (
data.byteLength >
Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes)
@@ -111,9 +55,9 @@ export async function trySaveScreenshot(
}
if (!(await isImageCapableRoute(ctx, exec))) return undefined;
try {
return await attachments.saveImage({ data, mediaType, name });
return await attachments.saveImage({ data, mediaType: "image/png", name });
} catch {
// A store hiccup must not fail the tool call; the path-only form still works.
// A store hiccup must not fail the tool call; the PNG path still works.
return undefined;
}
}
+15 -17
View File
@@ -13,17 +13,16 @@
import type { Context } from "@deepseek-ai/cordis";
import Schema from "@deepseek-ai/schemastery";
import { armArchiveCleanup } from "./archive-cleanup";
import { registerBrowserTools } from "./browser-tools";
import { armLazyTools } from "./lazy-tools";
import { ObservationService } from "./observation";
import { registerObservationRoutes } from "./observation-http";
import { KeyedExecutor } from "./queue";
import { type BskRunner, createBskRunner } from "./runner";
import { SessionRegistry } from "./sessions";
import { armAgentScopedBskSkill, registerBskSkill } from "./skill";
import type { PluginConfig } from "./tools";
import { registerBskSkill } from "./skill";
import { type PluginConfig, registerTools } from "./tools";
export const name = "@wxg-prc-cpg/browser-skill-dsh-plugin";
export const name = "dsh-plugin-browserskill";
export const inject = ["tools"];
/** Runtime configuration schema (validated and defaulted by Cordis). */
@@ -96,14 +95,11 @@ export function apply(
// ONLY model-visible advertisement — the tool suite reveals itself on a
// successful skill invocation (or a session whose history already has one).
const unregisterSkill = registerBskSkill(ctx);
// A same-name CLI skill left in ~/.agents is discovered in the nearer preset
// layer and shadows the global registration above. Re-register through every
// exact agent context at startup so DSH always loads the browser_* protocol
// instructions; the shared CLI skill remains untouched for other agents.
const disarmAgentSkill = armAgentScopedBskSkill(ctx);
const registerSuite = () =>
registerBrowserTools({ ctx, runner, registry, config: resolved, observation, queue });
const removeSuite = resolved.lazyTools ? armLazyTools(ctx, registerSuite) : registerSuite();
const removeSuite = resolved.lazyTools
? armLazyTools(ctx, () =>
registerTools({ ctx, runner, registry, config: resolved, observation, queue }),
)
: registerTools({ ctx, runner, registry, config: resolved, observation, queue });
// Route registration rides ctx.inject: the webServer service may be provided
// AFTER this plugin loads, and in headless compositions it never appears (the
// callback simply never runs, leaving the rest of the plugin unaffected).
@@ -132,28 +128,29 @@ export function apply(
);
// Unload cleanup: kill in-flight children, then stop every session this
// plugin OWNS (created via browser_session action=start). Referenced or unknown
// plugin OWNS (created via browser_session_start). Referenced or unknown
// sessions belonging to other programs on the shared daemon are never
// touched; per-stop failures (already stopped externally, daemon restart)
// are swallowed so one stale handle cannot abort the rest.
ctx.effect(() => {
return () => {
removeSuite();
disarmAgentSkill();
unregisterSkill();
removeRoutes();
disarmArchiveCleanup();
observation.dispose();
runner.killAll();
const stops = registry
.ownedIds()
.map((sessionId) => observation.stopSession(sessionId).catch(() => false));
return Promise.all(stops).then(() => observation.dispose());
.map((sessionId) =>
runner.run(["session", "stop", sessionId], { timeoutMs: 15_000 }).catch(() => {}),
);
return Promise.all(stops).then(() => {});
};
});
}
export { armArchiveCleanup, ownerSessionIds } from "./archive-cleanup";
export { registerBrowserTools } from "./browser-tools";
export type { ObservationEvent, ObservationOptions, SessionObservation } from "./observation";
export { ObservationService } from "./observation";
export { registerObservationRoutes } from "./observation-http";
@@ -162,3 +159,4 @@ export type { BskRunner, BskRunResult, SpawnImpl } from "./runner";
export { BskError, createBskRunner } from "./runner";
export { SessionRegistry } from "./sessions";
export type { PluginConfig, ToolDeps } from "./tools";
export { registerTools } from "./tools";
@@ -1,6 +1,6 @@
/**
* Lazy tool-schema injection ("progressive disclosure", final stage): with
* `lazyTools` on (the default), the six browser tool schemas stay OUT of the
* `lazyTools` on (the default), the 28 browser_* tool schemas stay OUT of the
* system prompt until the `browser-skill` skill has actually been invoked —
* the skill catalog entry is the only advertisement. One successful
* invocation (model tool call, or a user's `/browser-skill` gesture) reveals
@@ -96,7 +96,7 @@ export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[]
/**
* Arm the lazy reveal. Returns a disposer tearing down listeners and — when
* the reveal already happened — the tool suite itself.
* @param registerSuite - registers the six browser tools and returns their disposer.
* @param registerSuite - registers the 28 browser_* tools, returns their disposer.
*/
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
let suiteDisposer: (() => void) | undefined;
@@ -18,7 +18,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Context } from "@deepseek-ai/cordis";
import type { KeyedExecutor } from "./queue";
import { BskError, type BskRunner, parseBskJson, runWithSessionBusyRetry } from "./runner";
import type { BskRunner } from "./runner";
import type { SessionRegistry } from "./sessions";
/** One owned session's live observation record (wire-stable shape). */
@@ -90,10 +90,6 @@ export class ObservationService {
private readonly listeners = new Set<(event: ObservationEvent) => void>();
private readonly captureTimers = new Map<string, unknown>();
private readonly captureInFlight = new Set<string>();
/** Captures intentionally cancelled to make way for foreground work. */
private readonly capturePreempted = new Set<string>();
/** Number of queued/running model-facing calls that currently outrank thumbnails. */
private readonly foregroundDepth = new Map<string, number>();
private readonly captureFailures = new Map<string, number>();
private readonly lastActivity = new Map<string, number>();
/** Ephemeral frame id → PNG bytes. Only the live ring members are present. */
@@ -161,7 +157,7 @@ export class ObservationService {
this.emit({ type: "upsert", session: { ...entry } });
}
/** Register a fresh owned session (called from browser_session action=start). */
/** Register a fresh owned session (called from browser_session_start). */
addSession(sessionId: string, url?: string): void {
if (!this.deps.options.enabled || this.disposed) return;
const dshSessionIds = this.deps.registry.dshOwnersOf(sessionId);
@@ -176,11 +172,9 @@ export class ObservationService {
this.scheduleCapture(sessionId, 0);
}
/** Drop a session (called from browser_session action=stop). */
/** Drop a session (called from browser_session_stop). */
removeSession(sessionId: string): void {
this.cancelCapture(sessionId);
this.foregroundDepth.delete(sessionId);
this.capturePreempted.delete(sessionId);
this.captureFailures.delete(sessionId);
this.lastActivity.delete(sessionId);
this.dropSessionFrames(sessionId);
@@ -192,37 +186,6 @@ export class ObservationService {
}
}
/**
* Give model-facing work priority over the best-effort thumbnail lane.
* The first lease cancels a pending timer and gracefully interrupts an
* active bsk screenshot; the last release schedules one fresh frame.
*/
acquireForeground(sessionId: string): () => void {
if (!this.deps.options.enabled || this.disposed || !this.observations.has(sessionId)) {
return () => {};
}
const depth = this.foregroundDepth.get(sessionId) ?? 0;
this.foregroundDepth.set(sessionId, depth + 1);
if (depth === 0) {
this.cancelCapture(sessionId);
if (this.deps.runner.killFor(`observation:${sessionId}`) > 0) {
this.capturePreempted.add(sessionId);
}
}
let released = false;
return () => {
if (released) return;
released = true;
const remaining = (this.foregroundDepth.get(sessionId) ?? 1) - 1;
if (remaining > 0) {
this.foregroundDepth.set(sessionId, remaining);
return;
}
this.foregroundDepth.delete(sessionId);
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId, 0);
};
}
/** Mark an action starting on a session (tool entry instrumentation). */
beginAction(sessionId: string, action: string): void {
if (!this.deps.options.enabled || this.disposed) return;
@@ -250,7 +213,7 @@ export class ObservationService {
if (error !== undefined) next.lastError = error;
else delete next.lastError;
this.put(next);
if (!this.foregroundDepth.has(sessionId)) this.scheduleCapture(sessionId, 0);
this.scheduleCapture(sessionId, 0);
}
/** Record the settled page URL (navigate success / start with url). */
@@ -276,51 +239,32 @@ export class ObservationService {
/**
* Stop one owned session and close its Agent Window (the overlay's stop
* button — same end state as `browser_session` action=stop). Never waits behind a
* button — same end state as `browser_session_stop`). Never waits behind a
* hung in-flight command: tool children are killed first so the session's
* keyed queue drains immediately, and no further captures queue up. A
* session the daemon already forgot stops idempotently — the goal state
* (entry gone) is identical.
* @returns false only for a foreign session; bsk failures reject so callers
* can preserve the structured error instead of silently leaving a ghost.
* session the daemon already forgot (dead strip entries) stops
* idempotently — the goal state (entry gone) is identical.
* @returns whether the session ended up stopped/removed.
*/
async stopSession(sessionId: string, signal?: AbortSignal): Promise<boolean> {
async stopSession(sessionId: string): Promise<boolean> {
if (!this.deps.registry.isOwned(sessionId)) return false;
const releaseForeground = this.acquireForeground(sessionId);
let actionError: string | undefined;
this.beginAction(sessionId, "stopping");
try {
this.deps.runner.killFor(sessionId);
const result = await this.deps.queue.run(
sessionId,
() =>
runWithSessionBusyRetry(
() =>
this.deps.runner.run(["session", "stop", sessionId], {
signal,
timeoutMs: 30_000,
tag: sessionId,
}),
signal,
),
signal,
);
if (result.aborted) throw abortError();
this.cancelCapture(sessionId);
this.deps.runner.killFor(sessionId);
const result = await this.deps.queue.run(sessionId, () =>
this.deps.runner.run(["session", "stop", sessionId], { timeoutMs: 30_000 }),
);
if (result.code !== 0) {
let code: string | undefined;
try {
parseBskJson(result, "session stop");
} catch (error) {
if (!isSessionNotFoundError(error)) throw error;
code = (JSON.parse(result.stdout) as { code?: string }).code;
} catch {
code = undefined;
}
this.deps.registry.remove(sessionId);
this.removeSession(sessionId);
return true;
} catch (error) {
actionError = error instanceof Error ? error.message.split("\n")[0] : String(error);
throw error;
} finally {
this.endAction(sessionId, actionError);
releaseForeground();
if (code !== "session_not_found") return false;
}
this.deps.registry.remove(sessionId);
this.removeSession(sessionId);
return true;
}
/**
@@ -343,8 +287,6 @@ export class ObservationService {
for (const sessionId of [...this.captureTimers.keys()]) this.cancelCapture(sessionId);
this.captureTimers.clear();
this.captureInFlight.clear();
this.capturePreempted.clear();
this.foregroundDepth.clear();
this.captureFailures.clear();
this.lastActivity.clear();
this.frames.clear();
@@ -371,7 +313,6 @@ export class ObservationService {
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
private scheduleCapture(sessionId: string, delayMs?: number): void {
if (!this.deps.options.enabled || this.disposed) return;
if (this.foregroundDepth.has(sessionId)) return;
const entry = this.observations.get(sessionId);
if (entry === undefined || entry.dead === true) return;
this.cancelCapture(sessionId);
@@ -408,15 +349,12 @@ export class ObservationService {
const outPath = join(tmpdir(), `bsk-obs-${this.scratchNamespace}-${sessionKey}.png`);
let writtenPath = outPath;
try {
const result = await this.deps.queue.run(sessionId, () => {
// A foreground lease may have arrived while this capture was queued.
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined);
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
const result = await this.deps.queue.run(sessionId, () =>
this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
timeoutMs: 15_000,
tag: `observation:${sessionId}`,
});
});
if (result === undefined) return;
}),
);
if (result.code !== 0) {
let code: string | undefined;
try {
@@ -424,12 +362,8 @@ export class ObservationService {
} catch {
code = undefined;
}
if (isSessionNotFoundCode(code)) {
// Observation entries are owned sessions. If the daemon has already
// forgotten one (external close / restart), reconcile both stores so
// the PiP cannot retain a ghost strip or keep retrying screenshots.
this.deps.registry.remove(sessionId);
this.removeSession(sessionId);
if (code === "session_not_found") {
this.markDead(sessionId);
return;
}
throw new Error(`screenshot exited ${result.code}`);
@@ -444,17 +378,12 @@ export class ObservationService {
this.setAvailable(true);
this.publishFrame(sessionId, entry, data);
} catch {
// Foreground preemption is healthy scheduling, not browser/daemon
// unavailability; do not let normal tool traffic trip capture backoff.
if (!this.capturePreempted.delete(sessionId)) {
// Silent by design: keep the previous frame, count toward backoff.
this.captureFailures.set(sessionId, (this.captureFailures.get(sessionId) ?? 0) + 1);
this.globalFailures += 1;
if (this.globalFailures >= FAILURE_BACKOFF_THRESHOLD) this.setAvailable(false);
}
// Silent by design: keep the previous frame, count toward backoff.
this.captureFailures.set(sessionId, (this.captureFailures.get(sessionId) ?? 0) + 1);
this.globalFailures += 1;
if (this.globalFailures >= FAILURE_BACKOFF_THRESHOLD) this.setAvailable(false);
} finally {
await unlink(writtenPath).catch(() => {});
this.capturePreempted.delete(sessionId);
this.captureInFlight.delete(sessionId);
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId);
}
@@ -485,20 +414,14 @@ export class ObservationService {
this.hashes.delete(sessionId);
this.seqs.delete(sessionId);
}
}
function isSessionNotFoundCode(code: string | undefined): boolean {
return code === "not_found" || code === "session_not_found";
}
function isSessionNotFoundError(error: unknown): boolean {
return error instanceof BskError && isSessionNotFoundCode(error.code);
}
function abortError(): Error {
const error = new Error("tool call aborted");
error.name = "AbortError";
return error;
/** Mark a session dead: grey it out, stop asking for frames, keep the entry for the strip. */
private markDead(sessionId: string): void {
const entry = this.observations.get(sessionId);
if (entry === undefined || entry.dead === true) return;
this.cancelCapture(sessionId);
this.put({ ...entry, dead: true, action: "idle" });
}
}
/** Map a bsk command label onto its observation action verb. */
@@ -1,7 +1,7 @@
import type { ToolDefinition, ToolResult, ToolRunContext } from "@deepseek-ai/dsh-tools";
import type { ToolDeps } from "./tools";
export type ToolRegistrar = (definition: ToolDefinition) => void;
export type ToolRegistrar = (definition: ToolDefinition) => () => void;
type TerminalPresentation = { card: "terminal"; output: string; exitCode: number } | undefined;
@@ -23,10 +23,10 @@ export function registerPhaseOneInteractionTools(
register(
defineTool({
name: "interact.hover",
name: "browser_hover",
description:
"Move the mouse over a snapshot ref or CSS selector to reveal hover-triggered UI. " +
"Run browser_inspect action=observe or snapshot afterwards to discover newly visible refs.",
"Run browser_observe or browser_snapshot again afterwards to discover newly visible refs.",
parameters: {
target: {
type: "string",
@@ -68,7 +68,7 @@ export function registerPhaseOneInteractionTools(
requireNonEmpty(args.target, "target");
requirePositive(args.timeoutMs, "timeoutMs");
requirePositive(args.settleMs, "settleMs");
const sessionId = registry.resolve(args.session, "browser_interact(action=hover)");
const sessionId = registry.resolve(args.session, "browser_hover");
const cmdArgs = ["hover", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
if (args.modifiers !== undefined && args.modifiers.length > 0) {
@@ -102,7 +102,7 @@ export function registerPhaseOneInteractionTools(
register(
defineTool({
name: "interact.select",
name: "browser_select",
description:
"Set a select element's option values. Pass one value for a normal select or multiple " +
"values for a multi-select; values replace the current selection.",
@@ -155,7 +155,7 @@ export function registerPhaseOneInteractionTools(
requireNonEmpty(args.target, "target");
requirePositive(args.timeoutMs, "timeoutMs");
if (args.values.length === 0) throw new Error("values must contain at least one option");
const sessionId = registry.resolve(args.session, "browser_interact(action=select)");
const sessionId = registry.resolve(args.session, "browser_select");
const cmdArgs = ["select", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
for (const value of args.values) cmdArgs.push("--value", value);
@@ -51,7 +51,7 @@ export function registerPhaseOneNavigationTools(
const { registry } = deps;
const registerHistory = (direction: "back" | "forward") => {
const toolName = `page.${direction}` as const;
const toolName = `browser_navigate_${direction}` as const;
const command = `navigate-${direction}`;
register(
defineTool({
@@ -106,7 +106,7 @@ export function registerPhaseOneNavigationTools(
register(
defineTool({
name: "page.reload",
name: "browser_reload",
description:
"Reload the active Agent Window tab and wait for a lifecycle phase. Set hard=true to " +
"bypass the HTTP cache.",
@@ -132,7 +132,7 @@ export function registerPhaseOneNavigationTools(
},
async execute(args, exec) {
requirePositive(args.timeoutMs, "timeoutMs");
const sessionId = registry.resolve(args.session, "browser_page(action=reload)");
const sessionId = registry.resolve(args.session, "browser_reload");
const cmdArgs = ["reload", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
appendWaitOptions(cmdArgs, args.waitUntil, args.timeoutMs);
@@ -163,7 +163,7 @@ export function registerPhaseOneNavigationTools(
register(
defineTool({
name: "page.wait",
name: "browser_wait_for_navigation",
description:
"Wait for a page lifecycle event on the active Agent Window tab. Use after an action " +
"that may navigate when the action result itself does not wait for navigation.",
@@ -195,7 +195,7 @@ export function registerPhaseOneNavigationTools(
},
async execute(args, exec) {
requirePositive(args.timeoutMs, "timeoutMs");
const sessionId = registry.resolve(args.session, "browser_page(action=wait)");
const sessionId = registry.resolve(args.session, "browser_wait_for_navigation");
const timeoutMs = args.timeoutMs ?? 30_000;
const cmdArgs = ["wait-for-navigation", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
@@ -143,7 +143,7 @@ export function registerPhaseOneSupportTools(
register(
defineTool({
name: "assist.request-help",
name: "browser_request_help",
description:
"Pause browser automation and ask the user to complete an in-page step such as login, " +
"captcha, OTP, or confirmation. After continued/completed, observe again before using refs.",
@@ -233,7 +233,7 @@ export function registerPhaseOneSupportTools(
if (criteria?.stableForMs !== undefined && criteria.stableForMs < 0) {
throw new Error("completionCriteria.stableForMs must be zero or greater");
}
const sessionId = registry.resolve(args.session, "browser_assist(action=request-help)");
const sessionId = registry.resolve(args.session, "browser_request_help");
const timeoutMs = args.timeoutMs ?? 300_000;
const cmdArgs = [
"request-help",
@@ -285,10 +285,10 @@ export function registerPhaseOneSupportTools(
register(
defineTool({
name: "inspect.html",
name: "browser_get_html",
description:
"Read raw DOM HTML from the active Agent Window tab, optionally scoped to a fresh " +
"snapshot ref. Use only when browser_inspect observe/snapshot cannot answer the question.",
"snapshot ref. Use only when browser_observe/browser_snapshot cannot answer the question.",
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
@@ -326,7 +326,7 @@ export function registerPhaseOneSupportTools(
if (!isSnapshotRef(args.ref)) throw new Error("ref must be a snapshot ref such as @e3");
}
requirePositive(args.maxBytes, "maxBytes");
const sessionId = registry.resolve(args.session, "browser_inspect(action=html)");
const sessionId = registry.resolve(args.session, "browser_get_html");
const cmdArgs = ["get-html", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
if (args.ref !== undefined) cmdArgs.push("--ref", args.ref);
@@ -361,7 +361,7 @@ export function registerPhaseOneSupportTools(
register(
defineTool({
name: "inspect.console",
name: "browser_console",
description:
"Read buffered console messages, browser log entries, and JavaScript exceptions from a tab. " +
"This is read-only and does not evaluate JavaScript.",
@@ -448,7 +448,7 @@ export function registerPhaseOneSupportTools(
isConcurrencySafe: () => true,
async execute(args, exec) {
validateDebugArgs(args);
const sessionId = registry.resolve(args.session, "browser_inspect(action=console)");
const sessionId = registry.resolve(args.session, "browser_console");
const cmdArgs = ["console", "--session", sessionId];
appendDebugOptions(cmdArgs, args);
if (args.includeStack === true) cmdArgs.push("--include-stack");
@@ -493,7 +493,7 @@ export function registerPhaseOneSupportTools(
register(
defineTool({
name: "inspect.network",
name: "browser_network",
description:
"Read buffered network responses and failures from a tab. Returns metadata only; request " +
"and response headers and bodies are not captured.",
@@ -552,7 +552,7 @@ export function registerPhaseOneSupportTools(
isConcurrencySafe: () => true,
async execute(args, exec) {
validateDebugArgs(args);
const sessionId = registry.resolve(args.session, "browser_inspect(action=network)");
const sessionId = registry.resolve(args.session, "browser_network");
const cmdArgs = ["network", "--session", sessionId];
appendDebugOptions(cmdArgs, args);
const reply = (await runtime.run(exec, cmdArgs, "network", sessionId)) as {
@@ -592,7 +592,7 @@ export function registerPhaseOneSupportTools(
register(
defineTool({
name: "assist.resize",
name: "browser_window_resize",
description: "Resize the owned session's Agent Window using outer CSS-pixel dimensions.",
parameters: {
width: {
@@ -629,7 +629,7 @@ export function registerPhaseOneSupportTools(
if (args.width < 100 || args.width > 7680 || args.height < 100 || args.height > 7680) {
throw new Error("width and height must each be in the range 100..=7680");
}
const sessionId = registry.resolve(args.session, "browser_assist(action=resize)");
const sessionId = registry.resolve(args.session, "browser_window_resize");
const reply = (await runtime.run(
exec,
[
@@ -7,7 +7,7 @@ import type { ToolDeps } from "./tools";
const TAB_ID_REQUIRED = {
type: "integer",
required: true,
description: "Chrome tab id returned by browser_tabs list or create.",
description: "Chrome tab id returned by browser_tab_list or browser_tab_create.",
} as const;
interface RawTabInfo {
@@ -29,7 +29,7 @@ export function registerPhaseOneTabTools(
register(
defineTool({
name: "tabs.list",
name: "browser_tab_list",
description:
"List tabs visible to an owned browser session. Other sessions' Agent Windows remain " +
"hidden; use scope=user to find a user tab before borrowing it.",
@@ -83,7 +83,7 @@ export function registerPhaseOneTabTools(
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tabs(action=list)");
const sessionId = registry.resolve(args.session, "browser_tab_list");
const cmdArgs = ["tab", "list", "--session", sessionId];
if (args.scope !== undefined) cmdArgs.push("--scope", args.scope);
const reply = (await runtime.run(exec, cmdArgs, "tab list", sessionId)) as {
@@ -118,7 +118,7 @@ export function registerPhaseOneTabTools(
register(
defineTool({
name: "tabs.create",
name: "browser_tab_create",
description:
"Create a tab inside the owned session's Agent Window. The tab is focused by default; " +
"set active=false to open it in the background.",
@@ -148,7 +148,7 @@ export function registerPhaseOneTabTools(
},
async execute(args, exec) {
if (args.url !== undefined) requireNonEmpty(args.url, "url");
const sessionId = registry.resolve(args.session, "browser_tabs(action=create)");
const sessionId = registry.resolve(args.session, "browser_tab_create");
const cmdArgs = ["tab", "create", "--session", sessionId];
if (args.url !== undefined) cmdArgs.push("--url", args.url);
if (args.active === false) cmdArgs.push("--no-active");
@@ -185,7 +185,7 @@ export function registerPhaseOneTabTools(
register(
defineTool({
name: "tabs.close",
name: "browser_tab_close",
description: "Close a tab in the owned session's Agent Window.",
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
output: {
@@ -202,7 +202,7 @@ export function registerPhaseOneTabTools(
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tabs(action=close)");
const sessionId = registry.resolve(args.session, "browser_tab_close");
const reply = (await runtime.run(
exec,
["tab", "close", String(args.tabId), "--session", sessionId],
@@ -228,7 +228,7 @@ export function registerPhaseOneTabTools(
register(
defineTool({
name: "tabs.select",
name: "browser_tab_select",
description: "Focus a tab in the owned session's Agent Window.",
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
output: {
@@ -249,7 +249,7 @@ export function registerPhaseOneTabTools(
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tabs(action=select)");
const sessionId = registry.resolve(args.session, "browser_tab_select");
const reply = (await runtime.run(
exec,
["tab", "select", String(args.tabId), "--session", sessionId],
@@ -275,10 +275,10 @@ export function registerPhaseOneTabTools(
register(
defineTool({
name: "tabs.borrow",
name: "browser_tab_borrow",
description:
"Move a user-window tab into the owned session's Agent Window for controlled interaction. " +
"Return it with browser_tabs action=return; stopping the session also auto-returns it.",
"Return it with browser_tab_return when finished; stopping the session also auto-returns it.",
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
output: {
schema: {
@@ -302,7 +302,7 @@ export function registerPhaseOneTabTools(
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tabs(action=borrow)");
const sessionId = registry.resolve(args.session, "browser_tab_borrow");
const reply = (await runtime.run(
exec,
["tab", "borrow", String(args.tabId), "--session", sessionId],
@@ -339,7 +339,7 @@ export function registerPhaseOneTabTools(
register(
defineTool({
name: "tabs.return",
name: "browser_tab_return",
description: "Return a borrowed tab to its original user window and tab-strip position.",
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
output: {
@@ -365,7 +365,7 @@ export function registerPhaseOneTabTools(
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tabs(action=return)");
const sessionId = registry.resolve(args.session, "browser_tab_return");
const reply = (await runtime.run(
exec,
["tab", "return", String(args.tabId), "--session", sessionId],
+3 -60
View File
@@ -64,17 +64,14 @@ export interface BskRunner {
killFor(tag: string): number;
}
// Business RPCs translate SIGINT into the daemon's cancel(rpc_id) protocol.
// Give that bounded reconciliation path time to settle before the hard kill.
const KILL_GRACE_MS = 3000;
const SESSION_BUSY_RETRY_DELAY_MS = 100;
const KILL_GRACE_MS = 2000;
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
const live = new Map<ChildProcess, string | undefined>();
function killChild(child: ChildProcess): void {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGINT");
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, KILL_GRACE_MS);
@@ -166,60 +163,6 @@ export function isCommandNotFound(error: unknown): boolean {
);
}
/** True only for the daemon's transient per-session reconciliation window. */
export function isSessionBusyResult(result: BskRunResult): boolean {
if (result.code === 0) return false;
try {
const body = JSON.parse(result.stdout) as BskErrorBody;
const reason =
typeof body.data === "object" && body.data !== null && "reason" in body.data
? (body.data as { reason?: unknown }).reason
: undefined;
return reason === "session_busy";
} catch {
return false;
}
}
/**
* Retry exactly once after the tiny daemon-settlement race that can follow a
* graceful SIGINT cancellation. Other errors and persistent busy states stay
* visible to callers.
*/
export async function runWithSessionBusyRetry(
run: () => Promise<BskRunResult>,
signal?: AbortSignal,
): Promise<BskRunResult> {
const first = await run();
if (!isSessionBusyResult(first)) return first;
await abortableDelay(SESSION_BUSY_RETRY_DELAY_MS, signal);
return run();
}
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(abortError());
return new Promise((resolve, reject) => {
const timer = setTimeout(done, ms);
timer.unref();
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(abortError());
};
function done() {
signal?.removeEventListener("abort", onAbort);
resolve();
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function abortError(): Error {
const error = new Error("tool call aborted");
error.name = "AbortError";
return error;
}
/** Install guidance shown when the bsk CLI cannot be spawned. */
export function bskInstallMessage(bskPath: string): string {
return (
@@ -239,7 +182,7 @@ export function parseBskJson(result: BskRunResult, commandLabel: string): unknow
throw new BskError(`bsk ${commandLabel} timed out`, { timedOut: true });
}
const body = result.stdout.trim();
// Killed by our own interrupt (SIGINT from killFor), not by the abort path:
// Killed by our own interrupt (SIGTERM from killFor), not by the abort path:
// say so instead of doubling the generic label into the message.
if (result.code === null && !result.aborted && !result.timedOut) {
throw new BskError(`bsk ${commandLabel} was interrupted (process killed)`);
@@ -7,7 +7,7 @@
*
* Strict ownership boundary (the bsk daemon may be shared with other agents,
* terminals, or dsh instances): the registry ONLY ever holds sessions created
* by this plugin's browser_session start action. Tools cannot see or act on any
* by this plugin's browser_session_start. Tools cannot see or act on any
* other session — an explicit `session` argument naming a foreign id is an
* error, the list tool shows owned sessions only, and stop/unload cleanup
* can never touch a session this plugin did not create.
@@ -48,7 +48,7 @@ export class SessionRegistry {
if (this.sessions.size + this.pendingStarts >= this.maxSessions) {
throw new Error(
`session limit reached (${this.maxSessions} concurrent sessions); ` +
"stop one with browser_session action=stop before starting another",
"stop one with browser_session_stop before starting another",
);
}
this.pendingStarts += 1;
@@ -69,7 +69,7 @@ export class SessionRegistry {
if (!this.sessions.has(session.sessionId) && this.sessions.size >= this.maxSessions) {
throw new Error(
`session limit reached (${this.maxSessions} concurrent sessions); ` +
"stop one with browser_session action=stop before starting another",
"stop one with browser_session_stop before starting another",
);
}
this.sessions.set(session.sessionId, { ...session, owned: true });
@@ -149,7 +149,7 @@ export class SessionRegistry {
private foreignError(sessionId: string, toolName: string): Error {
return new Error(
`${toolName}: session "${sessionId}" does not belong to this plugin — only sessions ` +
"created by browser_session action=start are visible and operable here",
"created by browser_session_start are visible and operable here",
);
}
@@ -168,7 +168,7 @@ export class SessionRegistry {
const current = this.current();
if (current === undefined) {
throw new Error(
`${toolName} needs a session but none is active — use browser_session action=start`,
`${toolName} needs a session but none is active — start one with browser_session_start`,
);
}
this.touch(current);
@@ -184,11 +184,10 @@ export class SessionRegistry {
explicit !== undefined && explicit.trim().length > 0 ? explicit : this.current();
if (candidate === undefined) {
throw new Error(
"browser_session action=stop needs a session but none is active — use action=start first",
"browser_session_stop needs a session but none is active — start one with browser_session_start",
);
}
if (!this.isOwned(candidate))
throw this.foreignError(candidate, "browser_session(action=stop)");
if (!this.isOwned(candidate)) throw this.foreignError(candidate, "browser_session_stop");
return candidate;
}
}
@@ -9,7 +9,6 @@
*/
import type { Context } from "@deepseek-ai/cordis";
import type { Agent } from "@deepseek-ai/dsh-agent";
import {
BSK_SKILL_DESCRIPTION,
BSK_SKILL_MARKDOWN,
@@ -26,11 +25,6 @@ interface SkillsLike {
}): () => void;
}
/** Structural view of the live-agent registry (absent in bare compositions). */
interface AgentsLike {
list(): Agent[];
}
/**
* Publish the browser skill as an embedded runtime skill. Returns the unregistration
* disposer. A composition without the `skills` service (or with dsh-tool-skill
@@ -49,54 +43,3 @@ export function registerBskSkill(ctx: Context): () => void {
source: "bundled",
});
}
/**
* Install the DSH-specific browser skill into every exact agent scope.
*
* DSH merges skill layers nearest-first (`agent -> preset -> global`). A legacy
* CLI skill installed at `~/.agents/skills/browser-skill` is discovered by the
* preset filesystem provider and therefore shadows a plugin-level registration.
* Registering the embedded skill through `agent.ctx` makes the DSH protocol
* contract authoritative for that agent without touching the shared CLI skill.
*
* New agents are handled at `agent/session-start`, the first supported startup
* injection point and still before the first prompt assembly. Existing agents
* are registered immediately so plugin reloads take effect without recreating
* the conversation. Returns a disposer for all plugin-owned registrations.
*/
export function armAgentScopedBskSkill(ctx: Context): () => void {
const registrations = new Map<Agent, () => void>();
let active = true;
const registerForAgent = (agent: Agent): void => {
if (!active || registrations.has(agent)) return;
registrations.set(agent, registerBskSkill(agent.ctx));
};
let stopSessionStart = () => {};
let stopDisposed = () => {};
if (typeof ctx.on === "function") {
stopSessionStart = ctx.on("agent/session-start", ({ agent }) => {
registerForAgent(agent);
});
stopDisposed = ctx.on("agent/disposed", ({ agent }) => {
// Agent-scoped effects have already unwound at this lifecycle edge. Drop
// the retained disposer without invoking it against a disposed context.
registrations.delete(agent);
});
}
const agents = ctx.get("agents") as AgentsLike | undefined | null;
if (agents != null && typeof agents.list === "function") {
for (const agent of agents.list()) registerForAgent(agent);
}
return () => {
if (!active) return;
active = false;
stopDisposed();
stopSessionStart();
for (const unregister of [...registrations.values()].reverse()) unregister();
registrations.clear();
};
}
@@ -3,7 +3,7 @@
export const SESSION_PARAM = {
type: "string",
description:
"bsk session id to act on; must be one created by browser_session with action=start. " +
"bsk session id to act on; must be one created by this plugin's browser_session_start. " +
"Omit to use the current session (the one most recently started or used).",
} as const;
+66 -84
View File
@@ -1,8 +1,9 @@
/**
* Internal browser operation definitions shared by the six model-facing tools.
* These definitions are never registered with `ctx.tools`: they provide the
* action-level validation, execution, rendering, and UI presentation reused by
* the domain tools without exposing one schema per operation to the model.
* Model-facing tool definitions. Each tool maps to one `bsk <cmd> --json`
* invocation: spawn via the runner, parse the JSON envelope, and return a
* canonical value whose `output.render` produces the model-facing text.
* UI cards reuse the terminal style: the pending card is the command line,
* the completed card carries the output.
*/
import { readFile, unlink } from "node:fs/promises";
@@ -21,13 +22,7 @@ import { trySaveScreenshot } from "./image";
import { actionForLabel, type ObservationService } from "./observation";
import { registerPhaseOneTools } from "./phase-one-tools";
import type { KeyedExecutor } from "./queue";
import {
type BskRunner,
bskInstallMessage,
isCommandNotFound,
parseBskJson,
runWithSessionBusyRetry,
} from "./runner";
import { type BskRunner, bskInstallMessage, isCommandNotFound, parseBskJson } from "./runner";
import type { SessionRegistry } from "./sessions";
import { SESSION_PARAM } from "./tool-params";
@@ -84,8 +79,6 @@ async function runBsk(
observeSession?: string,
runnerTimeoutMs?: number,
): Promise<unknown> {
const releaseForeground =
observeSession !== undefined ? deps.observation.acquireForeground(observeSession) : undefined;
// beginAction must fire only when the task actually starts inside the
// per-session queue — marking it while still queued would overwrite the
// in-flight action's label (and flash it idle on a queued abort).
@@ -99,15 +92,11 @@ async function runBsk(
began = true;
deps.observation.beginAction(observeSession, actionForLabel(label));
}
return runWithSessionBusyRetry(
() =>
deps.runner.run(args, {
signal: exec.signal,
timeoutMs: runnerTimeoutMs ?? deps.config.defaultTimeoutMs,
...(observeSession !== undefined ? { tag: observeSession } : {}),
}),
exec.signal,
);
return deps.runner.run(args, {
signal: exec.signal,
timeoutMs: runnerTimeoutMs ?? deps.config.defaultTimeoutMs,
...(observeSession !== undefined ? { tag: observeSession } : {}),
});
};
result =
observeSession !== undefined
@@ -132,7 +121,6 @@ async function runBsk(
if (observeSession !== undefined && began) {
deps.observation.endAction(observeSession, actionError);
}
releaseForeground?.();
}
}
@@ -157,15 +145,22 @@ function abortError(): Error {
return error;
}
type DefinitionRegistrar = (definition: ToolDefinition) => void;
/** Define the private action handlers through an injected collector. */
function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar): void {
const { registry } = deps;
/** Register the full browser tool suite; returns the combined unregister disposer. */
export function registerTools(deps: ToolDeps): () => void {
const { ctx, registry } = deps;
// Track every registration without touching the cordis context itself
// (contexts are fiber-owned; the lazy path registers from a listener fiber).
const disposers: (() => void)[] = [];
const register = (definition: ToolDefinition): (() => void) => {
const dispose = ctx.tools.register(definition);
// Test doubles occasionally return the registry map instead of a disposer.
if (typeof dispose === "function") disposers.push(dispose);
return dispose;
};
register(
defineTool({
name: "session.start",
name: "browser_session_start",
description:
"Start a new browser session: opens an Agent Window in the connected browser and returns " +
"its session id. The new session becomes the current session for subsequent browser_* calls. " +
@@ -272,15 +267,22 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
}
} catch (error) {
// A half-initialized session must not leak: stop it before surfacing.
// Reuse the same capture-preempting, idempotent path as explicit and
// overlay stops; local ownership is dropped even when daemon cleanup
// itself fails, because this start never becomes usable to the model.
try {
await deps.observation.stopSession(reply.session_id);
} catch {
registry.remove(reply.session_id);
deps.observation.removeSession(reply.session_id);
// The stop funnels through the session's queue (an in-flight command
// — e.g. an observation capture — would otherwise make the daemon
// refuse it), with one retry for good measure.
for (let attempt = 0; attempt < 2; attempt++) {
try {
const stopped = await deps.queue.run(reply.session_id, () =>
deps.runner.run(["session", "stop", reply.session_id], { timeoutMs: 30_000 }),
);
if (stopped.code === 0) break;
} catch {
// retry once, then give up quietly (registry/observation are
// cleaned below either way; the daemon reaps orphans).
}
}
registry.remove(reply.session_id);
deps.observation.removeSession(reply.session_id);
throw error;
}
return {
@@ -306,10 +308,10 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
register(
defineTool({
name: "session.stop",
name: "browser_session_stop",
description:
"Stop a browser session and close its Agent Window. Stops the given session, or the " +
"current session when `session` is omitted. Only plugin-created sessions " +
"current session when `session` is omitted. Only sessions created by browser_session_start " +
"can be stopped — sessions owned by other programs sharing the bsk daemon are refused.",
parameters: { session: SESSION_PARAM },
output: {
@@ -324,15 +326,9 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
},
async execute(args, exec) {
const sessionId = registry.resolveForStop(args.session);
try {
const stopped = await deps.observation.stopSession(sessionId, exec.signal);
if (!stopped) throw new Error(`browser session ${sessionId} is not owned by this plugin`);
} catch (error) {
if (isCommandNotFound(error)) {
throw new Error(bskInstallMessage(deps.config.bskPath));
}
throw error;
}
await runBsk(deps, exec, ["session", "stop", sessionId], "session stop", sessionId);
registry.remove(sessionId);
deps.observation.removeSession(sessionId);
return { stopped: sessionId };
},
presentCall: (args) => ({
@@ -346,7 +342,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
register(
defineTool({
name: "session.list",
name: "browser_session_list",
description:
"List the browser sessions created by this plugin. Sessions owned by other programs on the " +
"shared bsk daemon are not visible here. The session marked `current` is the one browser_* " +
@@ -411,7 +407,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
register(
defineTool({
name: "page.navigate",
name: "browser_navigate",
description:
"Navigate the session's active tab to a URL and wait for a page lifecycle phase " +
"(default: load). Returns the final URL after redirects.",
@@ -453,7 +449,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
},
async execute(args, exec) {
if (args.url.trim().length === 0) throw new Error("url must be a non-empty string");
const sessionId = registry.resolve(args.session, "browser_page(action=navigate)");
const sessionId = registry.resolve(args.session, "browser_navigate");
const cmdArgs = ["navigate", "--session", sessionId];
if (args.waitUntil !== undefined) cmdArgs.push("--wait-until", args.waitUntil);
if (args.timeoutMs !== undefined) cmdArgs.push("--timeout", `${args.timeoutMs}ms`);
@@ -484,12 +480,12 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
const registerObservationTool = (kind: "snapshot" | "observe") => {
const isSnapshot = kind === "snapshot";
const name = isSnapshot ? "inspect.snapshot" : "inspect.observe";
const name = isSnapshot ? "browser_snapshot" : "browser_observe";
const description = isSnapshot
? "Capture an indented aria-tree snapshot of the session's active tab. Interactive elements " +
"carry @eN refs for browser_interact actions. Prefer browser_inspect action=observe for a richer semantic view."
"carry @eN refs for browser_click / browser_fill. Prefer browser_observe for a richer semantic view."
: "Produce a semantic VOM observation of the session's active tab (roles, states, perception " +
"probes) with @eN refs for browser_interact actions. Read-only: never submits input.";
"probes) with @eN refs for browser_click / browser_fill. Read-only: never submits input.";
register(
defineTool({
name,
@@ -558,10 +554,10 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
register(
defineTool({
name: "interact.click",
name: "browser_click",
description:
"Click an element in the session's active tab. Target is a snapshot ref (@e3) from the " +
"last browser_inspect observation, or a CSS selector.",
"last browser_snapshot/browser_observe, or a CSS selector.",
parameters: {
target: {
type: "string",
@@ -599,7 +595,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
},
async execute(args, exec) {
if (args.target.trim().length === 0) throw new Error("target must be a non-empty string");
const sessionId = registry.resolve(args.session, "browser_interact(action=click)");
const sessionId = registry.resolve(args.session, "browser_click");
const cmdArgs = ["click", "--session", sessionId];
if (args.button !== undefined) cmdArgs.push("--button", args.button);
if (args.clickCount !== undefined) cmdArgs.push("--click-count", String(args.clickCount));
@@ -622,7 +618,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
register(
defineTool({
name: "interact.fill",
name: "browser_fill",
description:
"Fill an input / textarea / contenteditable element, clearing it first by default. " +
"Target is a snapshot ref (@e3) or a CSS selector.",
@@ -658,7 +654,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
},
async execute(args, exec) {
if (args.target.trim().length === 0) throw new Error("target must be a non-empty string");
const sessionId = registry.resolve(args.session, "browser_interact(action=fill)");
const sessionId = registry.resolve(args.session, "browser_fill");
const cmdArgs = ["fill", "--session", sessionId, "--value", args.value];
if (args.noClear === true) cmdArgs.push("--no-clear");
cmdArgs.push(args.target);
@@ -679,7 +675,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
register(
defineTool({
name: "interact.press",
name: "browser_press",
description:
"Dispatch a keyboard key or combo (Enter, Escape, ArrowDown, Ctrl+A, …) to the session's " +
"active tab, optionally focusing a target element first.",
@@ -718,7 +714,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
},
async execute(args, exec) {
if (args.key.trim().length === 0) throw new Error("key must be a non-empty string");
const sessionId = registry.resolve(args.session, "browser_interact(action=press)");
const sessionId = registry.resolve(args.session, "browser_press");
const cmdArgs = ["press", "--session", sessionId];
if (args.target !== undefined) {
if (args.target.trim().length === 0) throw new Error("target must be a non-empty string");
@@ -749,7 +745,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
register(
defineTool({
name: "inspect.screenshot",
name: "browser_screenshot",
description:
"Capture a PNG screenshot of the session's active tab, or crop to a snapshot ref element. " +
"Returns the image itself when the deployment supports image input, otherwise a file path.",
@@ -776,11 +772,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
additionalProperties: false,
properties: {
attachmentId: { type: "string", required: true },
mediaType: {
type: "string",
required: true,
enum: ["image/png", "image/jpeg", "image/webp", "image/gif"],
},
mediaType: { type: "string", required: true, const: "image/png" },
bytes: { type: "integer", required: true },
width: { type: "integer", required: true },
height: { type: "integer", required: true },
@@ -814,7 +806,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_inspect(action=screenshot)");
const sessionId = registry.resolve(args.session, "browser_screenshot");
const outPath = join(
tmpdir(),
`bsk-screenshot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`,
@@ -850,7 +842,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
? {
image: {
attachmentId: String(ref.attachmentId),
mediaType: ref.mediaType,
mediaType: "image/png" as const,
bytes: ref.bytes,
width: ref.width,
height: ref.height,
@@ -879,7 +871,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
register(
defineTool({
name: "assist.emulate",
name: "browser_emulate",
description:
"Apply (or clear) mobile device emulation on the session's active tab: viewport metrics, " +
"user agent, and touch. Overrides are per-tab and not inherited by new tabs.",
@@ -920,7 +912,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_assist(action=emulate)");
const sessionId = registry.resolve(args.session, "browser_emulate");
if (args.off === true) {
if (args.device !== undefined || args.width !== undefined || args.height !== undefined) {
throw new Error("off is mutually exclusive with device/width/height");
@@ -969,17 +961,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
commandLine: (args) => cmdline(deps, args),
presentTerminalResult,
});
}
/**
* Build the private action handlers without publishing them to the model. The
* six browser tools dispatch through these handlers, preserving validation,
* rendering, cancellation, ownership, UI presentation, and attachments.
*/
export function createBrowserOperationDefinitions(deps: ToolDeps): ToolDefinition[] {
const definitions: ToolDefinition[] = [];
defineBrowserOperations(deps, (definition) => {
definitions.push(definition);
});
return definitions;
return () => {
for (const dispose of disposers.splice(0)) dispose();
};
}
@@ -1,17 +1,17 @@
// @vitest-environment happy-dom
// browser_inspect keyed toolview: view-model derivation from frozen blocks,
// browser_screenshot keyed toolview: view-model derivation from frozen blocks,
// image/path-only/running/error rendering, the registration key, and the
// session-bound attachment loader.
import type { RunningToolCall, ToolResultNode } from "@deepseek-ai/dsh-client-runtime/client";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
BrowserInspectToolView,
type BrowserInspectToolViewProps,
viewModelOf,
} from "../../src/client/BrowserInspectToolView";
import { apply } from "../../src/client/index";
import {
ScreenshotToolView,
type ScreenshotToolViewProps,
viewModelOf,
} from "../../src/client/ScreenshotToolView";
const ATTACHMENT = {
attachmentId: "sha256:abc123",
@@ -25,7 +25,7 @@ const ATTACHMENT = {
function runningBlock(argsRaw: string): RunningToolCall {
return {
callId: "c1",
name: "browser_inspect",
name: "browser_screenshot",
argsRaw,
turn: 1,
step: 1,
@@ -41,7 +41,7 @@ function settledBlock(argsRaw: string, content: unknown[], isError = false): Too
seq: 1,
time: 0,
callId: "c1",
call: { name: "browser_inspect", argsRaw },
call: { name: "browser_screenshot", argsRaw },
callTime: 0,
content,
isError,
@@ -58,14 +58,14 @@ afterEach(cleanup);
describe("viewModelOf", () => {
it("derives the running model from the call frame", () => {
const model = viewModelOf(runningBlock('{"action":"screenshot","session":"s1"}'));
const model = viewModelOf(runningBlock('{"session":"s1"}'));
expect(model.state).toBe("running");
expect(model.command).toBe("bsk screenshot --session s1");
expect(model.image).toBeNull();
});
it("marks settled results carrying an image block", () => {
const block = settledBlock('{"action":"screenshot"}', [
const block = settledBlock("{}", [
{ type: "text", text: IMAGE_TEXT },
{ type: "image", attachment: ATTACHMENT },
]);
@@ -77,56 +77,30 @@ describe("viewModelOf", () => {
});
it("keeps the path-only form image-free", () => {
const model = viewModelOf(
settledBlock('{"action":"screenshot"}', [{ type: "text", text: PATH_TEXT }]),
);
const model = viewModelOf(settledBlock("{}", [{ type: "text", text: PATH_TEXT }]));
expect(model.state).toBe("ok");
expect(model.image).toBeNull();
expect(model.summary).toBe(PATH_TEXT);
});
it("marks error results", () => {
const model = viewModelOf(
settledBlock('{"action":"screenshot"}', [{ type: "text", text: "Error: boom" }], true),
);
const model = viewModelOf(settledBlock("{}", [{ type: "text", text: "Error: boom" }], true));
expect(model.state).toBe("error");
});
it("renders non-screenshot inspect actions as ordinary terminal calls", () => {
const model = viewModelOf(
settledBlock('{"action":"observe","session":"s1"}', [
{ type: "text", text: "page observation" },
]),
);
expect(model.title).toBe("Observe");
expect(model.command).toBe("bsk observe --session s1");
expect(model.image).toBeNull();
});
it("preserves action-specific arguments in diagnostic command cards", () => {
const model = viewModelOf(
runningBlock(
'{"action":"console","session":"s1","tabId":7,"since":4,"limit":20,"maxTextChars":500,"includeStack":true}',
),
);
expect(model.command).toBe(
"bsk console --session s1 --tab-id 7 --since 4 --limit 20 --max-text-chars 500 --include-stack",
);
});
});
describe("BrowserInspectToolView", () => {
const baseProps = { callId: "c1", toolName: "browser_inspect", openFile: () => {} };
describe("ScreenshotToolView", () => {
const baseProps = { callId: "c1", toolName: "browser_screenshot", openFile: () => {} };
const renderView = (
block: BrowserInspectToolViewProps["block"],
loadImage: BrowserInspectToolViewProps["loadImage"],
block: ScreenshotToolViewProps["block"],
loadImage: ScreenshotToolViewProps["loadImage"],
) => {
const props = { ...baseProps, block, loadImage } as unknown as BrowserInspectToolViewProps;
return render(<BrowserInspectToolView {...props} />);
const props = { ...baseProps, block, loadImage } as unknown as ScreenshotToolViewProps;
return render(<ScreenshotToolView {...props} />);
};
it("renders the screenshot image through the loader", async () => {
const block = settledBlock('{"action":"screenshot"}', [
const block = settledBlock("{}", [
{ type: "text", text: IMAGE_TEXT },
{ type: "image", attachment: ATTACHMENT },
]);
@@ -141,7 +115,7 @@ describe("BrowserInspectToolView", () => {
});
it("renders the path-only form without touching the loader", async () => {
const block = settledBlock('{"action":"screenshot"}', [{ type: "text", text: PATH_TEXT }]);
const block = settledBlock("{}", [{ type: "text", text: PATH_TEXT }]);
const loadImage = vi.fn(async () => "blob:unused");
renderView(block, loadImage);
screen.getByRole("button", { name: /screenshot/i }).click();
@@ -151,14 +125,14 @@ describe("BrowserInspectToolView", () => {
it("renders the running form without output", () => {
const loadImage = vi.fn();
renderView(runningBlock('{"action":"screenshot"}'), loadImage);
renderView(runningBlock("{}"), loadImage);
expect(screen.getByText("bsk screenshot --session (current)")).toBeTruthy();
expect(loadImage).not.toHaveBeenCalled();
});
});
describe("client plugin registration", () => {
it("registers the browser_inspect toolview and the shell observation overlay", () => {
it("registers the browser_screenshot toolview and the shell observation overlay", () => {
const registrations: { name: string; key?: string; id?: string }[] = [];
const sessions = { binding: () => undefined };
const ctx = {
@@ -176,7 +150,7 @@ describe("client plugin registration", () => {
};
apply(ctx as never);
expect(registrations).toEqual([
{ name: "tool.call.toolview", key: "browser_inspect" },
{ name: "tool.call.toolview", key: "browser_screenshot" },
{ name: "shell.overlay", id: "bsk-observation" },
]);
});
@@ -73,15 +73,15 @@ describe("dispose cleanup ownership", () => {
};
apply(ctx as never, { maxSessions: 5, lazyTools: false }, { runnerFactory: () => runner });
const session = tools.get("browser_session");
const inspect = tools.get("browser_inspect");
if (session === undefined || inspect === undefined) throw new Error("tools not registered");
await session.execute({ action: "start" }, makeExec());
await session.execute({ action: "start" }, makeExec());
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(
inspect.execute({ action: "snapshot", session: "ext9" }, makeExec()),
).rejects.toThrow(/does not belong to this plugin/);
await expect(snapshot.execute({ session: "ext9" }, makeExec())).rejects.toThrow(
/does not belong to this plugin/,
);
await Promise.all(disposers.map((dispose) => dispose()));
@@ -211,25 +211,19 @@ describe("lazyTools wiring in apply()", () => {
it("lazyTools: false registers the suite at apply time", () => {
const { tools } = applyHarness({ lazyTools: false });
expect([...tools.keys()].sort()).toEqual([
"browser_assist",
"browser_inspect",
"browser_interact",
"browser_page",
"browser_session",
"browser_tabs",
]);
expect(tools.has("browser_session_start")).toBe(true);
expect(tools.has("browser_emulate")).toBe(true);
});
it("lazyTools: true hides the suite until the skill fires", () => {
const { tools, listeners } = applyHarness({ lazyTools: true });
expect(tools.has("browser_session")).toBe(false);
expect(tools.has("browser_session_start")).toBe(false);
callListeners(
listeners,
"tools/result",
{ name: "skill", arguments: { name: "browser-skill" } },
{ isError: false },
);
expect(tools.has("browser_session")).toBe(true);
expect(tools.has("browser_session_start")).toBe(true);
});
});
@@ -78,7 +78,7 @@ function fakeRunner(
if (opts.stopNotFound) {
return {
code: 4,
stdout: JSON.stringify({ code: "not_found", message: "no such session" }),
stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }),
stderr: "",
timedOut: false,
aborted: false,
@@ -93,7 +93,7 @@ function fakeRunner(
if (opts.screenshotNotFound) {
return {
code: 4,
stdout: JSON.stringify({ code: "not_found", message: "no such session" }),
stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }),
stderr: "",
timedOut: false,
aborted: false,
@@ -224,57 +224,6 @@ describe("state machine", () => {
});
describe("thumbnail cadence", () => {
it("gives foreground work priority and resumes with one fresh capture", async () => {
const scheduler = fakeScheduler();
let finishCapture: ((result: BskRunResult) => void) | undefined;
const killed: string[] = [];
const runner: FakeRunner = {
calls: [],
killed,
run(args) {
if (args[0] !== "screenshot") {
return Promise.resolve({
code: 0,
stdout: "{}",
stderr: "",
timedOut: false,
aborted: false,
});
}
return new Promise((resolve) => {
finishCapture = resolve;
});
},
killAll() {},
killFor(tag) {
killed.push(tag);
if (tag === "observation:s1" && finishCapture !== undefined) {
finishCapture({
code: 5,
stdout: JSON.stringify({ code: "cancelled", message: "cancelled" }),
stderr: "",
timedOut: false,
aborted: false,
});
finishCapture = undefined;
return 1;
}
return 0;
},
};
const { service } = setup({ runner, scheduler });
service.addSession("s1");
scheduler.runNext();
await waitFor(() => finishCapture !== undefined);
const release = service.acquireForeground("s1");
expect(killed).toEqual(["observation:s1"]);
await waitFor(() => scheduler.pending().length === 0);
release();
expect(service.isAvailable()).toBe(true);
expect(scheduler.pending()).toEqual([0]);
});
it("captures immediately on add and after action end, then fast-cadences while active", async () => {
const scheduler = fakeScheduler();
const runner = fakeRunner();
@@ -402,7 +351,7 @@ describe("stopSession", () => {
expect(registry.isOwned("s1")).toBe(true);
await expect(service.stopSession("s1")).resolves.toBe(true);
expect(runner.killed).toEqual(["observation:s1", "s1"]);
expect(runner.killed).toEqual(["s1"]);
const stop = runner.calls.find((c) => c.args[0] === "session" && c.args[1] === "stop");
expect(stop?.args).toEqual(["session", "stop", "s1"]);
expect(registry.isOwned("s1")).toBe(false);
@@ -431,13 +380,13 @@ describe("stopSession", () => {
expect(events[events.length - 1]).toMatchObject({ type: "remove" });
});
it("rejects and keeps the entry when the stop itself fails", async () => {
it("returns false and keeps the entry when the stop itself fails", async () => {
const registry = new SessionRegistry(5);
own(registry, "s1");
const runner = fakeRunner({ stopFails: true });
const { service } = setup({ registry, runner });
service.addSession("s1");
await expect(service.stopSession("s1")).rejects.toThrow(/boom/);
await expect(service.stopSession("s1")).resolves.toBe(false);
expect(registry.isOwned("s1")).toBe(true);
expect(service.getState().map((s) => s.sessionId)).toEqual(["s1"]);
});
@@ -641,7 +590,7 @@ describe("HTTP/SSE interface", () => {
});
});
describe("availability and missing sessions", () => {
describe("availability and dead sessions", () => {
it("flips availability off after repeated global failures and back on success", async () => {
const scheduler = fakeScheduler();
const runner = fakeRunner({ screenshotFails: true });
@@ -661,18 +610,23 @@ describe("availability and missing sessions", () => {
service.dispose();
});
it("removes an owned ghost on not_found and stops asking for frames", async () => {
it("marks a session dead on session_not_found and stops asking for frames", async () => {
const scheduler = fakeScheduler();
const runner = fakeRunner({ screenshotNotFound: true });
const registry = new SessionRegistry(5);
own(registry, "s1");
const { service, events } = setup({ runner, scheduler, registry });
const { service, events } = setup({ runner, scheduler });
service.addSession("s1");
scheduler.runNext();
await waitFor(() => events.some((e) => e.type === "remove"));
expect(service.getState()).toEqual([]);
expect(registry.isOwned("s1")).toBe(false);
await waitFor(() => events.some((e) => e.type === "upsert" && e.session?.dead === true));
expect(service.getState()[0].dead).toBe(true);
expect(service.getState()[0].action).toBe("idle");
// No further captures are scheduled for a dead session.
expect(scheduler.pending()).toEqual([]);
// Instrumentation is dropped for dead sessions.
service.beginAction("s1", "clicking");
expect(service.getState()[0].action).toBe("idle");
// A dead session leaves cleanly.
service.removeSession("s1");
expect(service.getState()).toEqual([]);
});
it("clears lastError on the next action and keeps it on failure", () => {
@@ -1,32 +0,0 @@
// dsh 0.1 keys the client graph and Node import by `export const name`.
// That string must stay identical to the published package name (and therefore
// the client.cjs __ModuleLoader__ id, which tsdown reads from package.json).
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { name } from "../src/index";
const pkg = JSON.parse(
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
) as {
name: string;
dsh: { client: { external?: string[] } };
};
describe("plugin identity", () => {
it("uses the published package name as the Cordis plugin id", () => {
expect(name).toBe("@wxg-prc-cpg/browser-skill-dsh-plugin");
expect(name).toBe(pkg.name);
});
it("declares client require()s so dsh arrives them before materialize", () => {
expect(pkg.dsh.client.external).toEqual(
expect.arrayContaining([
"@deepseek-ai/dsh-client-ui-attachment",
"@deepseek-ai/dsh-client-ui-primitives",
]),
);
});
});
@@ -1,14 +1,7 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import {
type BskRunResult,
createBskRunner,
isCommandNotFound,
isSessionBusyResult,
parseBskJson,
runWithSessionBusyRetry,
} from "../src/runner";
import { type BskRunResult, createBskRunner, isCommandNotFound, parseBskJson } from "../src/runner";
/** Minimal fake ChildProcess driven by the test. */
class FakeChild extends EventEmitter {
@@ -70,7 +63,7 @@ describe("createBskRunner", () => {
controller.abort();
const result = await promise;
expect(result.aborted).toBe(true);
expect(child.killedWith).toContain("SIGINT");
expect(child.killedWith).toContain("SIGTERM");
});
it("kills the child on timeout", async () => {
@@ -87,7 +80,7 @@ describe("createBskRunner", () => {
const promise = runner.run(["session", "list"]);
runner.killAll();
const result = await promise;
expect(child.killedWith).toContain("SIGINT");
expect(child.killedWith).toContain("SIGTERM");
expect(result.code).toBeNull();
});
});
@@ -147,51 +140,3 @@ describe("isCommandNotFound", () => {
expect(isCommandNotFound(new Error("other"))).toBe(false);
});
});
describe("session busy reconciliation", () => {
const busy: BskRunResult = {
code: 4,
stdout: JSON.stringify({
code: "timeout",
message: "session already has an unfinished command",
data: { reason: "session_busy" },
}),
stderr: "",
timedOut: false,
aborted: false,
};
const ok: BskRunResult = {
code: 0,
stdout: "{}",
stderr: "",
timedOut: false,
aborted: false,
};
it("recognizes only the structured session_busy reason", () => {
expect(isSessionBusyResult(busy)).toBe(true);
expect(isSessionBusyResult({ ...busy, stdout: '{"code":"timeout"}' })).toBe(false);
expect(isSessionBusyResult(ok)).toBe(false);
});
it("retries one transient busy result and returns the settled response", async () => {
const replies = [busy, ok];
let calls = 0;
const result = await runWithSessionBusyRetry(async () => {
calls += 1;
return replies.shift() ?? ok;
});
expect(result).toBe(ok);
expect(calls).toBe(2);
});
it("does not loop on a persistent busy result", async () => {
let calls = 0;
const result = await runWithSessionBusyRetry(async () => {
calls += 1;
return busy;
});
expect(result).toBe(busy);
expect(calls).toBe(2);
});
});
@@ -71,9 +71,7 @@ describe("SessionRegistry", () => {
it("resolve throws actionable guidance when no session exists", () => {
const registry = new SessionRegistry(5);
expect(() => registry.resolve(undefined, "browser_interact(action=click)")).toThrow(
/browser_session action=start/,
);
expect(() => registry.resolve(undefined, "browser_click")).toThrow(/browser_session_start/);
});
it("recency order survives remove of the current session", () => {
@@ -1,11 +1,8 @@
// Browser skill injection: registration wiring, catalog content, progressive-load
// weight, and silent degradation without the skill seam.
import { Context } from "@deepseek-ai/cordis";
import { createScope, scopeTarget } from "@deepseek-ai/dsh-scope";
import { SkillRegistry } from "@deepseek-ai/dsh-skill";
import { describe, expect, it } from "vitest";
import { armAgentScopedBskSkill, registerBskSkill } from "../src/skill";
import { registerBskSkill } from "../src/skill";
function fakeCtx(skills?: unknown) {
return { get: (key: string) => (key === "skills" ? skills : undefined) } as never;
@@ -32,22 +29,44 @@ describe("registerBskSkill", () => {
const content = String(skill.content);
const mentionedTools = [...new Set(content.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? [])].sort();
expect(mentionedTools).toEqual([
"browser_assist",
"browser_inspect",
"browser_interact",
"browser_page",
"browser_session",
"browser_tabs",
"browser_click",
"browser_console",
"browser_emulate",
"browser_fill",
"browser_get_html",
"browser_hover",
"browser_navigate",
"browser_navigate_back",
"browser_navigate_forward",
"browser_network",
"browser_observe",
"browser_press",
"browser_reload",
"browser_request_help",
"browser_screenshot",
"browser_select",
"browser_session_list",
"browser_session_start",
"browser_session_stop",
"browser_snapshot",
"browser_tab_borrow",
"browser_tab_close",
"browser_tab_create",
"browser_tab_list",
"browser_tab_return",
"browser_tab_select",
"browser_wait_for_navigation",
"browser_window_resize",
]);
expect(String(skill.description)).not.toMatch(/\bbsk\b/i);
expect(content).not.toMatch(/\bbsk\b/i);
expect(content).not.toMatch(/```(?:bash|sh|shell)\b/i);
expect(content).not.toMatch(/--[a-z]/);
expect(content).toMatch(/All browser work\s+must use the injected tools directly/);
expect(content).toContain("All browser operations must use the injected tools directly");
expect(content).toContain("Mandatory workflow");
expect(content).toContain("Refs invalidate after navigation");
expect(content).toContain("evaluation and interaction recording are intentionally unsupported");
expect(content.length).toBeGreaterThan(6_000);
expect(content.length).toBeGreaterThan(10_000);
expect(skill.source).toBe("bundled");
// Source frontmatter is registration metadata and must not leak into the body.
expect(content.startsWith("---")).toBe(false);
@@ -81,88 +100,3 @@ describe("registerBskSkill", () => {
expect(() => disposer()).not.toThrow();
});
});
describe("armAgentScopedBskSkill", () => {
it("overrides a nearer legacy CLI skill when the DSH agent starts", async () => {
const root = new Context();
const skillFiber = root.plugin(SkillRegistry);
await skillFiber;
// The plugin's original registration is global.
registerBskSkill(skillFiber.ctx);
// Model the standard preset's filesystem discovery of
// ~/.agents/skills/browser-skill. Its nearer scope wins before the fix.
const presetKey = { preset: "standard" };
const presetScope = createScope(skillFiber.ctx, presetKey);
presetScope.ctx.skills.register({
name: "browser-skill",
description: "Legacy BrowserSkill CLI instructions",
content: "Use bsk shell commands.",
source: "user-agents",
});
const agentKey = { agent: "test-agent" };
const agentScope = createScope(skillFiber.ctx, agentKey, { parent: presetKey });
const agent = {
id: "test-agent",
ctx: agentScope.ctx,
} as never;
const agents = { list: () => [] };
const pluginCtx = skillFiber.ctx.extend({ agents });
const before = await skillFiber.ctx.skills.get("browser-skill", { scope: agentKey });
expect(before?.content).toBe("Use bsk shell commands.");
expect(before?.source).toBe("user-agents");
const disarm = armAgentScopedBskSkill(pluginCtx);
pluginCtx.emit(scopeTarget(agent, agentKey), "agent/session-start", {
agent,
source: "startup",
});
const after = await skillFiber.ctx.skills.get("browser-skill", { scope: agentKey });
expect(after?.content).toMatch(/All browser work\s+must use the injected tools directly/);
expect(after?.content).not.toMatch(/\bbsk\b/i);
expect(after?.source).toBe("bundled");
disarm();
await agentScope.dispose();
await presetScope.dispose();
await skillFiber.dispose();
});
it("installs on existing agents for plugin reload and disposes registrations", () => {
const registered: Record<string, unknown>[] = [];
let disposed = 0;
const agent = {
ctx: fakeCtx({
register(skill: Record<string, unknown>) {
registered.push(skill);
return () => {
disposed += 1;
};
},
}),
} as never;
const listeners = new Map<string, (payload: { agent: never }) => void>();
const ctx = {
get: (key: string) => (key === "agents" ? { list: () => [agent] } : undefined),
on(event: string, listener: (payload: { agent: never }) => void) {
listeners.set(event, listener);
return () => listeners.delete(event);
},
} as never;
const disarm = armAgentScopedBskSkill(ctx);
expect(registered).toHaveLength(1);
// A later lifecycle notification for the same agent must not duplicate it.
listeners.get("agent/session-start")?.({ agent });
expect(registered).toHaveLength(1);
disarm();
expect(disposed).toBe(1);
expect(listeners.size).toBe(0);
});
});
@@ -3,12 +3,11 @@ 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 { registerBrowserTools } from "../src/browser-tools";
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 } from "../src/tools";
import { type PluginConfig, registerTools } from "../src/tools";
const CONFIG: PluginConfig = {
bskPath: "bsk",
@@ -85,66 +84,8 @@ function fakeRunner(responses: Record<string, unknown>) {
};
}
const ACTION_ROUTES: Record<string, readonly [string, string]> = {
"session.start": ["browser_session", "start"],
"session.stop": ["browser_session", "stop"],
"session.list": ["browser_session", "list"],
"page.navigate": ["browser_page", "navigate"],
"page.back": ["browser_page", "back"],
"page.forward": ["browser_page", "forward"],
"page.reload": ["browser_page", "reload"],
"page.wait": ["browser_page", "wait"],
"inspect.observe": ["browser_inspect", "observe"],
"inspect.snapshot": ["browser_inspect", "snapshot"],
"inspect.html": ["browser_inspect", "html"],
"inspect.screenshot": ["browser_inspect", "screenshot"],
"inspect.console": ["browser_inspect", "console"],
"inspect.network": ["browser_inspect", "network"],
"interact.click": ["browser_interact", "click"],
"interact.hover": ["browser_interact", "hover"],
"interact.fill": ["browser_interact", "fill"],
"interact.select": ["browser_interact", "select"],
"interact.press": ["browser_interact", "press"],
"tabs.list": ["browser_tabs", "list"],
"tabs.create": ["browser_tabs", "create"],
"tabs.select": ["browser_tabs", "select"],
"tabs.close": ["browser_tabs", "close"],
"tabs.borrow": ["browser_tabs", "borrow"],
"tabs.return": ["browser_tabs", "return"],
"assist.resize": ["browser_assist", "resize"],
"assist.emulate": ["browser_assist", "emulate"],
"assist.request-help": ["browser_assist", "request-help"],
};
class TestToolRegistry extends Map<string, ToolDefinition> {
override get(name: string): ToolDefinition | undefined {
const direct = super.get(name);
if (direct !== undefined) return direct;
const route = ACTION_ROUTES[name];
if (route === undefined) return undefined;
const [toolName, action] = route;
const tool = super.get(toolName);
if (tool === undefined) return undefined;
const withAction = (args: unknown) => ({
action,
...(args as Record<string, unknown>),
});
return {
...tool,
output: {
...tool.output,
render: (args, value) => tool.output.render(withAction(args) as never, value),
},
execute: (args, exec) => tool.execute(withAction(args) as never, exec),
isConcurrencySafe: (args) => tool.isConcurrencySafe?.(withAction(args) as never) === true,
presentCall: (args) => tool.presentCall?.(withAction(args) as never),
presentResult: (args, result) => tool.presentResult?.(withAction(args) as never, result),
} as ToolDefinition;
}
}
function makeCtx(services: Record<string, unknown> = {}) {
const tools = new TestToolRegistry();
const tools = new Map<string, ToolDefinition>();
const ctx = {
tools: { register: (def: ToolDefinition) => tools.set(def.name, def) },
get: (key: string) => services[key],
@@ -177,7 +118,7 @@ function setup(
const { runner, calls } = fakeRunner(responses);
const registry = new SessionRegistry(maxSessions);
const config = { ...CONFIG, maxSessions };
registerBrowserTools({
registerTools({
ctx: ctx as never,
runner: runner as BskRunner,
registry,
@@ -189,98 +130,56 @@ function setup(
}
async function startSession(tools: Map<string, ToolDefinition>, sessionId = "s1") {
const tool = tools.get("session.start");
if (tool === undefined) throw new Error("session.start not registered");
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" });
const EXPECTED_ACTIONS = {
browser_session: ["start", "stop", "list"],
browser_page: ["navigate", "back", "forward", "reload", "wait"],
browser_inspect: ["observe", "snapshot", "html", "screenshot", "console", "network"],
browser_interact: ["click", "hover", "fill", "select", "press"],
browser_tabs: ["list", "create", "select", "close", "borrow", "return"],
browser_assist: ["resize", "emulate", "request-help"],
} as const;
describe("tool registration", () => {
it("registers exactly six public schemas with the complete action contract", () => {
it("registers the full browser tool suite", () => {
const { tools } = setup({});
expect([...tools.keys()].sort()).toEqual(Object.keys(EXPECTED_ACTIONS).sort());
for (const [name, actions] of Object.entries(EXPECTED_ACTIONS)) {
const properties = tools.get(name)?.parameters.properties as
| Record<string, unknown>
| undefined;
expect(properties?.action).toMatchObject({ enum: [...actions] });
}
});
it("requires an action on every public tool", async () => {
const { tools } = setup({});
for (const name of Object.keys(EXPECTED_ACTIONS)) {
await expect(tools.get(name)?.execute({}, makeExec())).rejects.toThrow(/invalid arguments/i);
}
expect([...tools.keys()].sort()).toEqual([
"browser_click",
"browser_console",
"browser_emulate",
"browser_fill",
"browser_get_html",
"browser_hover",
"browser_navigate",
"browser_navigate_back",
"browser_navigate_forward",
"browser_network",
"browser_observe",
"browser_press",
"browser_reload",
"browser_request_help",
"browser_screenshot",
"browser_select",
"browser_session_list",
"browser_session_start",
"browser_session_stop",
"browser_snapshot",
"browser_tab_borrow",
"browser_tab_close",
"browser_tab_create",
"browser_tab_list",
"browser_tab_return",
"browser_tab_select",
"browser_wait_for_navigation",
"browser_window_resize",
]);
});
it("validates model args through defineTool (missing required param)", async () => {
const { tools } = setup({});
const navigate = tools.get("page.navigate");
const navigate = tools.get("browser_navigate");
await expect(navigate?.execute({}, makeExec())).rejects.toThrow(/invalid arguments/);
});
});
describe("action dispatch", () => {
it("rejects actions outside the public contract", async () => {
const { tools } = setup({});
await expect(
tools.get("browser_page")?.execute({ action: "unsupported" }, makeExec()),
).rejects.toThrow(/invalid arguments|unsupported browser action/i);
});
it("dispatches through the existing operation definitions", async () => {
const { tools, calls, registry } = setup({
"session start": START_REPLY("s1"),
navigate: { tab_id: 7, url: "https://example.test/", reached: "load" },
observe: SNAPSHOT_REPLY,
fill: { tab_id: 7, value_length: 5 },
"tab list": { tabs: [] },
"window resize": { window_id: 9, width: 900, height: 700 },
});
await tools.get("browser_session")?.execute({ action: "start" }, makeExec());
await tools
.get("browser_page")
?.execute({ action: "navigate", url: "https://example.test/" }, makeExec());
await tools.get("browser_inspect")?.execute({ action: "observe" }, makeExec());
await tools
.get("browser_interact")
?.execute({ action: "fill", target: "@e1", value: "hello" }, makeExec());
await tools.get("browser_tabs")?.execute({ action: "list" }, makeExec());
await tools
.get("browser_assist")
?.execute({ action: "resize", width: 900, height: 700 }, makeExec());
expect(registry.current()).toBe("s1");
expect(calls.map(({ args }) => args)).toEqual([
["session", "start"],
["navigate", "--session", "s1", "https://example.test/"],
["observe", "--session", "s1"],
["fill", "--session", "s1", "--value", "hello", "@e1"],
["tab", "list", "--session", "s1"],
["window", "resize", "--session", "s1", "--width", "900", "--height", "700"],
]);
});
it("keeps action-specific validation in the private operation", async () => {
const { tools } = setup({});
await expect(
tools.get("browser_page")?.execute({ action: "navigate" }, makeExec()),
).rejects.toThrow(/url.*required|invalid arguments/i);
});
});
describe("session.start", () => {
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);
@@ -294,7 +193,7 @@ describe("session.start", () => {
emulate: { tab_id: 7, cleared: false },
navigate: { tab_id: 7, url: "https://example.com", reached: "load" },
});
const tool = tools.get("session.start");
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(),
@@ -319,7 +218,7 @@ describe("session.start", () => {
it("requires width and height together", async () => {
const { tools } = setup({ "session start": START_REPLY("s1") });
const tool = tools.get("session.start");
const tool = tools.get("browser_session_start");
await expect(tool?.execute({ width: 1280 }, makeExec())).rejects.toThrow(/together/);
});
@@ -329,7 +228,7 @@ describe("session.start", () => {
"session stop": {},
});
// navigate has no canned response -> non-zero exit -> error.
const tool = tools.get("session.start");
const tool = tools.get("browser_session_start");
await expect(tool?.execute({ url: "https://example.com" }, makeExec())).rejects.toThrow(
/navigate/,
);
@@ -347,7 +246,7 @@ describe("multi-session behavior", () => {
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("inspect.snapshot");
const snapshot = tools.get("browser_snapshot");
const value = (await snapshot?.execute({}, makeExec())) as {
session: string;
refCount: number;
@@ -363,7 +262,7 @@ describe("multi-session behavior", () => {
});
await startSession(tools, "s1");
await startSession(tools, "s2");
const snapshot = tools.get("inspect.snapshot");
const snapshot = tools.get("browser_snapshot");
const value = (await snapshot?.execute({ session: "s1" }, makeExec())) as {
session: string;
};
@@ -376,7 +275,7 @@ describe("multi-session behavior", () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const callsBefore = calls.length;
const snapshot = tools.get("inspect.snapshot");
const snapshot = tools.get("browser_snapshot");
await expect(snapshot?.execute({ session: "other" }, makeExec())).rejects.toThrow(
/does not belong to this plugin/,
);
@@ -385,8 +284,8 @@ describe("multi-session behavior", () => {
it("fails with guidance when no session exists", async () => {
const { tools } = setup(responses);
const snapshot = tools.get("inspect.snapshot");
await expect(snapshot?.execute({}, makeExec())).rejects.toThrow(/browser_session action=start/);
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 () => {
@@ -396,7 +295,7 @@ describe("multi-session behavior", () => {
1,
);
await startSession(tools);
const start = tools.get("session.start");
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);
@@ -408,7 +307,7 @@ describe("multi-session behavior", () => {
{},
1,
);
const start = tools.get("session.start");
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([
@@ -426,7 +325,7 @@ describe("multi-session behavior", () => {
it("refuses to stop a session the plugin did not create", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const stop = tools.get("session.stop");
const stop = tools.get("browser_session_stop");
await expect(stop?.execute({ session: "ext7" }, makeExec())).rejects.toThrow(
/does not belong to this plugin/,
);
@@ -435,14 +334,14 @@ describe("multi-session behavior", () => {
});
});
describe("session.stop / list", () => {
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("session.stop");
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"]);
@@ -455,7 +354,7 @@ describe("session.stop / list", () => {
});
await startSession(tools);
await startSession(tools);
const list = tools.get("session.list");
const list = tools.get("browser_session_list");
const value = (await list?.execute({}, makeExec())) as {
sessions: { sessionId: string; current: boolean }[];
};
@@ -478,10 +377,10 @@ describe("interaction tools", () => {
emulate: { tab_id: 7, cleared: false },
};
it("interact.click maps target, button, and click count", async () => {
it("browser_click maps target, button, and click count", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const click = tools.get("interact.click");
const click = tools.get("browser_click");
const value = (await click?.execute(
{ target: "@e1", button: "right", clickCount: 2 },
makeExec(),
@@ -499,10 +398,10 @@ describe("interaction tools", () => {
]);
});
it("interact.fill passes value and --no-clear", async () => {
it("browser_fill passes value and --no-clear", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const fill = tools.get("interact.fill");
const fill = tools.get("browser_fill");
await fill?.execute({ target: "e2", value: "hello", noClear: true }, makeExec());
expect(calls[1].args).toEqual([
"fill",
@@ -515,20 +414,20 @@ describe("interaction tools", () => {
]);
});
it("interact.press picks --ref for snapshot refs and --selector otherwise", async () => {
it("browser_press picks --ref for snapshot refs and --selector otherwise", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const press = tools.get("interact.press");
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("page.navigate converts waitUntil and timeoutMs", async () => {
it("browser_navigate converts waitUntil and timeoutMs", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const navigate = tools.get("page.navigate");
const navigate = tools.get("browser_navigate");
const value = (await navigate?.execute(
{ url: "https://a.test", waitUntil: "networkidle", timeoutMs: 1500 },
makeExec(),
@@ -546,16 +445,16 @@ describe("interaction tools", () => {
]);
});
it("assist.emulate rejects off combined with overrides", async () => {
it("browser_emulate rejects off combined with overrides", async () => {
const { tools } = setup(responses);
await startSession(tools);
const emulate = tools.get("assist.emulate");
const emulate = tools.get("browser_emulate");
await expect(emulate?.execute({ off: true, device: "iphone-14" }, makeExec())).rejects.toThrow(
/mutually exclusive/,
);
});
it("interact.hover and interact.select map their structured arguments", async () => {
it("browser_hover and browser_select map their structured arguments", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
hover: { tab_id: 7, used_ref: "@e3", x: 12, y: 24 },
@@ -569,7 +468,7 @@ describe("interaction tools", () => {
});
await startSession(tools);
const hovered = (await tools
.get("interact.hover")
.get("browser_hover")
?.execute(
{ target: "@e3", modifiers: ["shift"], settleMs: 250, timeoutMs: 2000 },
makeExec(),
@@ -589,7 +488,7 @@ describe("interaction tools", () => {
]);
const selected = (await tools
.get("interact.select")
.get("browser_select")
?.execute({ target: "@e4", values: ["a", "b"], tabId: 7 }, makeExec())) as {
selectedValues: string[];
selectedLabels: string[];
@@ -647,25 +546,25 @@ describe("phase-one tab tools", () => {
});
await startSession(tools);
const listed = (await tools.get("tabs.list")?.execute({ scope: "agent" }, makeExec())) as {
tabs: { tabId: number }[];
};
const listed = (await tools
.get("browser_tab_list")
?.execute({ scope: "agent" }, makeExec())) as { tabs: { tabId: number }[] };
expect(listed.tabs[0].tabId).toBe(7);
const created = (await tools
.get("tabs.create")
.get("browser_tab_create")
?.execute({ url: "https://new.test", active: false, index: 1 }, makeExec())) as {
tabId: number;
};
expect(created.tabId).toBe(8);
await tools.get("tabs.close")?.execute({ tabId: 8 }, makeExec());
await tools.get("tabs.select")?.execute({ tabId: 7 }, makeExec());
const borrowed = (await tools.get("tabs.borrow")?.execute({ tabId: 20 }, makeExec())) as {
originalWindowId: number;
};
await tools.get("browser_tab_close")?.execute({ tabId: 8 }, makeExec());
await tools.get("browser_tab_select")?.execute({ tabId: 7 }, makeExec());
const borrowed = (await tools
.get("browser_tab_borrow")
?.execute({ tabId: 20 }, makeExec())) as { originalWindowId: number };
expect(borrowed.originalWindowId).toBe(9);
const returned = (await tools.get("tabs.return")?.execute({ tabId: 20 }, makeExec())) as {
fallback: boolean;
};
const returned = (await tools
.get("browser_tab_return")
?.execute({ tabId: 20 }, makeExec())) as { fallback: boolean };
expect(returned.fallback).toBe(false);
expect(calls.slice(1).map((call) => call.args)).toEqual([
@@ -715,14 +614,15 @@ describe("phase-one navigation tools", () => {
});
await startSession(tools);
await tools
.get("page.back")
.get("browser_navigate_back")
?.execute({ tabId: 7, waitUntil: "commit", timeoutMs: 1000 }, makeExec());
await tools.get("page.forward")?.execute({}, makeExec());
await tools.get("page.reload")?.execute({ hard: true, waitUntil: "networkidle" }, makeExec());
const waited = (await tools.get("page.wait")?.execute({ timeoutMs: 2500 }, makeExec())) as {
reached: string;
errorText: string;
};
await tools.get("browser_navigate_forward")?.execute({}, makeExec());
await tools
.get("browser_reload")
?.execute({ hard: true, waitUntil: "networkidle" }, makeExec());
const waited = (await tools
.get("browser_wait_for_navigation")
?.execute({ timeoutMs: 2500 }, makeExec())) as { reached: string; errorText: string };
expect(waited).toMatchObject({ reached: "timeout", errorText: "no navigation" });
expect(calls[1].args).toEqual([
"navigate-back",
@@ -768,7 +668,7 @@ describe("phase-one support tools", () => {
},
});
await startSession(tools);
const value = (await tools.get("assist.request-help")?.execute(
const value = (await tools.get("browser_request_help")?.execute(
{
prompt: "Please sign in",
title: "Login required",
@@ -836,17 +736,17 @@ describe("phase-one support tools", () => {
});
await startSession(tools);
const html = (await tools
.get("inspect.html")
.get("browser_get_html")
?.execute({ ref: "@e2", maxBytes: 2048 }, makeExec())) as { html: string };
expect(html.html).toBe("<main>Hi</main>");
const consoleValue = (await tools
.get("inspect.console")
.get("browser_console")
?.execute({ since: 1, limit: 10, includeStack: true }, makeExec())) as {
entries: { stackTrace: { functionName: string }[] }[];
};
expect(consoleValue.entries[0].stackTrace[0].functionName).toBe("run");
const networkValue = (await tools
.get("inspect.network")
.get("browser_network")
?.execute({ tabId: 7, maxTextChars: 512 }, makeExec())) as {
entries: { mimeType: string }[];
};
@@ -864,7 +764,7 @@ describe("phase-one support tools", () => {
"window resize": { window_id: 2, width: 1280, height: 800 },
});
await startSession(tools);
const resize = tools.get("assist.resize");
const resize = tools.get("browser_window_resize");
const value = (await resize?.execute({ width: 1280, height: 800 }, makeExec())) as {
windowId: number;
};
@@ -885,7 +785,7 @@ describe("phase-one support tools", () => {
});
});
describe("inspect.screenshot", () => {
describe("browser_screenshot", () => {
function pngFile(): string {
const dir = mkdtempSync(join(tmpdir(), "bsk-test-"));
const path = join(dir, "shot.png");
@@ -894,15 +794,6 @@ describe("inspect.screenshot", () => {
return path;
}
function jpegFile(): string {
const dir = mkdtempSync(join(tmpdir(), "bsk-test-"));
const path = join(dir, "shot.png");
// A JPEG SOI marker (FF D8 FF) even though the file is named ".png" —
// mirrors captureVisibleTab returning JPEG on some Chromium builds.
writeFileSync(path, Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0, 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({
@@ -910,7 +801,7 @@ describe("inspect.screenshot", () => {
screenshot: { tab_id: 7, width: 800, height: 600, format: "png", path, byte_size: 8 },
});
await startSession(tools);
const screenshot = tools.get("inspect.screenshot");
const screenshot = tools.get("browser_screenshot");
const value = (await screenshot?.execute({ ref: "@e1" }, makeExec())) as {
path: string;
image?: unknown;
@@ -959,7 +850,7 @@ describe("inspect.screenshot", () => {
options: {},
},
});
const screenshot = tools.get("inspect.screenshot");
const screenshot = tools.get("browser_screenshot");
const value = (await screenshot?.execute({}, exec)) as {
image?: { attachmentId: string; mediaType: string };
};
@@ -968,50 +859,6 @@ describe("inspect.screenshot", () => {
expect(rendered.map((block) => block.type)).toEqual(["text", "image"]);
});
it("declares the real media type when capture bytes are JPEG, not a hard-coded PNG", async () => {
const path = jpegFile();
const services = {
attachments: {
imageLimits: {
mediaTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"],
maxImageBytes: 10_000_000,
maxMessageImageBytes: 10_000_000,
},
saveImage: async (input: { data: Uint8Array; mediaType: string; name?: string }) => ({
attachmentId: "att-jpeg",
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: 9 },
},
services,
);
await startSession(tools);
const exec = makeExec({
agent: {
session: { requestHeader: () => ({ config: { provider: "deepseek", model: "vl" } }) },
options: {},
},
});
const screenshot = tools.get("inspect.screenshot");
const value = (await screenshot?.execute({}, exec)) as {
image?: { attachmentId: string; mediaType: string };
};
// The reference media type must reflect the sniffed JPEG bytes, not "image/png".
expect(value.image).toMatchObject({ attachmentId: "att-jpeg", mediaType: "image/jpeg" });
});
it("stays path-only when the model route is text-only", async () => {
const path = pngFile();
const services = {
@@ -1037,7 +884,7 @@ describe("inspect.screenshot", () => {
options: {},
},
});
const screenshot = tools.get("inspect.screenshot");
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);
@@ -1050,14 +897,14 @@ describe("error and cancellation semantics", () => {
"session start": START_REPLY("s1"),
});
await startSession(tools);
const snapshot = tools.get("inspect.snapshot");
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("inspect.snapshot");
const snapshot = tools.get("browser_snapshot");
const controller = new AbortController();
controller.abort();
const exec = makeExec({ signal: controller.signal });
@@ -1074,7 +921,7 @@ describe("error and cancellation semantics", () => {
killAll() {},
killFor: () => 0,
};
registerBrowserTools({
registerTools({
ctx: ctx as never,
runner,
registry,
@@ -1082,7 +929,7 @@ describe("error and cancellation semantics", () => {
observation: disabledObservation({ ctx, runner, registry }),
queue: new KeyedExecutor(),
});
const start = tools.get("session.start");
const start = tools.get("browser_session_start");
await expect(start?.execute({}, makeExec())).rejects.toThrow(/BrowserSkill must be installed/);
});
});
@@ -1090,7 +937,7 @@ describe("error and cancellation semantics", () => {
describe("presentation", () => {
it("presents calls as terminal cards with the bsk command line", () => {
const { tools } = setup({});
const click = tools.get("interact.click");
const click = tools.get("browser_click");
const view = click?.presentCall?.({ target: "@e1" }) as {
card: string;
title: string;
@@ -1101,7 +948,7 @@ describe("presentation", () => {
it("presents results as terminal cards carrying the rendered text", () => {
const { tools } = setup({});
const click = tools.get("interact.click");
const click = tools.get("browser_click");
const view = click?.presentResult?.({ target: "@e1" }, {
content: [{ type: "text", text: "clicked" }],
isError: false,
@@ -1111,7 +958,7 @@ describe("presentation", () => {
it("projects the text block of multi-block results (screenshot text + image)", () => {
const { tools } = setup({});
const screenshot = tools.get("inspect.screenshot");
const screenshot = tools.get("browser_screenshot");
const view = screenshot?.presentResult?.({}, {
content: [
{ type: "text", text: "[session s1] screenshot of tab 7" },
@@ -1170,7 +1017,7 @@ describe("screenshot scratch file lifecycle", () => {
function setupWithRunner(runner: BskRunner, services: Record<string, unknown> = {}) {
const { ctx, tools } = makeCtx(services);
const registry = new SessionRegistry(5);
registerBrowserTools({
registerTools({
ctx: ctx as never,
runner,
registry,
@@ -1205,7 +1052,7 @@ describe("screenshot scratch file lifecycle", () => {
options: {},
},
});
const screenshot = tools.get("inspect.screenshot");
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);
@@ -1217,7 +1064,7 @@ describe("screenshot scratch file lifecycle", () => {
const { runner, calls } = outWritingRunner();
const { tools } = setupWithRunner(runner);
await startSession(tools);
const screenshot = tools.get("inspect.screenshot");
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);
@@ -1292,18 +1139,11 @@ describe("observation action instrumentation timing", () => {
queue,
options: { enabled: true, thumbnailIntervalMs: 1500, idleIntervalMs: 8000 },
});
registerBrowserTools({
ctx: ctx as never,
runner,
registry,
config: CONFIG,
observation,
queue,
});
registerTools({ ctx: ctx as never, runner, registry, config: CONFIG, observation, queue });
await startSession(tools);
const navigate = tools.get("page.navigate");
const snapshot = tools.get("inspect.snapshot");
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(() =>

Some files were not shown because too many files have changed in this diff Show More