mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7f976335 | ||
|
|
d1d3eb6a94 | ||
|
|
38fd7d3f4c | ||
|
|
b5062ac19b | ||
|
|
41cb576256 |
@@ -247,41 +247,6 @@ describe("App", () => {
|
|||||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||||
expect(screen.getByText("连接后可用")).toBeTruthy();
|
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", () => {
|
describe("control hints toggle", () => {
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export function App() {
|
|||||||
}, [copiedTick]);
|
}, [copiedTick]);
|
||||||
|
|
||||||
const isSkewed = statusState === "version_skew";
|
const isSkewed = statusState === "version_skew";
|
||||||
const connectionLive = statusState === "connected" || isSkewed;
|
|
||||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||||
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
||||||
const extensionVersion = snapshot.extensionVersion || "—";
|
const extensionVersion = snapshot.extensionVersion || "—";
|
||||||
@@ -91,7 +90,7 @@ export function App() {
|
|||||||
setCopiedInstanceId(true);
|
setCopiedInstanceId(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const recordReady = connectionLive && Boolean(snapshot.instanceId);
|
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
||||||
const recordPurpose = purposeDraft.trim();
|
const recordPurpose = purposeDraft.trim();
|
||||||
const recordStartUrl = startUrlDraft.trim();
|
const recordStartUrl = startUrlDraft.trim();
|
||||||
const recordCommand = snapshot.instanceId
|
const recordCommand = snapshot.instanceId
|
||||||
@@ -207,12 +206,12 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
{isSkewed && (
|
{isSkewed && (
|
||||||
<p
|
<p
|
||||||
className="mt-2 text-xs leading-snug text-muted-foreground"
|
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||||
data-slot="popup-version-skew-warning"
|
data-slot="popup-version-skew-warning"
|
||||||
>
|
>
|
||||||
{t("popup.versionSkewWarning", {
|
{t("popup.versionSkewWarning", {
|
||||||
extensionProtocol: PROTOCOL_VERSION,
|
extensionProtocol: PROTOCOL_VERSION,
|
||||||
cliProtocol: daemonProtocol,
|
daemonProtocol,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1656,7 +1656,7 @@ describe("buildVomScene", () => {
|
|||||||
|
|
||||||
const scene = buildVomScene(axNodes, captured);
|
const scene = buildVomScene(axNodes, captured);
|
||||||
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||||
expect.objectContaining({ id: 20, role: "generic", cursor: "pointer" }),
|
expect.objectContaining({ id: 20, role: "button", name: "close", cursor: "pointer" }),
|
||||||
);
|
);
|
||||||
const rendered = renderVom(scene);
|
const rendered = renderVom(scene);
|
||||||
expect(rendered.text).toContain('@e1 button "close"');
|
expect(rendered.text).toContain('@e1 button "close"');
|
||||||
@@ -1732,7 +1732,7 @@ describe("buildVomScene", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scene = buildVomScene(axNodes, captured);
|
const scene = buildVomScene(axNodes, captured);
|
||||||
expect(scene.nodes.find((n) => n.id === 20)?.role).toBe("generic");
|
expect(scene.nodes.find((n) => n.id === 20)).toBeUndefined();
|
||||||
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("link");
|
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("link");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1801,9 +1801,9 @@ describe("buildVomScene", () => {
|
|||||||
|
|
||||||
const scene = buildVomScene(axNodes, captured);
|
const scene = buildVomScene(axNodes, captured);
|
||||||
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||||
expect.objectContaining({ id: 20, role: "generic", attrs: { "aria-label": "收藏" } }),
|
expect.objectContaining({ id: 20, role: "button", attrs: { "aria-label": "收藏" } }),
|
||||||
);
|
);
|
||||||
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("generic");
|
expect(scene.nodes.find((n) => n.id === 30)).toBeUndefined();
|
||||||
const rendered = renderVom(scene);
|
const rendered = renderVom(scene);
|
||||||
expect(rendered.text).toContain('@e1 button "收藏"');
|
expect(rendered.text).toContain('@e1 button "收藏"');
|
||||||
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([
|
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([
|
||||||
@@ -2131,7 +2131,7 @@ describe("buildVomScene", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(scene.surfaces).toEqual([
|
expect(scene.surfaces).toEqual([
|
||||||
{ triggerId: 21, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
|
{ triggerId: 20, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
|
||||||
]);
|
]);
|
||||||
expect(renderVom(scene).text).toContain(
|
expect(renderVom(scene).text).toContain(
|
||||||
'@e1 button "image" [hover first: My profile | Sign out]',
|
'@e1 button "image" [hover first: My profile | Sign out]',
|
||||||
@@ -3155,6 +3155,98 @@ 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 () => {
|
it("does not leak form secrets through the record-safe observation payload", async () => {
|
||||||
const secrets = {
|
const secrets = {
|
||||||
email: "user@example.com",
|
email: "user@example.com",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ function fakeSnapshotReply() {
|
|||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
nodeIndex: [1, 2, 3],
|
nodeIndex: [1, 2, 3],
|
||||||
// styles columns follow requested computedStyles order: [position, pointer-events]
|
// Legacy fixture: omitted style columns fall back to visible defaults.
|
||||||
styles: [
|
styles: [
|
||||||
[i("static"), i("auto")],
|
[i("static"), i("auto")],
|
||||||
[i("fixed"), i("auto")],
|
[i("fixed"), i("auto")],
|
||||||
@@ -682,6 +682,46 @@ describe("captureViewModel", () => {
|
|||||||
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
|
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 () => {
|
it("subtracts scroll offset to make rects viewport-relative", async () => {
|
||||||
const cdp = {
|
const cdp = {
|
||||||
send: vi.fn(async (_t: number, method: string) => {
|
send: vi.fn(async (_t: number, method: string) => {
|
||||||
|
|||||||
@@ -132,4 +132,67 @@ 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 } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,782 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
// CDP capture adapter: DOMSnapshot.captureSnapshot + Page.getLayoutMetrics
|
// CDP capture adapter: DOMSnapshot.captureSnapshot + Page.getLayoutMetrics
|
||||||
// → CapturedNode[] + Viewport. This is the ONLY VOM module that touches
|
// → CapturedNode[] + Viewport. This is the ONLY VOM module that touches
|
||||||
// raw CDP. The captureSnapshot reply is columnar (parallel arrays + a
|
// raw CDP. The captureSnapshot reply is columnar (parallel arrays + a
|
||||||
// shared string table); we request exactly three computed styles so the
|
// shared string table); requested computed styles are decoded through
|
||||||
// `styles` columns are [position, pointer-events, cursor] in that order.
|
// STYLE_COL so capture and visibility policy share one explicit contract.
|
||||||
|
|
||||||
import type { Rect, Viewport } from "@browser-skill/vom";
|
import type { Rect, Viewport } from "@browser-skill/vom";
|
||||||
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
||||||
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||||
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
|
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
|
||||||
import type { CdpRunner } from "../shared";
|
import type { CdpRunner } from "../shared";
|
||||||
|
import { clearHover, waitForHover } from "./hover-perception";
|
||||||
|
|
||||||
const REQUESTED_STYLES = ["position", "pointer-events", "cursor"] as const;
|
const REQUESTED_STYLES = ["position", "pointer-events", "cursor", "visibility", "opacity"] as const;
|
||||||
const STYLE_COL = Object.fromEntries(
|
const STYLE_COL = Object.fromEntries(
|
||||||
REQUESTED_STYLES.map((name, index) => [name, index]),
|
REQUESTED_STYLES.map((name, index) => [name, index]),
|
||||||
) as Record<(typeof REQUESTED_STYLES)[number], number>;
|
) as Record<(typeof REQUESTED_STYLES)[number], number>;
|
||||||
@@ -38,6 +39,12 @@ export interface CapturedNode {
|
|||||||
* `textContent`: the live parser always sets it, hand-built fixtures may not.
|
* `textContent`: the live parser always sets it, hand-built fixtures may not.
|
||||||
*/
|
*/
|
||||||
cursor?: string;
|
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;
|
textContent?: string;
|
||||||
formState?: "empty" | "filled" | "default";
|
formState?: "empty" | "filled" | "default";
|
||||||
formValue?: string;
|
formValue?: string;
|
||||||
@@ -460,36 +467,6 @@ 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 {
|
function capturedText(node: CapturedNode): string | undefined {
|
||||||
const value =
|
const value =
|
||||||
node.attrs["aria-label"] ?? node.attrs.title ?? node.attrs.alt ?? node.textContent ?? "";
|
node.attrs["aria-label"] ?? node.attrs.title ?? node.attrs.alt ?? node.textContent ?? "";
|
||||||
@@ -669,7 +646,7 @@ async function probeHoverSurfaces(
|
|||||||
try {
|
try {
|
||||||
await clearHover(cdp, tabId);
|
await clearHover(cdp, tabId);
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
await wait(HOVER_SETTLE_MS, options.signal);
|
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
||||||
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
expression: hoverStateExpression(),
|
expression: hoverStateExpression(),
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
@@ -684,7 +661,7 @@ async function probeHoverSurfaces(
|
|||||||
y: candidate.y,
|
y: candidate.y,
|
||||||
});
|
});
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
await wait(HOVER_SETTLE_MS, options.signal);
|
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
||||||
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
expression: hoverStateExpression(),
|
expression: hoverStateExpression(),
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
@@ -817,6 +794,8 @@ function parseDocumentNodes(
|
|||||||
const posCol = STYLE_COL.position;
|
const posCol = STYLE_COL.position;
|
||||||
const peCol = STYLE_COL["pointer-events"];
|
const peCol = STYLE_COL["pointer-events"];
|
||||||
const cursorCol = STYLE_COL.cursor;
|
const cursorCol = STYLE_COL.cursor;
|
||||||
|
const visibilityCol = STYLE_COL.visibility;
|
||||||
|
const opacityCol = STYLE_COL.opacity;
|
||||||
const inputValues = sparseIndexMap(dn.inputValue);
|
const inputValues = sparseIndexMap(dn.inputValue);
|
||||||
const textValues = sparseIndexMap(dn.textValue);
|
const textValues = sparseIndexMap(dn.textValue);
|
||||||
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
|
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
|
||||||
@@ -861,6 +840,7 @@ function parseDocumentNodes(
|
|||||||
let position = "static";
|
let position = "static";
|
||||||
let pointerEvents = "auto";
|
let pointerEvents = "auto";
|
||||||
let cursor = "auto";
|
let cursor = "auto";
|
||||||
|
let rendered = false;
|
||||||
const li = layoutByNode.get(n);
|
const li = layoutByNode.get(n);
|
||||||
if (li !== undefined) {
|
if (li !== undefined) {
|
||||||
const b = dl?.bounds?.[li];
|
const b = dl?.bounds?.[li];
|
||||||
@@ -881,6 +861,13 @@ function parseDocumentNodes(
|
|||||||
position = str(strings, styleRow[posCol]) || "static";
|
position = str(strings, styleRow[posCol]) || "static";
|
||||||
pointerEvents = str(strings, styleRow[peCol]) || "auto";
|
pointerEvents = str(strings, styleRow[peCol]) || "auto";
|
||||||
cursor = str(strings, styleRow[cursorCol]) || "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
|
// Skip non-element nodes (#text, #cdata-section, etc.) — they carry no
|
||||||
@@ -916,6 +903,7 @@ function parseDocumentNodes(
|
|||||||
position,
|
position,
|
||||||
pointerEvents,
|
pointerEvents,
|
||||||
cursor,
|
cursor,
|
||||||
|
rendered,
|
||||||
textContent,
|
textContent,
|
||||||
...(formValue !== undefined ? { formValue } : {}),
|
...(formValue !== undefined ? { formValue } : {}),
|
||||||
...(tag === "input" || tag === "textarea"
|
...(tag === "input" || tag === "textarea"
|
||||||
@@ -1020,7 +1008,13 @@ export async function captureViewModel(
|
|||||||
options: CaptureViewModelOptions = {},
|
options: CaptureViewModelOptions = {},
|
||||||
): Promise<CapturedViewModel> {
|
): Promise<CapturedViewModel> {
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
const metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
|
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);
|
||||||
|
}
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
const dpr = devicePixelRatio(metrics);
|
const dpr = devicePixelRatio(metrics);
|
||||||
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
||||||
|
|||||||
@@ -33,25 +33,19 @@ async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFra
|
|||||||
|
|
||||||
function transformFrameNodes(
|
function transformFrameNodes(
|
||||||
nodes: CapturedNode[],
|
nodes: CapturedNode[],
|
||||||
projection: GeometryProjection,
|
projection: GeometryProjection | null,
|
||||||
): CapturedNode[] {
|
): CapturedNode[] {
|
||||||
return nodes.map((node) => ({
|
return nodes.map((node) => ({
|
||||||
...node,
|
...node,
|
||||||
rect: (() => {
|
rect: projection
|
||||||
const bounds = projectRectToViewport(node.rect, projection);
|
? (() => {
|
||||||
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
const bounds = projectRectToViewport(node.rect, projection);
|
||||||
})(),
|
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
||||||
|
})()
|
||||||
|
: 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(
|
async function captureMissingFrameDocuments(
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
@@ -65,14 +59,20 @@ async function captureMissingFrameDocuments(
|
|||||||
|
|
||||||
for (const frame of graph.frames) {
|
for (const frame of graph.frames) {
|
||||||
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
|
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
|
||||||
const owner = frameOwnerNode(captured, frame);
|
|
||||||
if (!owner?.rect || frame.ownerBackendNodeId === undefined) continue;
|
|
||||||
try {
|
try {
|
||||||
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
|
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
const projection = await resolveFrameProjection(cdp, graph, frame.frameId);
|
let projection: GeometryProjection | null = null;
|
||||||
if (!projection) continue;
|
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 rootFrameId = child.rootFrameId ?? frame.frameId;
|
const rootFrameId = child.rootFrameId ?? frame.frameId;
|
||||||
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
|
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
|
||||||
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
|
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
|
||||||
@@ -85,11 +85,13 @@ async function captureMissingFrameDocuments(
|
|||||||
for (const [childFrameId, nodes] of childFrames) {
|
for (const [childFrameId, nodes] of childFrames) {
|
||||||
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
|
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
|
||||||
}
|
}
|
||||||
captured.iframeNodes.set(
|
if (frame.ownerBackendNodeId !== undefined) {
|
||||||
frame.ownerBackendNodeId,
|
captured.iframeNodes.set(
|
||||||
captured.frameNodes.get(frame.frameId) ?? [],
|
frame.ownerBackendNodeId,
|
||||||
);
|
captured.frameNodes.get(frame.frameId) ?? [],
|
||||||
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
);
|
||||||
|
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
||||||
|
}
|
||||||
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
|
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
|
||||||
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
|
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
|
||||||
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
|
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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.
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
||||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
|
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
|
||||||
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
||||||
|
|
||||||
## Mandatory workflow
|
## Mandatory workflow
|
||||||
@@ -299,7 +299,7 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
|
|||||||
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
| `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 |
|
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
||||||
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
||||||
| `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`. |
|
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
|
||||||
|
|
||||||
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
||||||
|
|
||||||
|
|||||||
@@ -351,10 +351,10 @@ fn check_version_compatible(status: Option<&StatusResult>) -> CheckResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `bsk doctor` check: connected browsers should speak a protocol the
|
/// `bsk doctor` check: every connected browser should use the same
|
||||||
/// daemon accepts. A different protocol string is still a live
|
/// protocol version as the daemon. Minor protocol drift is accepted by
|
||||||
/// connection — report Ok so agents keep working. The detail is an
|
/// the daemon but flagged here so the user can see who needs updating
|
||||||
/// upgrade reminder, not a blocker.
|
/// (M10.4).
|
||||||
///
|
///
|
||||||
/// Review M2 (round-1 minor): when no browsers are connected, the
|
/// Review M2 (round-1 minor): when no browsers are connected, the
|
||||||
/// check has nothing to compare against, so it now reports
|
/// check has nothing to compare against, so it now reports
|
||||||
@@ -396,12 +396,13 @@ fn check_browsers_protocol_compatible(status: Option<&StatusResult>) -> CheckRes
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
CheckResult::ok(
|
CheckResult::fail(
|
||||||
name,
|
name,
|
||||||
format!(
|
format!(
|
||||||
"{} browser(s) report a different protocol version (still usable — continue, and upgrade soon): {stale}",
|
"{} browser(s) have protocol minor drift from the daemon: {stale}",
|
||||||
status.version_skew_browsers.len()
|
status.version_skew_browsers.len()
|
||||||
),
|
),
|
||||||
|
"upgrade the browser-skill extension or bsk CLI so both sides use the same protocol version",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +581,7 @@ mod m2_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browsers_check_reports_ok_when_skew_present() {
|
fn browsers_check_reports_fail_when_skew_present() {
|
||||||
let status = fake_status(
|
let status = fake_status(
|
||||||
vec![BrowserStatusEntry {
|
vec![BrowserStatusEntry {
|
||||||
instance_id: "alpha".into(),
|
instance_id: "alpha".into(),
|
||||||
@@ -604,17 +605,9 @@ mod m2_tests {
|
|||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
let check = check_browsers_protocol_compatible(Some(&status));
|
let check = check_browsers_protocol_compatible(Some(&status));
|
||||||
assert_eq!(check.status, CheckStatus::Ok);
|
assert_eq!(check.status, CheckStatus::Fail);
|
||||||
assert!(check.detail.contains("alpha"));
|
assert!(check.detail.contains("alpha"));
|
||||||
assert!(
|
assert!(check.hint.is_some());
|
||||||
check
|
|
||||||
.detail
|
|
||||||
.contains("still usable — continue, and upgrade soon")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
check.hint.is_none(),
|
|
||||||
"a protocol-version note must not hint to stop"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -642,7 +635,7 @@ mod m2_tests {
|
|||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
let check = check_browsers_protocol_compatible(Some(&status));
|
let check = check_browsers_protocol_compatible(Some(&status));
|
||||||
assert_eq!(check.status, CheckStatus::Ok);
|
assert_eq!(check.status, CheckStatus::Fail);
|
||||||
assert!(
|
assert!(
|
||||||
check
|
check
|
||||||
.detail
|
.detail
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ fn render_human(s: &StatusResult) {
|
|||||||
let bold = "\x1b[1m";
|
let bold = "\x1b[1m";
|
||||||
let reset = "\x1b[0m";
|
let reset = "\x1b[0m";
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{bold}{yellow}note:{reset} {} browser(s) report a different protocol version; still usable — continue, and run `bsk update` soon{reset}",
|
"{bold}{yellow}warning:{reset} {} browser(s) have protocol version drift from the daemon{reset}",
|
||||||
s.version_skew_browsers.len()
|
s.version_skew_browsers.len()
|
||||||
);
|
);
|
||||||
for skew in &s.version_skew_browsers {
|
for skew in &s.version_skew_browsers {
|
||||||
@@ -75,7 +75,7 @@ fn render_human(s: &StatusResult) {
|
|||||||
skew.label.clone()
|
skew.label.clone()
|
||||||
};
|
};
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {} ({}) — protocol ext {} vs CLI {} (app ext v{}, CLI v{}) — still usable; please upgrade",
|
" {} ({}) — protocol ext {} vs daemon {} (app ext v{}, daemon v{}) — please align protocol versions",
|
||||||
skew.instance_id,
|
skew.instance_id,
|
||||||
label,
|
label,
|
||||||
display_protocol(&skew.client_protocol_version),
|
display_protocol(&skew.client_protocol_version),
|
||||||
|
|||||||
@@ -20,43 +20,22 @@ extension keep owning the actual browser control — this package is a thin, wel
|
|||||||
| `browser_snapshot` | `bsk snapshot` | Indented aria-tree snapshot with `@eN` refs for interaction tools. |
|
| `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_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_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_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_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_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_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
|
|
||||||
DeepSeek Harness lifecycle before it can be added safely.
|
|
||||||
|
|
||||||
## Agent skill (progressive disclosure)
|
## Agent skill (progressive disclosure)
|
||||||
|
|
||||||
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
|
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
|
||||||
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
|
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
|
||||||
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
|
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
|
||||||
tool. Its single source is the DSH-specific `skill/SKILL.md`, which documents only structured
|
tool. The body is assembled at build time from two parts, so there is exactly one source of
|
||||||
`browser_*` calls and their plugin semantics. The repository-root CLI skill is intentionally not
|
truth: a dsh-specific prelude (`skill/prelude.md` — tool↔CLI map, owned-session semantics,
|
||||||
concatenated: its command examples belong to a different execution interface and would bypass
|
plugin-only overrides) followed verbatim by the canonical CLI skill (`skill/SKILL.md` at the
|
||||||
the plugin's ownership, live observation UI, cancellation, and cleanup path if followed directly.
|
repo root — the same file `crates/bsk-cli/build.rs` mirrors for the CLI package; workflows,
|
||||||
The build rejects internal CLI-name leakage, command-line code blocks, unknown browser tools, and
|
stop-when-done rules, refs usage, sandbox rules). Registration and every pre-step catalog
|
||||||
missing supported browser tools before embedding the Markdown. Registration and every pre-step
|
snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
|
||||||
catalog snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
|
|
||||||
seam degrade silently.
|
seam degrade silently.
|
||||||
|
|
||||||
## Multi-session model
|
## Multi-session model
|
||||||
@@ -109,7 +88,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: true # reveal browser_* tools only after the skill is invoked
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the 28
|
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the eleven
|
||||||
`browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the
|
`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
|
`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
|
advertisement. One successful invocation (model tool call, or a `/browser-skill` user
|
||||||
@@ -173,7 +152,7 @@ shell's theme cannot bleed back in.
|
|||||||
card). Screenshots additionally attach the image itself when the host mounts an attachment store
|
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.
|
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
|
- **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_screenshot`. The
|
`lib/client.js`, 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
|
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
|
image block, resolves the durable attachment through the client session's authorized
|
||||||
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
||||||
@@ -204,8 +183,8 @@ as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id stays
|
|||||||
Trigger it by pushing a tag that matches `package.json`'s `version`:
|
Trigger it by pushing a tag that matches `package.json`'s `version`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git tag dsh-plugin-v0.1.2
|
git tag dsh-plugin-v0.1.0
|
||||||
git push origin dsh-plugin-v0.1.2
|
git push origin dsh-plugin-v0.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads the
|
Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads the
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
|
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
|
||||||
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill browser automation (browser_* tools) to the model",
|
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill (bsk) browser automation to the model",
|
||||||
"version": "0.1.2",
|
"version": "0.1.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
// Embed the DeepSeek Harness-specific browser skill at build time. The model-facing
|
// Assemble the plugin's skill content: a dsh-specific routing prelude, the
|
||||||
// skill deliberately documents only injected browser_* tools; the repository's CLI
|
// canonical CLI skill body verbatim (repo-root skill/SKILL.md — the same source
|
||||||
// skill is a separate interface and must never be concatenated here.
|
// crates/bsk-cli's build.rs mirrors), then a dsh routing reminder postlude. The
|
||||||
|
// original's YAML frontmatter is stripped: name/description are supplied by the
|
||||||
|
// registration.
|
||||||
// Output: src/skill-content.generated.ts (gitignored, built before tsdown and
|
// Output: src/skill-content.generated.ts (gitignored, built before tsdown and
|
||||||
// before vitest), so registration is a pure in-memory read.
|
// before vitest) so the markdown ships inlined in the bundle and registration
|
||||||
|
// is a pure in-memory read — never a per-step disk access.
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
const pkg = join(here, "..");
|
const pkg = join(here, "..");
|
||||||
const source = readFileSync(join(pkg, "skill", "SKILL.md"), "utf8");
|
const prelude = readFileSync(join(pkg, "skill", "prelude.md"), "utf8");
|
||||||
|
const upstream = readFileSync(join(pkg, "..", "..", "skill", "SKILL.md"), "utf8");
|
||||||
|
const postlude = readFileSync(join(pkg, "skill", "postlude.md"), "utf8");
|
||||||
|
|
||||||
function stripFrontmatter(markdown) {
|
function stripFrontmatter(markdown) {
|
||||||
if (!markdown.startsWith("---\n")) return markdown;
|
if (!markdown.startsWith("---\n")) return markdown;
|
||||||
@@ -17,69 +22,18 @@ function stripFrontmatter(markdown) {
|
|||||||
return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\s*\n/, "");
|
return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\s*\n/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUPPORTED_BROWSER_TOOLS = [
|
|
||||||
"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) {
|
|
||||||
if (/\bbsk\b/i.test(markdown)) {
|
|
||||||
throw new Error("DSH skill must not expose the internal CLI name");
|
|
||||||
}
|
|
||||||
if (/```(?:bash|sh|shell)\b/i.test(markdown)) {
|
|
||||||
throw new Error("DSH skill must not contain command-line code blocks");
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentioned = new Set(markdown.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? []);
|
|
||||||
const supported = new Set(SUPPORTED_BROWSER_TOOLS);
|
|
||||||
const unknown = [...mentioned].filter((name) => !supported.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(", ")}`);
|
|
||||||
}
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new Error(`DSH skill omits supported browser tools: ${missing.join(", ")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const NAME = "browser-skill";
|
const NAME = "browser-skill";
|
||||||
const DESCRIPTION =
|
const DESCRIPTION =
|
||||||
"Browser automation against the user's logged-in Chromium through this plugin's injected " +
|
"Browser automation against the user's logged-in Chromium through this plugin's injected browser_* tools; never invoke bsk through bash or shell. " +
|
||||||
"browser_* tools. Use to visit and read pages, fill forms, click through flows, inspect tabs, " +
|
"tools (bsk engine): visit and read pages, fill forms, click through flows, scrape data, " +
|
||||||
"debug page activity, or smoke-test UI changes in a managed Agent Window.";
|
"smoke-test UI changes. Use when a task needs a real browser; requires the bsk CLI and the " +
|
||||||
|
"browser-skill extension connected.";
|
||||||
|
|
||||||
const content = `${stripFrontmatter(source).trim()}\n`;
|
const content = `${prelude.trim()}\n\n${stripFrontmatter(upstream).trim()}\n\n${postlude.trim()}\n`;
|
||||||
validateModelFacingSkill(content);
|
|
||||||
|
|
||||||
const banner =
|
const banner =
|
||||||
"// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" +
|
"// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" +
|
||||||
"// Source: skill/SKILL.md (DeepSeek Harness browser_* tool instructions).\n";
|
"// Sources: skill/prelude.md + ../../../skill/SKILL.md + skill/postlude.md.\n";
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(pkg, "src", "skill-content.generated.ts"),
|
join(pkg, "src", "skill-content.generated.ts"),
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
---
|
|
||||||
name: browser-skill
|
|
||||||
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 structured `browser_*` tools provided by this
|
|
||||||
plugin. The browser keeps the user's existing logins and cookies while isolating automation in an
|
|
||||||
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. If the tools were
|
|
||||||
hidden before this skill was loaded, start browser work only after the skill invocation succeeds.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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_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 tool-call sequence:
|
|
||||||
|
|
||||||
```text
|
|
||||||
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 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 a bounded task, not open-ended browsing.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Core interaction loop
|
|
||||||
|
|
||||||
Use `browser_navigate` to open a destination in the active Agent Window tab, then inspect the page
|
|
||||||
before interacting:
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Use interaction tools according to their schemas:
|
|
||||||
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Observation and page reading priority
|
|
||||||
|
|
||||||
Escalate page reading only as needed:
|
|
||||||
|
|
||||||
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. Screenshots may
|
|
||||||
return an inline image or a saved path depending on the deployment.
|
|
||||||
|
|
||||||
## Tabs and the Agent Window boundary
|
|
||||||
|
|
||||||
Write actions normally affect only Agent Window tabs.
|
|
||||||
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
Use only a `tabId` returned by `browser_tab_list` or `browser_tab_create`. Do not assume a tab id
|
|
||||||
from page position or title.
|
|
||||||
|
|
||||||
## Navigation and lifecycle
|
|
||||||
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
After navigation, history traversal, or reload, discard old refs and call `browser_observe` or
|
|
||||||
`browser_snapshot` again.
|
|
||||||
|
|
||||||
## Ask the user for browser help
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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 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 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.
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
## DSH routing reminder
|
||||||
|
|
||||||
|
The CLI-oriented reference above describes behavior, not the execution mechanism in
|
||||||
|
DeepSeek Harness. For every browser operation, call the injected `browser_*` model
|
||||||
|
tool. Do not translate these examples into `bash` commands and do not execute `bsk`
|
||||||
|
directly; the plugin invokes `bsk` internally. In particular, always begin with
|
||||||
|
`browser_session_start`, pass its returned session id to subsequent `browser_*`
|
||||||
|
tools, and finish with `browser_session_stop`.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# browser-skill (dsh edition)
|
||||||
|
|
||||||
|
## DSH tool-routing requirement (highest priority)
|
||||||
|
|
||||||
|
This skill is running inside DeepSeek Harness, where the plugin has injected
|
||||||
|
structured `browser_*` model tools. **Always call those injected `browser_*` tools
|
||||||
|
for browser work. Never use `bash`, `shell`, `exec`, or another tool to invoke the
|
||||||
|
`bsk` CLI directly.** The CLI command names shown below are only conceptual
|
||||||
|
mappings for understanding the tools; they are not instructions to run commands.
|
||||||
|
If a `browser_*` tool is available, using the CLI directly is incorrect because it
|
||||||
|
bypasses plugin session ownership, observation UI, tool cards, and cleanup.
|
||||||
|
|
||||||
|
Use the injected tool whose name matches the operation, for example:
|
||||||
|
`browser_session_start`, `browser_navigate`, `browser_observe`, `browser_snapshot`,
|
||||||
|
`browser_click`, `browser_fill`, `browser_press`, `browser_screenshot`, and
|
||||||
|
`browser_session_stop`. Pass arguments as the tool schema specifies (not CLI flags).
|
||||||
|
|
||||||
|
Drive the user's **real Chromium browser** (logins, cookies, Agent Window isolation)
|
||||||
|
through this plugin's `browser_*` tools. Each injected tool is implemented by the
|
||||||
|
plugin using the `bsk` engine internally — the model must not invoke that engine
|
||||||
|
itself. The workflow, judgment rules, and safety constraints remain identical to
|
||||||
|
the canonical CLI skill.
|
||||||
|
|
||||||
|
> **Tool availability**: invoking this skill (which you just did) makes the plugin's
|
||||||
|
> `browser_*` tools available for the rest of the session — they are injected on
|
||||||
|
> successful skill load. Until then they are deliberately hidden, so do not attempt
|
||||||
|
> a browser action before loading this skill.
|
||||||
|
|
||||||
|
## Tool ↔ CLI map
|
||||||
|
|
||||||
|
The tools below are thin, structured wrappers over the bsk CLI verbs of the same
|
||||||
|
name; `session` parameters take the 4-letter session id (no `--session` flag
|
||||||
|
spelling here):
|
||||||
|
|
||||||
|
| dsh tool | bsk equivalent |
|
||||||
|
|---|---|
|
||||||
|
| `browser_session_start` / `browser_session_stop` / `browser_session_list` | `bsk session start/stop/list` |
|
||||||
|
| `browser_navigate` | `bsk navigate` |
|
||||||
|
| `browser_snapshot` / `browser_observe` | `bsk snapshot` / `bsk observe` |
|
||||||
|
| `browser_click` / `browser_fill` / `browser_press` | `bsk click` / `bsk fill` / `bsk press` |
|
||||||
|
| `browser_screenshot` | `bsk screenshot` |
|
||||||
|
| `browser_emulate` | `bsk emulate` |
|
||||||
|
|
||||||
|
(`bsk console` / `bsk network` have no tool counterpart in this plugin yet.)
|
||||||
|
|
||||||
|
Plugin-specific semantics that override or narrow the CLI rules below:
|
||||||
|
|
||||||
|
- **Owned sessions only**: this plugin sees and acts on just the sessions IT
|
||||||
|
started via `browser_session_start`. `browser_session_list` shows only those
|
||||||
|
(with the current marker); passing a session id created by another program
|
||||||
|
(or person) sharing the bsk daemon is refused outright — never retry those
|
||||||
|
with the same id.
|
||||||
|
- **Current session**: omitted `session` parameters resolve to the most
|
||||||
|
recently started or used OWNED session. With several sessions in flight,
|
||||||
|
pass `session` explicitly to avoid surprises.
|
||||||
|
- **Screenshots**: when the model route accepts images, `browser_screenshot`
|
||||||
|
returns the PNG inline as an image attachment; otherwise it returns the
|
||||||
|
saved file path.
|
||||||
|
- **`browser_emulate mobile`**: requires `width`+`height` — the bsk daemon
|
||||||
|
refuses `--mobile` without viewport dimensions.
|
||||||
|
- **Live observation**: in the dsh Web UI, every owned session is watched by
|
||||||
|
the floating overlay (thumbnails, action, interrupt) — observational side
|
||||||
|
traffic is free and never steals your command turn; just keep acting.
|
||||||
|
|
||||||
|
The canonical CLI skill body follows verbatim (source of truth: `skill/SKILL.md`
|
||||||
|
in the BrowserSkill repo, mirrored by build into `crates/bsk-cli/skill/SKILL.md`).
|
||||||
|
Read `bsk <verb> --session <id>` as "the `browser_<verb>` tool with
|
||||||
|
`session: <id>`"; everything else — when to use, the stop-when-done rules, the
|
||||||
|
interaction loop, refs, sandbox rules — applies unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Lazy tool-schema injection ("progressive disclosure", final stage): with
|
* Lazy tool-schema injection ("progressive disclosure", final stage): with
|
||||||
* `lazyTools` on (the default), the 28 browser_* tool schemas stay OUT of the
|
* `lazyTools` on (the default), the 11 browser_* tool schemas stay OUT of the
|
||||||
* system prompt until the `browser-skill` skill has actually been invoked —
|
* system prompt until the `browser-skill` skill has actually been invoked —
|
||||||
* the skill catalog entry is the only advertisement. One successful
|
* the skill catalog entry is the only advertisement. One successful
|
||||||
* invocation (model tool call, or a user's `/browser-skill` gesture) reveals
|
* 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
|
* Arm the lazy reveal. Returns a disposer tearing down listeners and — when
|
||||||
* the reveal already happened — the tool suite itself.
|
* the reveal already happened — the tool suite itself.
|
||||||
* @param registerSuite - registers the 28 browser_* tools, returns their disposer.
|
* @param registerSuite - registers the 11 browser_* tools, returns their disposer.
|
||||||
*/
|
*/
|
||||||
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
|
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
|
||||||
let suiteDisposer: (() => void) | undefined;
|
let suiteDisposer: (() => void) | undefined;
|
||||||
|
|||||||
@@ -439,48 +439,14 @@ export function actionForLabel(label: string): string {
|
|||||||
return "observing";
|
return "observing";
|
||||||
case "click":
|
case "click":
|
||||||
return "clicking";
|
return "clicking";
|
||||||
case "hover":
|
|
||||||
return "hovering";
|
|
||||||
case "fill":
|
case "fill":
|
||||||
return "filling";
|
return "filling";
|
||||||
case "select":
|
|
||||||
return "selecting";
|
|
||||||
case "press":
|
case "press":
|
||||||
return "pressing";
|
return "pressing";
|
||||||
case "screenshot":
|
case "screenshot":
|
||||||
return "capturing";
|
return "capturing";
|
||||||
case "emulate":
|
case "emulate":
|
||||||
return "emulating";
|
return "emulating";
|
||||||
case "tab list":
|
|
||||||
return "listing tabs";
|
|
||||||
case "tab create":
|
|
||||||
return "creating tab";
|
|
||||||
case "tab close":
|
|
||||||
return "closing tab";
|
|
||||||
case "tab select":
|
|
||||||
return "selecting tab";
|
|
||||||
case "tab borrow":
|
|
||||||
return "borrowing tab";
|
|
||||||
case "tab return":
|
|
||||||
return "returning tab";
|
|
||||||
case "navigate-back":
|
|
||||||
return "navigating back";
|
|
||||||
case "navigate-forward":
|
|
||||||
return "navigating forward";
|
|
||||||
case "reload":
|
|
||||||
return "reloading";
|
|
||||||
case "wait-for-navigation":
|
|
||||||
return "waiting for navigation";
|
|
||||||
case "request-help":
|
|
||||||
return "waiting for user";
|
|
||||||
case "get-html":
|
|
||||||
return "reading HTML";
|
|
||||||
case "console":
|
|
||||||
return "reading console";
|
|
||||||
case "network":
|
|
||||||
return "reading network";
|
|
||||||
case "window resize":
|
|
||||||
return "resizing window";
|
|
||||||
default:
|
default:
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { ToolDefinition, ToolResult, ToolRunContext } from "@deepseek-ai/dsh-tools";
|
|
||||||
import type { ToolDeps } from "./tools";
|
|
||||||
|
|
||||||
export type ToolRegistrar = (definition: ToolDefinition) => () => void;
|
|
||||||
|
|
||||||
type TerminalPresentation = { card: "terminal"; output: string; exitCode: number } | undefined;
|
|
||||||
|
|
||||||
/** Existing tool runtime seams reused by the phase-one capability modules. */
|
|
||||||
export interface PhaseOneRuntime {
|
|
||||||
run(
|
|
||||||
exec: ToolRunContext,
|
|
||||||
args: string[],
|
|
||||||
label: string,
|
|
||||||
observeSession?: string,
|
|
||||||
runnerTimeoutMs?: number,
|
|
||||||
): Promise<unknown>;
|
|
||||||
commandLine(args: string[]): string;
|
|
||||||
presentTerminalResult: (_args: never, result: ToolResult) => TerminalPresentation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireNonEmpty(value: string, name: string): void {
|
|
||||||
if (value.trim().length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requirePositive(value: number | undefined, name: string): void {
|
|
||||||
if (value !== undefined && value <= 0) throw new Error(`${name} must be greater than zero`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CLI positional target detection is shared by hover/select/request-help. */
|
|
||||||
export function isSnapshotRef(target: string): boolean {
|
|
||||||
return /^@?e\d+$/.test(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Give the child enough time to honour a command-level timeout plus IPC
|
|
||||||
* settlement slack, without shortening the plugin's configured default.
|
|
||||||
*/
|
|
||||||
export function runnerTimeout(deps: ToolDeps, commandTimeoutMs: number | undefined): number {
|
|
||||||
if (commandTimeoutMs === undefined) return deps.config.defaultTimeoutMs;
|
|
||||||
return Math.max(deps.config.defaultTimeoutMs, commandTimeoutMs + 15_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendTarget(args: string[], target: string): void {
|
|
||||||
args.push(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendTabId(args: string[], tabId: number | undefined): void {
|
|
||||||
if (tabId !== undefined) args.push("--tab-id", String(tabId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendWaitOptions(
|
|
||||||
args: string[],
|
|
||||||
waitUntil: string | undefined,
|
|
||||||
timeoutMs: number | undefined,
|
|
||||||
): void {
|
|
||||||
if (waitUntil !== undefined) args.push("--wait-until", waitUntil);
|
|
||||||
if (timeoutMs !== undefined) args.push("--timeout", `${timeoutMs}ms`);
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
||||||
import {
|
|
||||||
appendTabId,
|
|
||||||
appendTarget,
|
|
||||||
type PhaseOneRuntime,
|
|
||||||
requireNonEmpty,
|
|
||||||
requirePositive,
|
|
||||||
runnerTimeout,
|
|
||||||
type ToolRegistrar,
|
|
||||||
} from "./phase-one-runtime";
|
|
||||||
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM } from "./tool-params";
|
|
||||||
import type { ToolDeps } from "./tools";
|
|
||||||
|
|
||||||
const MODIFIERS = ["alt", "ctrl", "meta", "shift"] as const;
|
|
||||||
|
|
||||||
/** Add hover and select without bypassing session ownership or observation. */
|
|
||||||
export function registerPhaseOneInteractionTools(
|
|
||||||
deps: ToolDeps,
|
|
||||||
register: ToolRegistrar,
|
|
||||||
runtime: PhaseOneRuntime,
|
|
||||||
): void {
|
|
||||||
const { registry } = deps;
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
name: "browser_hover",
|
|
||||||
description:
|
|
||||||
"Move the mouse over a snapshot ref or CSS selector to reveal hover-triggered UI. " +
|
|
||||||
"Run browser_observe or browser_snapshot again afterwards to discover newly visible refs.",
|
|
||||||
parameters: {
|
|
||||||
target: {
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
description: "Snapshot ref (@e3 / e3) or CSS selector of the element to hover.",
|
|
||||||
},
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
tabId: TAB_ID_PARAM,
|
|
||||||
modifiers: {
|
|
||||||
type: "array",
|
|
||||||
items: { type: "string", enum: MODIFIERS },
|
|
||||||
description: "Keyboard modifiers held during the mouse move.",
|
|
||||||
},
|
|
||||||
settleMs: {
|
|
||||||
type: "integer",
|
|
||||||
description: "Milliseconds to wait for hover-triggered UI to settle (default: 200).",
|
|
||||||
},
|
|
||||||
timeoutMs: TIMEOUT_MS_PARAM,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
x: { type: "number", required: true },
|
|
||||||
y: { type: "number", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `[session ${value.session}] hovered at (${value.x}, ${value.y}) on tab ${value.tabId}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
requireNonEmpty(args.target, "target");
|
|
||||||
requirePositive(args.timeoutMs, "timeoutMs");
|
|
||||||
requirePositive(args.settleMs, "settleMs");
|
|
||||||
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) {
|
|
||||||
cmdArgs.push("--modifiers", args.modifiers.join(","));
|
|
||||||
}
|
|
||||||
if (args.settleMs !== undefined) cmdArgs.push("--settle", `${args.settleMs}ms`);
|
|
||||||
if (args.timeoutMs !== undefined) cmdArgs.push("--timeout", `${args.timeoutMs}ms`);
|
|
||||||
appendTarget(cmdArgs, args.target);
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
cmdArgs,
|
|
||||||
"hover",
|
|
||||||
sessionId,
|
|
||||||
runnerTimeout(deps, args.timeoutMs),
|
|
||||||
)) as { tab_id: number; x: number; y: number };
|
|
||||||
return { session: sessionId, tabId: reply.tab_id, x: reply.x, y: reply.y };
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"hover",
|
|
||||||
args.target,
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
]),
|
|
||||||
description: "Hover an element",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
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.",
|
|
||||||
parameters: {
|
|
||||||
target: {
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
description: "Snapshot ref (@e3 / e3) or CSS selector of the select element.",
|
|
||||||
},
|
|
||||||
values: {
|
|
||||||
type: "array",
|
|
||||||
required: true,
|
|
||||||
items: { type: "string" },
|
|
||||||
description: "Option value attributes to select; at least one value is required.",
|
|
||||||
},
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
tabId: TAB_ID_PARAM,
|
|
||||||
timeoutMs: TIMEOUT_MS_PARAM,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
multiple: { type: "boolean", required: true },
|
|
||||||
selectedValues: {
|
|
||||||
type: "array",
|
|
||||||
required: true,
|
|
||||||
items: { type: "string" },
|
|
||||||
},
|
|
||||||
selectedLabels: {
|
|
||||||
type: "array",
|
|
||||||
required: true,
|
|
||||||
items: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
`[session ${value.session}] selected ` +
|
|
||||||
`${value.selectedValues.join(", ") || "(none)"} on tab ${value.tabId}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
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_select");
|
|
||||||
const cmdArgs = ["select", "--session", sessionId];
|
|
||||||
appendTabId(cmdArgs, args.tabId);
|
|
||||||
for (const value of args.values) cmdArgs.push("--value", value);
|
|
||||||
if (args.timeoutMs !== undefined) cmdArgs.push("--timeout", `${args.timeoutMs}ms`);
|
|
||||||
appendTarget(cmdArgs, args.target);
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
cmdArgs,
|
|
||||||
"select",
|
|
||||||
sessionId,
|
|
||||||
runnerTimeout(deps, args.timeoutMs),
|
|
||||||
)) as {
|
|
||||||
tab_id: number;
|
|
||||||
multiple: boolean;
|
|
||||||
selected_values: string[];
|
|
||||||
selected_labels: string[];
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
multiple: reply.multiple,
|
|
||||||
selectedValues: reply.selected_values,
|
|
||||||
selectedLabels: reply.selected_labels,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"select",
|
|
||||||
args.target,
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
]),
|
|
||||||
description: `Select ${args.values.length} option${args.values.length === 1 ? "" : "s"}`,
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
||||||
import {
|
|
||||||
appendTabId,
|
|
||||||
appendWaitOptions,
|
|
||||||
type PhaseOneRuntime,
|
|
||||||
requirePositive,
|
|
||||||
runnerTimeout,
|
|
||||||
type ToolRegistrar,
|
|
||||||
} from "./phase-one-runtime";
|
|
||||||
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM, WAIT_UNTIL_PARAM } from "./tool-params";
|
|
||||||
import type { ToolDeps } from "./tools";
|
|
||||||
|
|
||||||
interface RawHistoryResult {
|
|
||||||
tab_id: number;
|
|
||||||
previous_url?: string;
|
|
||||||
final_url?: string;
|
|
||||||
reached: string;
|
|
||||||
error_text?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HISTORY_OUTPUT = {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
previousUrl: { type: "string" },
|
|
||||||
finalUrl: { type: "string" },
|
|
||||||
reached: { type: "string", required: true },
|
|
||||||
errorText: { type: "string" },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function mapHistory(sessionId: string, reply: RawHistoryResult) {
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
...(reply.previous_url !== undefined ? { previousUrl: reply.previous_url } : {}),
|
|
||||||
...(reply.final_url !== undefined ? { finalUrl: reply.final_url } : {}),
|
|
||||||
reached: reply.reached,
|
|
||||||
...(reply.error_text !== undefined ? { errorText: reply.error_text } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add browser history, reload, and explicit lifecycle waiting. */
|
|
||||||
export function registerPhaseOneNavigationTools(
|
|
||||||
deps: ToolDeps,
|
|
||||||
register: ToolRegistrar,
|
|
||||||
runtime: PhaseOneRuntime,
|
|
||||||
): void {
|
|
||||||
const { registry } = deps;
|
|
||||||
|
|
||||||
const registerHistory = (direction: "back" | "forward") => {
|
|
||||||
const toolName = `browser_navigate_${direction}` as const;
|
|
||||||
const command = `navigate-${direction}`;
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
name: toolName,
|
|
||||||
description: `Navigate the active Agent Window tab ${direction} by one history entry.`,
|
|
||||||
parameters: {
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
tabId: TAB_ID_PARAM,
|
|
||||||
waitUntil: WAIT_UNTIL_PARAM,
|
|
||||||
timeoutMs: TIMEOUT_MS_PARAM,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: HISTORY_OUTPUT,
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
`[session ${value.session}] navigated ${direction} on tab ${value.tabId}` +
|
|
||||||
(value.finalUrl !== undefined ? ` to ${value.finalUrl}` : "") +
|
|
||||||
` (reached: ${value.reached})` +
|
|
||||||
(value.errorText !== undefined ? ` — ${value.errorText}` : ""),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
requirePositive(args.timeoutMs, "timeoutMs");
|
|
||||||
const sessionId = registry.resolve(args.session, toolName);
|
|
||||||
const cmdArgs = [command, "--session", sessionId];
|
|
||||||
appendTabId(cmdArgs, args.tabId);
|
|
||||||
appendWaitOptions(cmdArgs, args.waitUntil, args.timeoutMs);
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
cmdArgs,
|
|
||||||
command,
|
|
||||||
sessionId,
|
|
||||||
runnerTimeout(deps, args.timeoutMs),
|
|
||||||
)) as RawHistoryResult;
|
|
||||||
if (reply.final_url !== undefined) deps.observation.setUrl(sessionId, reply.final_url);
|
|
||||||
return mapHistory(sessionId, reply);
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([command, "--session", args.session ?? "(current)"]),
|
|
||||||
description: `Navigate ${direction}`,
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
registerHistory("back");
|
|
||||||
registerHistory("forward");
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
name: "browser_reload",
|
|
||||||
description:
|
|
||||||
"Reload the active Agent Window tab and wait for a lifecycle phase. Set hard=true to " +
|
|
||||||
"bypass the HTTP cache.",
|
|
||||||
parameters: {
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
tabId: TAB_ID_PARAM,
|
|
||||||
waitUntil: WAIT_UNTIL_PARAM,
|
|
||||||
timeoutMs: TIMEOUT_MS_PARAM,
|
|
||||||
hard: { type: "boolean", description: "Bypass the HTTP cache while reloading." },
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: HISTORY_OUTPUT,
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
`[session ${value.session}] reloaded tab ${value.tabId}` +
|
|
||||||
(value.finalUrl !== undefined ? ` at ${value.finalUrl}` : "") +
|
|
||||||
` (reached: ${value.reached})` +
|
|
||||||
(value.errorText !== undefined ? ` — ${value.errorText}` : ""),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
requirePositive(args.timeoutMs, "timeoutMs");
|
|
||||||
const sessionId = registry.resolve(args.session, "browser_reload");
|
|
||||||
const cmdArgs = ["reload", "--session", sessionId];
|
|
||||||
appendTabId(cmdArgs, args.tabId);
|
|
||||||
appendWaitOptions(cmdArgs, args.waitUntil, args.timeoutMs);
|
|
||||||
if (args.hard === true) cmdArgs.push("--hard");
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
cmdArgs,
|
|
||||||
"reload",
|
|
||||||
sessionId,
|
|
||||||
runnerTimeout(deps, args.timeoutMs),
|
|
||||||
)) as RawHistoryResult;
|
|
||||||
if (reply.final_url !== undefined) deps.observation.setUrl(sessionId, reply.final_url);
|
|
||||||
return mapHistory(sessionId, reply);
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"reload",
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
...(args.hard === true ? ["--hard"] : []),
|
|
||||||
]),
|
|
||||||
description: "Reload the active tab",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
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.",
|
|
||||||
parameters: {
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
tabId: TAB_ID_PARAM,
|
|
||||||
waitUntil: WAIT_UNTIL_PARAM,
|
|
||||||
timeoutMs: TIMEOUT_MS_PARAM,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
reached: { type: "string", required: true },
|
|
||||||
errorText: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
`[session ${value.session}] navigation wait on tab ${value.tabId}: ${value.reached}` +
|
|
||||||
(value.errorText !== undefined ? ` — ${value.errorText}` : ""),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
requirePositive(args.timeoutMs, "timeoutMs");
|
|
||||||
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);
|
|
||||||
if (args.waitUntil !== undefined) cmdArgs.push("--wait-until", args.waitUntil);
|
|
||||||
cmdArgs.push("--timeout", `${timeoutMs}ms`);
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
cmdArgs,
|
|
||||||
"wait-for-navigation",
|
|
||||||
sessionId,
|
|
||||||
runnerTimeout(deps, timeoutMs),
|
|
||||||
)) as { tab_id: number; reached: string; error_text?: string };
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
reached: reply.reached,
|
|
||||||
...(reply.error_text !== undefined ? { errorText: reply.error_text } : {}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"wait-for-navigation",
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
]),
|
|
||||||
description: "Wait for page navigation",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,672 +0,0 @@
|
|||||||
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
||||||
import {
|
|
||||||
appendTabId,
|
|
||||||
isSnapshotRef,
|
|
||||||
type PhaseOneRuntime,
|
|
||||||
requireNonEmpty,
|
|
||||||
requirePositive,
|
|
||||||
runnerTimeout,
|
|
||||||
type ToolRegistrar,
|
|
||||||
} from "./phase-one-runtime";
|
|
||||||
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM } from "./tool-params";
|
|
||||||
import type { ToolDeps } from "./tools";
|
|
||||||
|
|
||||||
interface HelpConditionInput {
|
|
||||||
urlContains?: string;
|
|
||||||
urlMatches?: string;
|
|
||||||
selectorExists?: string;
|
|
||||||
selectorMissing?: string;
|
|
||||||
textExists?: string;
|
|
||||||
textMissing?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompletionCriteriaInput {
|
|
||||||
any?: HelpConditionInput[];
|
|
||||||
all?: HelpConditionInput[];
|
|
||||||
stableForMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawConsoleFrame {
|
|
||||||
function_name?: string;
|
|
||||||
url?: string;
|
|
||||||
line?: number;
|
|
||||||
column?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawConsoleEntry {
|
|
||||||
sequence: number;
|
|
||||||
kind: string;
|
|
||||||
level: string;
|
|
||||||
text: string;
|
|
||||||
url?: string;
|
|
||||||
line?: number;
|
|
||||||
column?: number;
|
|
||||||
timestamp?: number;
|
|
||||||
stack_trace?: RawConsoleFrame[];
|
|
||||||
truncated?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawNetworkEntry {
|
|
||||||
sequence: number;
|
|
||||||
kind: "response" | "failure";
|
|
||||||
method?: string;
|
|
||||||
url?: string;
|
|
||||||
status?: number;
|
|
||||||
status_text?: string;
|
|
||||||
mime_type?: string;
|
|
||||||
resource_type?: string;
|
|
||||||
error_text?: string;
|
|
||||||
timestamp?: number;
|
|
||||||
truncated?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HELP_CONDITION_SCHEMA = {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
urlContains: { type: "string" },
|
|
||||||
urlMatches: { type: "string" },
|
|
||||||
selectorExists: { type: "string" },
|
|
||||||
selectorMissing: { type: "string" },
|
|
||||||
textExists: { type: "string" },
|
|
||||||
textMissing: { type: "string" },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const DEBUG_PARAMETERS = {
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
tabId: TAB_ID_PARAM,
|
|
||||||
since: {
|
|
||||||
type: "integer",
|
|
||||||
description: "Return entries with a sequence strictly greater than this cursor.",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "integer",
|
|
||||||
description: "Maximum entries to return (default: 50, daemon cap: 200).",
|
|
||||||
},
|
|
||||||
maxTextChars: {
|
|
||||||
type: "integer",
|
|
||||||
description: "Maximum characters per entry text/URL (default: 1000, daemon cap: 4096).",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function mapCondition(condition: HelpConditionInput) {
|
|
||||||
return {
|
|
||||||
...(condition.urlContains !== undefined ? { url_contains: condition.urlContains } : {}),
|
|
||||||
...(condition.urlMatches !== undefined ? { url_matches: condition.urlMatches } : {}),
|
|
||||||
...(condition.selectorExists !== undefined
|
|
||||||
? { selector_exists: condition.selectorExists }
|
|
||||||
: {}),
|
|
||||||
...(condition.selectorMissing !== undefined
|
|
||||||
? { selector_missing: condition.selectorMissing }
|
|
||||||
: {}),
|
|
||||||
...(condition.textExists !== undefined ? { text_exists: condition.textExists } : {}),
|
|
||||||
...(condition.textMissing !== undefined ? { text_missing: condition.textMissing } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapCompletionCriteria(criteria: CompletionCriteriaInput) {
|
|
||||||
return {
|
|
||||||
...(criteria.any !== undefined ? { any: criteria.any.map(mapCondition) } : {}),
|
|
||||||
...(criteria.all !== undefined ? { all: criteria.all.map(mapCondition) } : {}),
|
|
||||||
...(criteria.stableForMs !== undefined ? { stable_for_ms: criteria.stableForMs } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateDebugArgs(args: { since?: number; limit?: number; maxTextChars?: number }): void {
|
|
||||||
if (args.since !== undefined && args.since < 0) {
|
|
||||||
throw new Error("since must be zero or greater");
|
|
||||||
}
|
|
||||||
requirePositive(args.limit, "limit");
|
|
||||||
requirePositive(args.maxTextChars, "maxTextChars");
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendDebugOptions(
|
|
||||||
args: string[],
|
|
||||||
options: { tabId?: number; since?: number; limit?: number; maxTextChars?: number },
|
|
||||||
): void {
|
|
||||||
appendTabId(args, options.tabId);
|
|
||||||
if (options.since !== undefined) args.push("--since", String(options.since));
|
|
||||||
if (options.limit !== undefined) args.push("--limit", String(options.limit));
|
|
||||||
if (options.maxTextChars !== undefined) {
|
|
||||||
args.push("--max-text-chars", String(options.maxTextChars));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Human help, raw HTML, diagnostics, and Agent Window sizing. */
|
|
||||||
export function registerPhaseOneSupportTools(
|
|
||||||
deps: ToolDeps,
|
|
||||||
register: ToolRegistrar,
|
|
||||||
runtime: PhaseOneRuntime,
|
|
||||||
): void {
|
|
||||||
const { registry } = deps;
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
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.",
|
|
||||||
parameters: {
|
|
||||||
prompt: {
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
description: "Clear instructions shown to the user in the browser help overlay.",
|
|
||||||
},
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
tabId: TAB_ID_PARAM,
|
|
||||||
title: { type: "string", description: "Optional title for the help overlay." },
|
|
||||||
targets: {
|
|
||||||
type: "array",
|
|
||||||
items: { type: "string" },
|
|
||||||
description: "Snapshot refs or CSS selectors to scroll to and highlight for the user.",
|
|
||||||
},
|
|
||||||
timeoutMs: {
|
|
||||||
...TIMEOUT_MS_PARAM,
|
|
||||||
description: "Maximum wait for the user in milliseconds (default: 300000).",
|
|
||||||
},
|
|
||||||
completionCriteria: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
description: "Optional explicit success detector for automatic completion.",
|
|
||||||
properties: {
|
|
||||||
any: { type: "array", items: HELP_CONDITION_SCHEMA },
|
|
||||||
all: { type: "array", items: HELP_CONDITION_SCHEMA },
|
|
||||||
stableForMs: {
|
|
||||||
type: "integer",
|
|
||||||
description: "How long criteria must remain true before completing.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
outcome: {
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
enum: ["continued", "cancelled", "timed_out", "completed", "navigated", "disabled"],
|
|
||||||
},
|
|
||||||
completedBy: { type: "string" },
|
|
||||||
note: { type: "string" },
|
|
||||||
resolvedTargets: {
|
|
||||||
type: "array",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
matched: { type: "boolean", required: true },
|
|
||||||
ref: { type: "string" },
|
|
||||||
selector: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
`[session ${value.session}] user help outcome on tab ${value.tabId}: ${value.outcome}` +
|
|
||||||
(value.note !== undefined ? ` — ${value.note}` : "") +
|
|
||||||
(value.resolvedTargets !== undefined
|
|
||||||
? `\ntargets: ${value.resolvedTargets
|
|
||||||
.map(
|
|
||||||
(target) =>
|
|
||||||
`${target.ref ?? target.selector ?? "(unknown)"}=${target.matched ? "matched" : "not found"}`,
|
|
||||||
)
|
|
||||||
.join(", ")}`
|
|
||||||
: ""),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
requireNonEmpty(args.prompt, "prompt");
|
|
||||||
if (args.title !== undefined) requireNonEmpty(args.title, "title");
|
|
||||||
requirePositive(args.timeoutMs, "timeoutMs");
|
|
||||||
for (const target of args.targets ?? []) requireNonEmpty(target, "target");
|
|
||||||
const criteria = args.completionCriteria as CompletionCriteriaInput | undefined;
|
|
||||||
if (criteria?.stableForMs !== undefined && criteria.stableForMs < 0) {
|
|
||||||
throw new Error("completionCriteria.stableForMs must be zero or greater");
|
|
||||||
}
|
|
||||||
const sessionId = registry.resolve(args.session, "browser_request_help");
|
|
||||||
const timeoutMs = args.timeoutMs ?? 300_000;
|
|
||||||
const cmdArgs = [
|
|
||||||
"request-help",
|
|
||||||
"--session",
|
|
||||||
sessionId,
|
|
||||||
"--prompt",
|
|
||||||
args.prompt,
|
|
||||||
"--timeout",
|
|
||||||
`${timeoutMs}ms`,
|
|
||||||
];
|
|
||||||
appendTabId(cmdArgs, args.tabId);
|
|
||||||
if (args.title !== undefined) cmdArgs.push("--title", args.title);
|
|
||||||
for (const target of args.targets ?? []) cmdArgs.push("--target", target);
|
|
||||||
if (criteria !== undefined) {
|
|
||||||
cmdArgs.push("--completion-criteria", JSON.stringify(mapCompletionCriteria(criteria)));
|
|
||||||
}
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
cmdArgs,
|
|
||||||
"request-help",
|
|
||||||
sessionId,
|
|
||||||
runnerTimeout(deps, timeoutMs),
|
|
||||||
)) as {
|
|
||||||
outcome: "continued" | "cancelled" | "timed_out" | "completed" | "navigated" | "disabled";
|
|
||||||
completed_by?: string;
|
|
||||||
note?: string;
|
|
||||||
tab_id: number;
|
|
||||||
resolved_targets?: { matched: boolean; ref?: string; selector?: string }[];
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
outcome: reply.outcome,
|
|
||||||
...(reply.completed_by !== undefined ? { completedBy: reply.completed_by } : {}),
|
|
||||||
...(reply.note !== undefined ? { note: reply.note } : {}),
|
|
||||||
...(reply.resolved_targets !== undefined
|
|
||||||
? { resolvedTargets: reply.resolved_targets }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine(["request-help", "--session", args.session ?? "(current)"]),
|
|
||||||
description: "Ask the user for browser help",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
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_observe/browser_snapshot cannot answer the question.",
|
|
||||||
parameters: {
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
tabId: TAB_ID_PARAM,
|
|
||||||
ref: { type: "string", description: "Fresh snapshot ref that scopes the HTML subtree." },
|
|
||||||
maxBytes: {
|
|
||||||
type: "integer",
|
|
||||||
description: "Maximum returned HTML bytes before truncation (default: 524288).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
html: { type: "string", required: true },
|
|
||||||
truncated: { type: "boolean", required: true },
|
|
||||||
byteSize: { type: "integer", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
value.html +
|
|
||||||
(value.truncated ? `\n(HTML truncated; original size ${value.byteSize} bytes)` : ""),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
isConcurrencySafe: () => true,
|
|
||||||
async execute(args, exec) {
|
|
||||||
if (args.ref !== undefined) {
|
|
||||||
requireNonEmpty(args.ref, "ref");
|
|
||||||
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_get_html");
|
|
||||||
const cmdArgs = ["get-html", "--session", sessionId];
|
|
||||||
appendTabId(cmdArgs, args.tabId);
|
|
||||||
if (args.ref !== undefined) cmdArgs.push("--ref", args.ref);
|
|
||||||
if (args.maxBytes !== undefined) cmdArgs.push("--max-bytes", String(args.maxBytes));
|
|
||||||
const reply = (await runtime.run(exec, cmdArgs, "get-html", sessionId)) as {
|
|
||||||
tab_id: number;
|
|
||||||
html: string;
|
|
||||||
truncated?: boolean;
|
|
||||||
byte_size: number;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
html: reply.html,
|
|
||||||
truncated: reply.truncated ?? false,
|
|
||||||
byteSize: reply.byte_size,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"get-html",
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
...(args.ref !== undefined ? ["--ref", args.ref] : []),
|
|
||||||
]),
|
|
||||||
description: "Read raw page HTML",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
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.",
|
|
||||||
parameters: {
|
|
||||||
...DEBUG_PARAMETERS,
|
|
||||||
includeStack: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Include structured stack frames in returned console entries.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
entries: {
|
|
||||||
type: "array",
|
|
||||||
required: true,
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
sequence: { type: "integer", required: true },
|
|
||||||
kind: { type: "string", required: true },
|
|
||||||
level: { type: "string", required: true },
|
|
||||||
text: { type: "string", required: true },
|
|
||||||
url: { type: "string" },
|
|
||||||
line: { type: "integer" },
|
|
||||||
column: { type: "integer" },
|
|
||||||
timestamp: { type: "number" },
|
|
||||||
stackTrace: {
|
|
||||||
type: "array",
|
|
||||||
required: true,
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
functionName: { type: "string" },
|
|
||||||
url: { type: "string" },
|
|
||||||
line: { type: "integer" },
|
|
||||||
column: { type: "integer" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
truncated: { type: "boolean", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
nextSince: { type: "integer", required: true },
|
|
||||||
truncated: { type: "boolean", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
value.entries.length === 0
|
|
||||||
? "(no console messages captured)"
|
|
||||||
: value.entries
|
|
||||||
.map((entry) => {
|
|
||||||
const location =
|
|
||||||
entry.url === undefined
|
|
||||||
? ""
|
|
||||||
: ` ${entry.url}${entry.line !== undefined ? `:${entry.line}` : ""}${entry.column !== undefined ? `:${entry.column}` : ""}`;
|
|
||||||
const stack = entry.stackTrace
|
|
||||||
.map(
|
|
||||||
(frame) =>
|
|
||||||
`\n at ${frame.functionName ?? "<anonymous>"} ${frame.url ?? ""}` +
|
|
||||||
`${frame.line !== undefined ? `:${frame.line}` : ""}` +
|
|
||||||
`${frame.column !== undefined ? `:${frame.column}` : ""}`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
return `#${entry.sequence} ${entry.level} ${entry.kind}${location} ${entry.text}${stack}`;
|
|
||||||
})
|
|
||||||
.join("\n") +
|
|
||||||
(value.truncated
|
|
||||||
? `\n(output truncated; continue with since=${value.nextSince})`
|
|
||||||
: ""),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
isConcurrencySafe: () => true,
|
|
||||||
async execute(args, exec) {
|
|
||||||
validateDebugArgs(args);
|
|
||||||
const sessionId = registry.resolve(args.session, "browser_console");
|
|
||||||
const cmdArgs = ["console", "--session", sessionId];
|
|
||||||
appendDebugOptions(cmdArgs, args);
|
|
||||||
if (args.includeStack === true) cmdArgs.push("--include-stack");
|
|
||||||
const reply = (await runtime.run(exec, cmdArgs, "console", sessionId)) as {
|
|
||||||
tab_id: number;
|
|
||||||
entries?: RawConsoleEntry[];
|
|
||||||
next_since: number;
|
|
||||||
truncated?: boolean;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
entries: (reply.entries ?? []).map((entry) => ({
|
|
||||||
sequence: entry.sequence,
|
|
||||||
kind: entry.kind,
|
|
||||||
level: entry.level,
|
|
||||||
text: entry.text,
|
|
||||||
...(entry.url !== undefined ? { url: entry.url } : {}),
|
|
||||||
...(entry.line !== undefined ? { line: entry.line } : {}),
|
|
||||||
...(entry.column !== undefined ? { column: entry.column } : {}),
|
|
||||||
...(entry.timestamp !== undefined ? { timestamp: entry.timestamp } : {}),
|
|
||||||
stackTrace: (entry.stack_trace ?? []).map((frame) => ({
|
|
||||||
...(frame.function_name !== undefined ? { functionName: frame.function_name } : {}),
|
|
||||||
...(frame.url !== undefined ? { url: frame.url } : {}),
|
|
||||||
...(frame.line !== undefined ? { line: frame.line } : {}),
|
|
||||||
...(frame.column !== undefined ? { column: frame.column } : {}),
|
|
||||||
})),
|
|
||||||
truncated: entry.truncated ?? false,
|
|
||||||
})),
|
|
||||||
nextSince: reply.next_since,
|
|
||||||
truncated: reply.truncated ?? false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine(["console", "--session", args.session ?? "(current)"]),
|
|
||||||
description: "Read browser console messages",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
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.",
|
|
||||||
parameters: DEBUG_PARAMETERS,
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
entries: {
|
|
||||||
type: "array",
|
|
||||||
required: true,
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
sequence: { type: "integer", required: true },
|
|
||||||
kind: { type: "string", required: true, enum: ["response", "failure"] },
|
|
||||||
method: { type: "string" },
|
|
||||||
url: { type: "string" },
|
|
||||||
status: { type: "integer" },
|
|
||||||
statusText: { type: "string" },
|
|
||||||
mimeType: { type: "string" },
|
|
||||||
resourceType: { type: "string" },
|
|
||||||
errorText: { type: "string" },
|
|
||||||
timestamp: { type: "number" },
|
|
||||||
truncated: { type: "boolean", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
nextSince: { type: "integer", required: true },
|
|
||||||
truncated: { type: "boolean", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
value.entries.length === 0
|
|
||||||
? "(no network activity captured)"
|
|
||||||
: value.entries
|
|
||||||
.map((entry) =>
|
|
||||||
entry.kind === "failure"
|
|
||||||
? `#${entry.sequence} FAILED ${entry.method ?? "?"} ${entry.url ?? "(unknown)"} — ${entry.errorText ?? "failed"}`
|
|
||||||
: `#${entry.sequence} ${entry.status ?? "?"} ${entry.method ?? "?"} ${entry.url ?? "(unknown)"}`,
|
|
||||||
)
|
|
||||||
.join("\n") +
|
|
||||||
(value.truncated
|
|
||||||
? `\n(output truncated; continue with since=${value.nextSince})`
|
|
||||||
: ""),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
isConcurrencySafe: () => true,
|
|
||||||
async execute(args, exec) {
|
|
||||||
validateDebugArgs(args);
|
|
||||||
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 {
|
|
||||||
tab_id: number;
|
|
||||||
entries?: RawNetworkEntry[];
|
|
||||||
next_since: number;
|
|
||||||
truncated?: boolean;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
entries: (reply.entries ?? []).map((entry) => ({
|
|
||||||
sequence: entry.sequence,
|
|
||||||
kind: entry.kind,
|
|
||||||
...(entry.method !== undefined ? { method: entry.method } : {}),
|
|
||||||
...(entry.url !== undefined ? { url: entry.url } : {}),
|
|
||||||
...(entry.status !== undefined ? { status: entry.status } : {}),
|
|
||||||
...(entry.status_text !== undefined ? { statusText: entry.status_text } : {}),
|
|
||||||
...(entry.mime_type !== undefined ? { mimeType: entry.mime_type } : {}),
|
|
||||||
...(entry.resource_type !== undefined ? { resourceType: entry.resource_type } : {}),
|
|
||||||
...(entry.error_text !== undefined ? { errorText: entry.error_text } : {}),
|
|
||||||
...(entry.timestamp !== undefined ? { timestamp: entry.timestamp } : {}),
|
|
||||||
truncated: entry.truncated ?? false,
|
|
||||||
})),
|
|
||||||
nextSince: reply.next_since,
|
|
||||||
truncated: reply.truncated ?? false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine(["network", "--session", args.session ?? "(current)"]),
|
|
||||||
description: "Read browser network activity",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
name: "browser_window_resize",
|
|
||||||
description: "Resize the owned session's Agent Window using outer CSS-pixel dimensions.",
|
|
||||||
parameters: {
|
|
||||||
width: {
|
|
||||||
type: "integer",
|
|
||||||
required: true,
|
|
||||||
description: "Agent Window outer width in CSS pixels (100..=7680).",
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
type: "integer",
|
|
||||||
required: true,
|
|
||||||
description: "Agent Window outer height in CSS pixels (100..=7680).",
|
|
||||||
},
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
windowId: { type: "integer", required: true },
|
|
||||||
width: { type: "integer", required: true },
|
|
||||||
height: { type: "integer", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `[session ${value.session}] resized window ${value.windowId} to ${value.width}x${value.height}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
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_window_resize");
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
[
|
|
||||||
"window",
|
|
||||||
"resize",
|
|
||||||
"--session",
|
|
||||||
sessionId,
|
|
||||||
"--width",
|
|
||||||
String(args.width),
|
|
||||||
"--height",
|
|
||||||
String(args.height),
|
|
||||||
],
|
|
||||||
"window resize",
|
|
||||||
sessionId,
|
|
||||||
)) as { window_id: number; width: number; height: number };
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
windowId: reply.window_id,
|
|
||||||
width: reply.width,
|
|
||||||
height: reply.height,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"window",
|
|
||||||
"resize",
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
"--width",
|
|
||||||
String(args.width),
|
|
||||||
"--height",
|
|
||||||
String(args.height),
|
|
||||||
]),
|
|
||||||
description: "Resize the Agent Window",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
||||||
import type { PhaseOneRuntime, ToolRegistrar } from "./phase-one-runtime";
|
|
||||||
import { requireNonEmpty } from "./phase-one-runtime";
|
|
||||||
import { SESSION_PARAM } from "./tool-params";
|
|
||||||
import type { ToolDeps } from "./tools";
|
|
||||||
|
|
||||||
const TAB_ID_REQUIRED = {
|
|
||||||
type: "integer",
|
|
||||||
required: true,
|
|
||||||
description: "Chrome tab id returned by browser_tab_list or browser_tab_create.",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
interface RawTabInfo {
|
|
||||||
tab_id: number;
|
|
||||||
title?: string;
|
|
||||||
url?: string;
|
|
||||||
window_id?: number;
|
|
||||||
active?: boolean;
|
|
||||||
scope?: "user" | "agent";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add Agent Window tab management while preserving the owned-session boundary. */
|
|
||||||
export function registerPhaseOneTabTools(
|
|
||||||
deps: ToolDeps,
|
|
||||||
register: ToolRegistrar,
|
|
||||||
runtime: PhaseOneRuntime,
|
|
||||||
): void {
|
|
||||||
const { registry } = deps;
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
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.",
|
|
||||||
parameters: {
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
scope: {
|
|
||||||
type: "string",
|
|
||||||
enum: ["user", "agent", "all"],
|
|
||||||
description: "Which tabs to list (default: all).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabs: {
|
|
||||||
type: "array",
|
|
||||||
required: true,
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
title: { type: "string" },
|
|
||||||
url: { type: "string" },
|
|
||||||
windowId: { type: "integer" },
|
|
||||||
active: { type: "boolean" },
|
|
||||||
scope: { type: "string", enum: ["user", "agent"] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
value.tabs.length === 0
|
|
||||||
? `[session ${value.session}] no matching tabs`
|
|
||||||
: value.tabs
|
|
||||||
.map(
|
|
||||||
(tab) =>
|
|
||||||
`${tab.tabId} [${tab.scope ?? "unknown"}]${tab.active === true ? " [active]" : ""} ` +
|
|
||||||
`${tab.title ?? "(untitled)"} — ${tab.url ?? "(unknown URL)"}`,
|
|
||||||
)
|
|
||||||
.join("\n"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
isConcurrencySafe: () => true,
|
|
||||||
async execute(args, exec) {
|
|
||||||
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 {
|
|
||||||
tabs: RawTabInfo[];
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabs: reply.tabs.map((tab) => ({
|
|
||||||
tabId: tab.tab_id,
|
|
||||||
...(tab.title !== undefined ? { title: tab.title } : {}),
|
|
||||||
...(tab.url !== undefined ? { url: tab.url } : {}),
|
|
||||||
...(tab.window_id !== undefined ? { windowId: tab.window_id } : {}),
|
|
||||||
...(tab.active !== undefined ? { active: tab.active } : {}),
|
|
||||||
...(tab.scope !== undefined ? { scope: tab.scope } : {}),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"tab",
|
|
||||||
"list",
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
...(args.scope !== undefined ? ["--scope", args.scope] : []),
|
|
||||||
]),
|
|
||||||
description: "List browser tabs",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
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.",
|
|
||||||
parameters: {
|
|
||||||
session: SESSION_PARAM,
|
|
||||||
url: { type: "string", description: "Initial URL (default: chrome://newtab/)." },
|
|
||||||
active: { type: "boolean", description: "Whether to focus the new tab (default: true)." },
|
|
||||||
index: { type: "integer", description: "Insertion index in the Agent Window tab strip." },
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
windowId: { type: "integer", required: true },
|
|
||||||
url: { type: "string", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `[session ${value.session}] created tab ${value.tabId} in window ${value.windowId} (${value.url || "pending URL"})`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
if (args.url !== undefined) requireNonEmpty(args.url, "url");
|
|
||||||
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");
|
|
||||||
if (args.index !== undefined) cmdArgs.push("--index", String(args.index));
|
|
||||||
const reply = (await runtime.run(exec, cmdArgs, "tab create", sessionId)) as {
|
|
||||||
tab_id: number;
|
|
||||||
window_id: number;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
if (args.active !== false && reply.url.length > 0) {
|
|
||||||
deps.observation.setUrl(sessionId, reply.url);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
windowId: reply.window_id,
|
|
||||||
url: reply.url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"tab",
|
|
||||||
"create",
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
...(args.url !== undefined ? ["--url", args.url] : []),
|
|
||||||
]),
|
|
||||||
description: "Create an Agent Window tab",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
name: "browser_tab_close",
|
|
||||||
description: "Close a tab in the owned session's Agent Window.",
|
|
||||||
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{ type: "text", text: `[session ${value.session}] closed tab ${value.tabId}` },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
const sessionId = registry.resolve(args.session, "browser_tab_close");
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
["tab", "close", String(args.tabId), "--session", sessionId],
|
|
||||||
"tab close",
|
|
||||||
sessionId,
|
|
||||||
)) as { tab_id: number };
|
|
||||||
return { session: sessionId, tabId: reply.tab_id };
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"tab",
|
|
||||||
"close",
|
|
||||||
String(args.tabId),
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
]),
|
|
||||||
description: "Close an Agent Window tab",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
name: "browser_tab_select",
|
|
||||||
description: "Focus a tab in the owned session's Agent Window.",
|
|
||||||
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
|
||||||
output: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
windowId: { type: "integer", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `[session ${value.session}] selected tab ${value.tabId} in window ${value.windowId}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
const sessionId = registry.resolve(args.session, "browser_tab_select");
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
["tab", "select", String(args.tabId), "--session", sessionId],
|
|
||||||
"tab select",
|
|
||||||
sessionId,
|
|
||||||
)) as { tab_id: number; window_id: number };
|
|
||||||
return { session: sessionId, tabId: reply.tab_id, windowId: reply.window_id };
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"tab",
|
|
||||||
"select",
|
|
||||||
String(args.tabId),
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
]),
|
|
||||||
description: "Select an Agent Window tab",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
name: "browser_tab_borrow",
|
|
||||||
description:
|
|
||||||
"Move a user-window tab into the owned session's Agent Window for controlled interaction. " +
|
|
||||||
"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: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
originalWindowId: { type: "integer", required: true },
|
|
||||||
originalIndex: { type: "integer", required: true },
|
|
||||||
agentWindowId: { type: "integer", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
`[session ${value.session}] borrowed tab ${value.tabId} from window ` +
|
|
||||||
`${value.originalWindowId} into Agent Window ${value.agentWindowId}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
const sessionId = registry.resolve(args.session, "browser_tab_borrow");
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
["tab", "borrow", String(args.tabId), "--session", sessionId],
|
|
||||||
"tab borrow",
|
|
||||||
sessionId,
|
|
||||||
)) as {
|
|
||||||
tab_id: number;
|
|
||||||
original_window_id: number;
|
|
||||||
original_index: number;
|
|
||||||
agent_window_id: number;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
originalWindowId: reply.original_window_id,
|
|
||||||
originalIndex: reply.original_index,
|
|
||||||
agentWindowId: reply.agent_window_id,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"tab",
|
|
||||||
"borrow",
|
|
||||||
String(args.tabId),
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
]),
|
|
||||||
description: "Borrow a user tab",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
register(
|
|
||||||
defineTool({
|
|
||||||
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: {
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
session: { type: "string", required: true },
|
|
||||||
tabId: { type: "integer", required: true },
|
|
||||||
returnedToWindowId: { type: "integer", required: true },
|
|
||||||
returnedToIndex: { type: "integer", required: true },
|
|
||||||
fallback: { type: "boolean", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (_args, value) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text:
|
|
||||||
`[session ${value.session}] returned tab ${value.tabId} to window ` +
|
|
||||||
`${value.returnedToWindowId} at index ${value.returnedToIndex}` +
|
|
||||||
(value.fallback ? " (original window unavailable; used fallback)" : ""),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async execute(args, exec) {
|
|
||||||
const sessionId = registry.resolve(args.session, "browser_tab_return");
|
|
||||||
const reply = (await runtime.run(
|
|
||||||
exec,
|
|
||||||
["tab", "return", String(args.tabId), "--session", sessionId],
|
|
||||||
"tab return",
|
|
||||||
sessionId,
|
|
||||||
)) as {
|
|
||||||
tab_id: number;
|
|
||||||
returned_to_window_id: number;
|
|
||||||
returned_to_index: number;
|
|
||||||
fallback?: boolean;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
session: sessionId,
|
|
||||||
tabId: reply.tab_id,
|
|
||||||
returnedToWindowId: reply.returned_to_window_id,
|
|
||||||
returnedToIndex: reply.returned_to_index,
|
|
||||||
fallback: reply.fallback ?? false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
presentCall: (args) => ({
|
|
||||||
card: "terminal",
|
|
||||||
title: runtime.commandLine([
|
|
||||||
"tab",
|
|
||||||
"return",
|
|
||||||
String(args.tabId),
|
|
||||||
"--session",
|
|
||||||
args.session ?? "(current)",
|
|
||||||
]),
|
|
||||||
description: "Return a borrowed tab",
|
|
||||||
}),
|
|
||||||
presentResult: runtime.presentTerminalResult,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { PhaseOneRuntime, ToolRegistrar } from "./phase-one-runtime";
|
|
||||||
import { registerPhaseOneInteractionTools } from "./phase-one-tools-interaction";
|
|
||||||
import { registerPhaseOneNavigationTools } from "./phase-one-tools-navigation";
|
|
||||||
import { registerPhaseOneSupportTools } from "./phase-one-tools-support";
|
|
||||||
import { registerPhaseOneTabTools } from "./phase-one-tools-tabs";
|
|
||||||
import type { ToolDeps } from "./tools";
|
|
||||||
|
|
||||||
/** Register the first DSH capability-parity tranche on the existing runtime. */
|
|
||||||
export function registerPhaseOneTools(
|
|
||||||
deps: ToolDeps,
|
|
||||||
register: ToolRegistrar,
|
|
||||||
runtime: PhaseOneRuntime,
|
|
||||||
): void {
|
|
||||||
registerPhaseOneInteractionTools(deps, register, runtime);
|
|
||||||
registerPhaseOneTabTools(deps, register, runtime);
|
|
||||||
registerPhaseOneNavigationTools(deps, register, runtime);
|
|
||||||
registerPhaseOneSupportTools(deps, register, runtime);
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
* Progressive disclosure of the BrowserSkill agent skill through the harness's
|
* Progressive disclosure of the BrowserSkill agent skill through the harness's
|
||||||
* official skill seam (`ctx.skills`). The catalog entry (name + description)
|
* official skill seam (`ctx.skills`). The catalog entry (name + description)
|
||||||
* is resident; the body is loaded only when the model invokes the `skill`
|
* is resident; the body is loaded only when the model invokes the `skill`
|
||||||
* tool. The dedicated DeepSeek Harness markdown is embedded into a static
|
* tool. The markdown is build-time assembled into a static module (dsh
|
||||||
* module at build time, so registration and every pre-step catalog snapshot
|
* prelude + the canonical CLI skill body verbatim), so registration and every
|
||||||
* are pure in-memory reads — no disk, no process, no daemon. The CLI-oriented
|
* pre-step catalog snapshot are pure in-memory reads — no disk, no process,
|
||||||
* repository skill is intentionally a separate interface.
|
* no daemon.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from "@deepseek-ai/cordis";
|
import type { Context } from "@deepseek-ai/cordis";
|
||||||
@@ -26,7 +26,7 @@ interface SkillsLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish the browser skill as an embedded runtime skill. Returns the unregistration
|
* Publish the BSK skill as an embedded runtime skill. Returns the unregistration
|
||||||
* disposer. A composition without the `skills` service (or with dsh-tool-skill
|
* disposer. A composition without the `skills` service (or with dsh-tool-skill
|
||||||
* retired) leaves the rest of the plugin unaffected — the no-op is silent.
|
* retired) leaves the rest of the plugin unaffected — the no-op is silent.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
/** Shared model-facing parameter schemas for browser tools. */
|
|
||||||
|
|
||||||
export const SESSION_PARAM = {
|
|
||||||
type: "string",
|
|
||||||
description:
|
|
||||||
"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;
|
|
||||||
|
|
||||||
export const TAB_ID_PARAM = {
|
|
||||||
type: "integer",
|
|
||||||
description: "Target tab id. Omit to use the Agent Window's active tab.",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const WAIT_UNTIL_PARAM = {
|
|
||||||
type: "string",
|
|
||||||
enum: ["load", "domcontentloaded", "networkidle", "commit"],
|
|
||||||
description: "Page lifecycle phase to wait for (default: load).",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const TIMEOUT_MS_PARAM = {
|
|
||||||
type: "integer",
|
|
||||||
description: "Command timeout in milliseconds; must be greater than zero.",
|
|
||||||
} as const;
|
|
||||||
@@ -20,11 +20,9 @@ import {
|
|||||||
import { ownerSessionIds } from "./archive-cleanup";
|
import { ownerSessionIds } from "./archive-cleanup";
|
||||||
import { trySaveScreenshot } from "./image";
|
import { trySaveScreenshot } from "./image";
|
||||||
import { actionForLabel, type ObservationService } from "./observation";
|
import { actionForLabel, type ObservationService } from "./observation";
|
||||||
import { registerPhaseOneTools } from "./phase-one-tools";
|
|
||||||
import type { KeyedExecutor } from "./queue";
|
import type { KeyedExecutor } from "./queue";
|
||||||
import { type BskRunner, bskInstallMessage, isCommandNotFound, parseBskJson } from "./runner";
|
import { type BskRunner, bskInstallMessage, isCommandNotFound, parseBskJson } from "./runner";
|
||||||
import type { SessionRegistry } from "./sessions";
|
import type { SessionRegistry } from "./sessions";
|
||||||
import { SESSION_PARAM } from "./tool-params";
|
|
||||||
|
|
||||||
/** Plugin configuration resolved from the Schemastery schema in index.ts. */
|
/** Plugin configuration resolved from the Schemastery schema in index.ts. */
|
||||||
export interface PluginConfig {
|
export interface PluginConfig {
|
||||||
@@ -64,6 +62,13 @@ const DEVICE_PRESETS = [
|
|||||||
"galaxy-tab-s8",
|
"galaxy-tab-s8",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const SESSION_PARAM = {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one bsk command and return its parsed JSON payload, or throw.
|
* Run one bsk command and return its parsed JSON payload, or throw.
|
||||||
* `observeSession` marks the run as model-facing work on that session: it is
|
* `observeSession` marks the run as model-facing work on that session: it is
|
||||||
@@ -77,7 +82,6 @@ async function runBsk(
|
|||||||
args: string[],
|
args: string[],
|
||||||
label: string,
|
label: string,
|
||||||
observeSession?: string,
|
observeSession?: string,
|
||||||
runnerTimeoutMs?: number,
|
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
// beginAction must fire only when the task actually starts inside the
|
// beginAction must fire only when the task actually starts inside the
|
||||||
// per-session queue — marking it while still queued would overwrite the
|
// per-session queue — marking it while still queued would overwrite the
|
||||||
@@ -94,7 +98,7 @@ async function runBsk(
|
|||||||
}
|
}
|
||||||
return deps.runner.run(args, {
|
return deps.runner.run(args, {
|
||||||
signal: exec.signal,
|
signal: exec.signal,
|
||||||
timeoutMs: runnerTimeoutMs ?? deps.config.defaultTimeoutMs,
|
timeoutMs: deps.config.defaultTimeoutMs,
|
||||||
...(observeSession !== undefined ? { tag: observeSession } : {}),
|
...(observeSession !== undefined ? { tag: observeSession } : {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -955,12 +959,6 @@ export function registerTools(deps: ToolDeps): () => void {
|
|||||||
presentResult: presentTerminalResult,
|
presentResult: presentTerminalResult,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
registerPhaseOneTools(deps, register, {
|
|
||||||
run: (exec, args, label, observeSession, runnerTimeoutMs) =>
|
|
||||||
runBsk(deps, exec, args, label, observeSession, runnerTimeoutMs),
|
|
||||||
commandLine: (args) => cmdline(deps, args),
|
|
||||||
presentTerminalResult,
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const dispose of disposers.splice(0)) dispose();
|
for (const dispose of disposers.splice(0)) dispose();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -649,9 +649,6 @@ describe("actionForLabel", () => {
|
|||||||
expect(actionForLabel("navigate")).toBe("navigating");
|
expect(actionForLabel("navigate")).toBe("navigating");
|
||||||
expect(actionForLabel("screenshot")).toBe("capturing");
|
expect(actionForLabel("screenshot")).toBe("capturing");
|
||||||
expect(actionForLabel("session start")).toBe("starting");
|
expect(actionForLabel("session start")).toBe("starting");
|
||||||
expect(actionForLabel("tab borrow")).toBe("borrowing tab");
|
|
||||||
expect(actionForLabel("request-help")).toBe("waiting for user");
|
|
||||||
expect(actionForLabel("wait-for-navigation")).toBe("waiting for navigation");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Browser skill injection: registration wiring, catalog content, progressive-load
|
// BSK skill injection: registration wiring, catalog content, progressive-load
|
||||||
// weight, and silent degradation without the skill seam.
|
// weight, and silent degradation without the skill seam.
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
@@ -9,7 +9,7 @@ function fakeCtx(skills?: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("registerBskSkill", () => {
|
describe("registerBskSkill", () => {
|
||||||
it("registers the catalog entry with the DSH browser-tool body", () => {
|
it("registers the catalog entry with the assembled body", () => {
|
||||||
const registrations: { skill: Record<string, unknown>; disposer: () => void }[] = [];
|
const registrations: { skill: Record<string, unknown>; disposer: () => void }[] = [];
|
||||||
const skills = {
|
const skills = {
|
||||||
register(skill: Record<string, unknown>) {
|
register(skill: Record<string, unknown>) {
|
||||||
@@ -26,51 +26,23 @@ describe("registerBskSkill", () => {
|
|||||||
expect(skill.name).toBe("browser-skill");
|
expect(skill.name).toBe("browser-skill");
|
||||||
expect(typeof skill.description).toBe("string");
|
expect(typeof skill.description).toBe("string");
|
||||||
expect(String(skill.description)).toContain("browser_*");
|
expect(String(skill.description)).toContain("browser_*");
|
||||||
|
// body = dsh prelude + canonical CLI skill body + dsh postlude
|
||||||
const content = String(skill.content);
|
const content = String(skill.content);
|
||||||
const mentionedTools = [...new Set(content.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? [])].sort();
|
expect(content).toContain("browser_observe");
|
||||||
expect(mentionedTools).toEqual([
|
expect(String(skill.description)).toContain("never invoke bsk through bash or shell");
|
||||||
"browser_click",
|
expect(content).toContain("Always call those injected `browser_*` tools");
|
||||||
"browser_console",
|
expect(content).toContain("Never use `bash`, `shell`, `exec`");
|
||||||
"browser_emulate",
|
expect(content).toContain("## DSH routing reminder");
|
||||||
"browser_fill",
|
expect(content.trimEnd()).toMatch(
|
||||||
"browser_get_html",
|
/always begin with[\s\S]*`browser_session_start`[\s\S]*`browser_session_stop`\.$/,
|
||||||
"browser_hover",
|
);
|
||||||
"browser_navigate",
|
expect(content).toContain("Owned sessions only");
|
||||||
"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).toContain("All browser operations must use the injected tools directly");
|
|
||||||
expect(content).toContain("Mandatory workflow");
|
expect(content).toContain("Mandatory workflow");
|
||||||
expect(content).toContain("Refs invalidate after navigation");
|
expect(content).toContain("Refs invalidate after navigation");
|
||||||
expect(content).toContain("evaluation and interaction recording are intentionally unsupported");
|
|
||||||
expect(content.length).toBeGreaterThan(10_000);
|
expect(content.length).toBeGreaterThan(10_000);
|
||||||
expect(skill.source).toBe("bundled");
|
expect(skill.source).toBe("bundled");
|
||||||
// Source frontmatter is registration metadata and must not leak into the body.
|
// the original YAML frontmatter must not leak into the body
|
||||||
expect(content.startsWith("---")).toBe(false);
|
expect(content.includes("name: browser-skill\ndescription:")).toBe(false);
|
||||||
expect(content).not.toContain("name: browser-skill\ndescription:");
|
|
||||||
// disposer passthrough
|
// disposer passthrough
|
||||||
unregister();
|
unregister();
|
||||||
expect(skill.__disposed).toBe(true);
|
expect(skill.__disposed).toBe(true);
|
||||||
|
|||||||
@@ -142,33 +142,16 @@ describe("tool registration", () => {
|
|||||||
const { tools } = setup({});
|
const { tools } = setup({});
|
||||||
expect([...tools.keys()].sort()).toEqual([
|
expect([...tools.keys()].sort()).toEqual([
|
||||||
"browser_click",
|
"browser_click",
|
||||||
"browser_console",
|
|
||||||
"browser_emulate",
|
"browser_emulate",
|
||||||
"browser_fill",
|
"browser_fill",
|
||||||
"browser_get_html",
|
|
||||||
"browser_hover",
|
|
||||||
"browser_navigate",
|
"browser_navigate",
|
||||||
"browser_navigate_back",
|
|
||||||
"browser_navigate_forward",
|
|
||||||
"browser_network",
|
|
||||||
"browser_observe",
|
"browser_observe",
|
||||||
"browser_press",
|
"browser_press",
|
||||||
"browser_reload",
|
|
||||||
"browser_request_help",
|
|
||||||
"browser_screenshot",
|
"browser_screenshot",
|
||||||
"browser_select",
|
|
||||||
"browser_session_list",
|
"browser_session_list",
|
||||||
"browser_session_start",
|
"browser_session_start",
|
||||||
"browser_session_stop",
|
"browser_session_stop",
|
||||||
"browser_snapshot",
|
"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",
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -453,336 +436,6 @@ describe("interaction tools", () => {
|
|||||||
/mutually exclusive/,
|
/mutually exclusive/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 },
|
|
||||||
select: {
|
|
||||||
tab_id: 7,
|
|
||||||
used_ref: "@e4",
|
|
||||||
multiple: true,
|
|
||||||
selected_values: ["a", "b"],
|
|
||||||
selected_labels: ["Alpha", "Beta"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await startSession(tools);
|
|
||||||
const hovered = (await tools
|
|
||||||
.get("browser_hover")
|
|
||||||
?.execute(
|
|
||||||
{ target: "@e3", modifiers: ["shift"], settleMs: 250, timeoutMs: 2000 },
|
|
||||||
makeExec(),
|
|
||||||
)) as { x: number; y: number };
|
|
||||||
expect(hovered).toMatchObject({ x: 12, y: 24 });
|
|
||||||
expect(calls[1].args).toEqual([
|
|
||||||
"hover",
|
|
||||||
"--session",
|
|
||||||
"s1",
|
|
||||||
"--modifiers",
|
|
||||||
"shift",
|
|
||||||
"--settle",
|
|
||||||
"250ms",
|
|
||||||
"--timeout",
|
|
||||||
"2000ms",
|
|
||||||
"@e3",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const selected = (await tools
|
|
||||||
.get("browser_select")
|
|
||||||
?.execute({ target: "@e4", values: ["a", "b"], tabId: 7 }, makeExec())) as {
|
|
||||||
selectedValues: string[];
|
|
||||||
selectedLabels: string[];
|
|
||||||
};
|
|
||||||
expect(selected).toMatchObject({
|
|
||||||
selectedValues: ["a", "b"],
|
|
||||||
selectedLabels: ["Alpha", "Beta"],
|
|
||||||
});
|
|
||||||
expect(calls[2].args).toEqual([
|
|
||||||
"select",
|
|
||||||
"--session",
|
|
||||||
"s1",
|
|
||||||
"--tab-id",
|
|
||||||
"7",
|
|
||||||
"--value",
|
|
||||||
"a",
|
|
||||||
"--value",
|
|
||||||
"b",
|
|
||||||
"@e4",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("phase-one tab tools", () => {
|
|
||||||
it("maps list/create/close/select/borrow/return through the owned session", async () => {
|
|
||||||
const { tools, calls } = setup({
|
|
||||||
"session start": START_REPLY("s1"),
|
|
||||||
"tab list": {
|
|
||||||
tabs: [
|
|
||||||
{
|
|
||||||
tab_id: 7,
|
|
||||||
title: "Example",
|
|
||||||
url: "https://example.test",
|
|
||||||
window_id: 2,
|
|
||||||
active: true,
|
|
||||||
scope: "agent",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"tab create": { tab_id: 8, window_id: 2, url: "https://new.test" },
|
|
||||||
"tab close": { tab_id: 8 },
|
|
||||||
"tab select": { tab_id: 7, window_id: 2 },
|
|
||||||
"tab borrow": {
|
|
||||||
tab_id: 20,
|
|
||||||
original_window_id: 9,
|
|
||||||
original_index: 1,
|
|
||||||
agent_window_id: 2,
|
|
||||||
},
|
|
||||||
"tab return": {
|
|
||||||
tab_id: 20,
|
|
||||||
returned_to_window_id: 9,
|
|
||||||
returned_to_index: 1,
|
|
||||||
fallback: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await startSession(tools);
|
|
||||||
|
|
||||||
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("browser_tab_create")
|
|
||||||
?.execute({ url: "https://new.test", active: false, index: 1 }, makeExec())) as {
|
|
||||||
tabId: number;
|
|
||||||
};
|
|
||||||
expect(created.tabId).toBe(8);
|
|
||||||
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("browser_tab_return")
|
|
||||||
?.execute({ tabId: 20 }, makeExec())) as { fallback: boolean };
|
|
||||||
expect(returned.fallback).toBe(false);
|
|
||||||
|
|
||||||
expect(calls.slice(1).map((call) => call.args)).toEqual([
|
|
||||||
["tab", "list", "--session", "s1", "--scope", "agent"],
|
|
||||||
[
|
|
||||||
"tab",
|
|
||||||
"create",
|
|
||||||
"--session",
|
|
||||||
"s1",
|
|
||||||
"--url",
|
|
||||||
"https://new.test",
|
|
||||||
"--no-active",
|
|
||||||
"--index",
|
|
||||||
"1",
|
|
||||||
],
|
|
||||||
["tab", "close", "8", "--session", "s1"],
|
|
||||||
["tab", "select", "7", "--session", "s1"],
|
|
||||||
["tab", "borrow", "20", "--session", "s1"],
|
|
||||||
["tab", "return", "20", "--session", "s1"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("phase-one navigation tools", () => {
|
|
||||||
it("maps back/forward/reload/wait and preserves timeout budgets", async () => {
|
|
||||||
const { tools, calls } = setup({
|
|
||||||
"session start": START_REPLY("s1"),
|
|
||||||
"navigate-back": {
|
|
||||||
tab_id: 7,
|
|
||||||
previous_url: "https://b.test",
|
|
||||||
final_url: "https://a.test",
|
|
||||||
reached: "load",
|
|
||||||
},
|
|
||||||
"navigate-forward": {
|
|
||||||
tab_id: 7,
|
|
||||||
previous_url: "https://a.test",
|
|
||||||
final_url: "https://b.test",
|
|
||||||
reached: "load",
|
|
||||||
},
|
|
||||||
reload: {
|
|
||||||
tab_id: 7,
|
|
||||||
previous_url: "https://b.test",
|
|
||||||
final_url: "https://b.test",
|
|
||||||
reached: "networkidle",
|
|
||||||
},
|
|
||||||
"wait-for-navigation": { tab_id: 7, reached: "timeout", error_text: "no navigation" },
|
|
||||||
});
|
|
||||||
await startSession(tools);
|
|
||||||
await tools
|
|
||||||
.get("browser_navigate_back")
|
|
||||||
?.execute({ tabId: 7, waitUntil: "commit", timeoutMs: 1000 }, makeExec());
|
|
||||||
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",
|
|
||||||
"--session",
|
|
||||||
"s1",
|
|
||||||
"--tab-id",
|
|
||||||
"7",
|
|
||||||
"--wait-until",
|
|
||||||
"commit",
|
|
||||||
"--timeout",
|
|
||||||
"1000ms",
|
|
||||||
]);
|
|
||||||
expect(calls[2].args).toEqual(["navigate-forward", "--session", "s1"]);
|
|
||||||
expect(calls[3].args).toEqual([
|
|
||||||
"reload",
|
|
||||||
"--session",
|
|
||||||
"s1",
|
|
||||||
"--wait-until",
|
|
||||||
"networkidle",
|
|
||||||
"--hard",
|
|
||||||
]);
|
|
||||||
expect(calls[4].args).toEqual([
|
|
||||||
"wait-for-navigation",
|
|
||||||
"--session",
|
|
||||||
"s1",
|
|
||||||
"--timeout",
|
|
||||||
"2500ms",
|
|
||||||
]);
|
|
||||||
expect(calls[4].options.timeoutMs).toBe(120_000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("phase-one support tools", () => {
|
|
||||||
it("maps request-help targets and structured completion criteria", async () => {
|
|
||||||
const { tools, calls } = setup({
|
|
||||||
"session start": START_REPLY("s1"),
|
|
||||||
"request-help": {
|
|
||||||
outcome: "completed",
|
|
||||||
completed_by: "system",
|
|
||||||
note: "signed in",
|
|
||||||
tab_id: 7,
|
|
||||||
resolved_targets: [{ matched: true, ref: "@e3" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await startSession(tools);
|
|
||||||
const value = (await tools.get("browser_request_help")?.execute(
|
|
||||||
{
|
|
||||||
prompt: "Please sign in",
|
|
||||||
title: "Login required",
|
|
||||||
targets: ["@e3", "#submit"],
|
|
||||||
timeoutMs: 300_000,
|
|
||||||
completionCriteria: {
|
|
||||||
any: [{ urlContains: "/dashboard" }, { selectorExists: "[data-user]" }],
|
|
||||||
stableForMs: 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
makeExec(),
|
|
||||||
)) as { outcome: string; note: string };
|
|
||||||
expect(value).toMatchObject({ outcome: "completed", note: "signed in" });
|
|
||||||
expect(calls[1].options.timeoutMs).toBe(315_000);
|
|
||||||
expect(calls[1].args.slice(0, 8)).toEqual([
|
|
||||||
"request-help",
|
|
||||||
"--session",
|
|
||||||
"s1",
|
|
||||||
"--prompt",
|
|
||||||
"Please sign in",
|
|
||||||
"--timeout",
|
|
||||||
"300000ms",
|
|
||||||
"--title",
|
|
||||||
]);
|
|
||||||
const criteriaIndex = calls[1].args.indexOf("--completion-criteria");
|
|
||||||
expect(JSON.parse(calls[1].args[criteriaIndex + 1])).toEqual({
|
|
||||||
any: [{ url_contains: "/dashboard" }, { selector_exists: "[data-user]" }],
|
|
||||||
stable_for_ms: 1000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps get-html, console, and network results without raw shell access", async () => {
|
|
||||||
const { tools, calls } = setup({
|
|
||||||
"session start": START_REPLY("s1"),
|
|
||||||
"get-html": { tab_id: 7, html: "<main>Hi</main>", truncated: false, byte_size: 15 },
|
|
||||||
console: {
|
|
||||||
tab_id: 7,
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
sequence: 3,
|
|
||||||
kind: "exception",
|
|
||||||
level: "error",
|
|
||||||
text: "boom",
|
|
||||||
stack_trace: [{ function_name: "run", url: "https://a.test/app.js", line: 4 }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
next_since: 3,
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
network: {
|
|
||||||
tab_id: 7,
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
sequence: 5,
|
|
||||||
kind: "response",
|
|
||||||
method: "GET",
|
|
||||||
url: "https://a.test/api",
|
|
||||||
status: 200,
|
|
||||||
mime_type: "application/json",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
next_since: 5,
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await startSession(tools);
|
|
||||||
const html = (await tools
|
|
||||||
.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("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("browser_network")
|
|
||||||
?.execute({ tabId: 7, maxTextChars: 512 }, makeExec())) as {
|
|
||||||
entries: { mimeType: string }[];
|
|
||||||
};
|
|
||||||
expect(networkValue.entries[0].mimeType).toBe("application/json");
|
|
||||||
expect(calls.slice(1).map((call) => call.args)).toEqual([
|
|
||||||
["get-html", "--session", "s1", "--ref", "@e2", "--max-bytes", "2048"],
|
|
||||||
["console", "--session", "s1", "--since", "1", "--limit", "10", "--include-stack"],
|
|
||||||
["network", "--session", "s1", "--tab-id", "7", "--max-text-chars", "512"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resizes only within the CLI-supported range", async () => {
|
|
||||||
const { tools, calls } = setup({
|
|
||||||
"session start": START_REPLY("s1"),
|
|
||||||
"window resize": { window_id: 2, width: 1280, height: 800 },
|
|
||||||
});
|
|
||||||
await startSession(tools);
|
|
||||||
const resize = tools.get("browser_window_resize");
|
|
||||||
const value = (await resize?.execute({ width: 1280, height: 800 }, makeExec())) as {
|
|
||||||
windowId: number;
|
|
||||||
};
|
|
||||||
expect(value.windowId).toBe(2);
|
|
||||||
expect(calls[1].args).toEqual([
|
|
||||||
"window",
|
|
||||||
"resize",
|
|
||||||
"--session",
|
|
||||||
"s1",
|
|
||||||
"--width",
|
|
||||||
"1280",
|
|
||||||
"--height",
|
|
||||||
"800",
|
|
||||||
]);
|
|
||||||
await expect(resize?.execute({ width: 99, height: 800 }, makeExec())).rejects.toThrow(
|
|
||||||
/100\.\.=7680/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("browser_screenshot", () => {
|
describe("browser_screenshot", () => {
|
||||||
|
|||||||
@@ -6,27 +6,27 @@
|
|||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"connecting": "Connecting…",
|
"connecting": "Connecting…",
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"version_skew": "Connected",
|
"version_skew": "Protocol mismatch",
|
||||||
"disabled": "Connection off"
|
"disabled": "Connection off"
|
||||||
},
|
},
|
||||||
"stateDetail": {
|
"stateDetail": {
|
||||||
"disconnected": "Open BrowserSkill to connect.",
|
"disconnected": "Open BrowserSkill to connect.",
|
||||||
"connecting": "Connecting to BrowserSkill…",
|
"connecting": "Connecting to BrowserSkill…",
|
||||||
"connected": "Ready for automation tasks.",
|
"connected": "Ready for automation tasks.",
|
||||||
"version_skew": "Protocol versions differ. Please upgrade."
|
"version_skew": "Some features may be unavailable."
|
||||||
},
|
},
|
||||||
"stateBadge": {
|
"stateBadge": {
|
||||||
"disconnected": "Standby",
|
"disconnected": "Standby",
|
||||||
"connecting": "Igniting",
|
"connecting": "Igniting",
|
||||||
"connected": "Ready",
|
"connected": "Ready",
|
||||||
"version_skew": "Upgradable",
|
"version_skew": "Action needed",
|
||||||
"disabled": "Off"
|
"disabled": "Off"
|
||||||
},
|
},
|
||||||
"connectionToggleTitle": "BrowserSkill connection",
|
"connectionToggleTitle": "BrowserSkill connection",
|
||||||
"controlHintsToggleTitle": "Control hints",
|
"controlHintsToggleTitle": "Control hints",
|
||||||
"controlHintsToggleHint": "Show the status pill and orange glow while the Agent controls a page.",
|
"controlHintsToggleHint": "Show the status pill and orange glow while the Agent controls a page.",
|
||||||
"controlHintsInfoLabel": "About control hints",
|
"controlHintsInfoLabel": "About control hints",
|
||||||
"versionSkewWarning": "Extension protocol v{{extensionProtocol}}, CLI protocol v{{cliProtocol}}. Protocol versions differ — please upgrade.",
|
"versionSkewWarning": "Extension protocol v{{extensionProtocol}}, daemon v{{daemonProtocol}}.",
|
||||||
"upgradeAvailable": "Upgradable",
|
"upgradeAvailable": "Upgradable",
|
||||||
"launcher": {
|
"launcher": {
|
||||||
"title": "Quick actions"
|
"title": "Quick actions"
|
||||||
|
|||||||
@@ -6,27 +6,27 @@
|
|||||||
"disconnected": "未连接",
|
"disconnected": "未连接",
|
||||||
"connecting": "连接中…",
|
"connecting": "连接中…",
|
||||||
"connected": "已连接",
|
"connected": "已连接",
|
||||||
"version_skew": "已连接",
|
"version_skew": "协议不一致",
|
||||||
"disabled": "连接已关闭"
|
"disabled": "连接已关闭"
|
||||||
},
|
},
|
||||||
"stateDetail": {
|
"stateDetail": {
|
||||||
"disconnected": "请先打开 BrowserSkill。",
|
"disconnected": "请先打开 BrowserSkill。",
|
||||||
"connecting": "正在连接 BrowserSkill…",
|
"connecting": "正在连接 BrowserSkill…",
|
||||||
"connected": "可接收自动化任务。",
|
"connected": "可接收自动化任务。",
|
||||||
"version_skew": "协议版本不同,请及时升级。"
|
"version_skew": "部分能力可能不可用。"
|
||||||
},
|
},
|
||||||
"stateBadge": {
|
"stateBadge": {
|
||||||
"disconnected": "Standby",
|
"disconnected": "Standby",
|
||||||
"connecting": "Igniting",
|
"connecting": "Igniting",
|
||||||
"connected": "Ready",
|
"connected": "Ready",
|
||||||
"version_skew": "可升级",
|
"version_skew": "Action needed",
|
||||||
"disabled": "Off"
|
"disabled": "Off"
|
||||||
},
|
},
|
||||||
"connectionToggleTitle": "BrowserSkill 连接",
|
"connectionToggleTitle": "BrowserSkill 连接",
|
||||||
"controlHintsToggleTitle": "控制提示",
|
"controlHintsToggleTitle": "控制提示",
|
||||||
"controlHintsToggleHint": "Agent 控制页面时显示提示条和橙色闪光。",
|
"controlHintsToggleHint": "Agent 控制页面时显示提示条和橙色闪光。",
|
||||||
"controlHintsInfoLabel": "控制提示说明",
|
"controlHintsInfoLabel": "控制提示说明",
|
||||||
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},CLI 协议 v{{cliProtocol}}。协议版本不同,请及时升级。",
|
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},daemon 协议 v{{daemonProtocol}}。",
|
||||||
"upgradeAvailable": "可升级",
|
"upgradeAvailable": "可升级",
|
||||||
"launcher": {
|
"launcher": {
|
||||||
"title": "快捷功能"
|
"title": "快捷功能"
|
||||||
|
|||||||
@@ -333,6 +333,84 @@ describe("renderVom single-layer page", () => {
|
|||||||
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
|
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not treat implementation CSS classes as control names", () => {
|
||||||
|
const out = renderVom(
|
||||||
|
scene([
|
||||||
|
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||||
|
node({
|
||||||
|
id: 2,
|
||||||
|
parentId: 1,
|
||||||
|
role: "generic",
|
||||||
|
cursor: "pointer",
|
||||||
|
attrs: { onclick: "handleClick()" },
|
||||||
|
rect: { x: 20, y: 20, w: 40, h: 40 },
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 3,
|
||||||
|
parentId: 2,
|
||||||
|
role: "img",
|
||||||
|
tag: "svg",
|
||||||
|
attrs: { class: "kocomz" },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(out.text).not.toContain("kocomz");
|
||||||
|
expect(out.refs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers icon controls only from explicit semantic icon evidence", () => {
|
||||||
|
const out = renderVom(
|
||||||
|
scene([
|
||||||
|
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||||
|
node({
|
||||||
|
id: 2,
|
||||||
|
parentId: 1,
|
||||||
|
role: "generic",
|
||||||
|
cursor: "pointer",
|
||||||
|
attrs: { onclick: "handleFilter()" },
|
||||||
|
rect: { x: 20, y: 20, w: 40, h: 40 },
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 3,
|
||||||
|
parentId: 2,
|
||||||
|
role: "img",
|
||||||
|
tag: "svg",
|
||||||
|
attrs: { "aria-label": "Filter rows", class: "kocomz" },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(out.text).toContain('@e1 button "Filter rows"');
|
||||||
|
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a semantic image as evidence for its clickable wrapper", () => {
|
||||||
|
const out = renderVom(
|
||||||
|
scene([
|
||||||
|
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||||
|
node({
|
||||||
|
id: 2,
|
||||||
|
parentId: 1,
|
||||||
|
role: "generic",
|
||||||
|
cursor: "pointer",
|
||||||
|
rect: { x: 20, y: 20, w: 40, h: 40 },
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 3,
|
||||||
|
parentId: 2,
|
||||||
|
role: "img",
|
||||||
|
name: "Account",
|
||||||
|
cursor: "pointer",
|
||||||
|
tag: "div",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(out.text).toContain('@e1 button "Account"');
|
||||||
|
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the deepest custom control when wrapper and child both look clickable", () => {
|
it("keeps the deepest custom control when wrapper and child both look clickable", () => {
|
||||||
const out = renderVom(
|
const out = renderVom(
|
||||||
scene([
|
scene([
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
export { applyVomInteractionRecovery, isVomReferenceNode, renderVom } from "./render";
|
export {
|
||||||
|
applyVomInteractionRecovery,
|
||||||
|
isVomReferenceNode,
|
||||||
|
isVomStructuralRole,
|
||||||
|
renderVom,
|
||||||
|
} from "./render";
|
||||||
export type {
|
export type {
|
||||||
ActiveScopeBlock,
|
ActiveScopeBlock,
|
||||||
BlockingLayer,
|
BlockingLayer,
|
||||||
|
|||||||
+42
-16
@@ -75,6 +75,10 @@ const STRUCTURAL_ROLES = new Set([
|
|||||||
"webarea",
|
"webarea",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export function isVomStructuralRole(role: string | undefined): boolean {
|
||||||
|
return STRUCTURAL_ROLES.has(role?.toLowerCase() ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
const SENSITIVE_MASK = "•••";
|
const SENSITIVE_MASK = "•••";
|
||||||
const DEFAULT_MAX_DEPTH = Number.POSITIVE_INFINITY;
|
const DEFAULT_MAX_DEPTH = Number.POSITIVE_INFINITY;
|
||||||
const DEFAULT_MAX_TOKENS = Number.POSITIVE_INFINITY;
|
const DEFAULT_MAX_TOKENS = Number.POSITIVE_INFINITY;
|
||||||
@@ -193,13 +197,16 @@ function shouldRender(node: VomNode): boolean {
|
|||||||
const role = normalizedRole(node);
|
const role = normalizedRole(node);
|
||||||
if (SKIP_ROLES.has(role)) return false;
|
if (SKIP_ROLES.has(role)) return false;
|
||||||
if (INTERACTIVE_ROLES.has(role)) return true;
|
if (INTERACTIVE_ROLES.has(role)) return true;
|
||||||
if (STRUCTURAL_ROLES.has(role)) return true;
|
if (isVomStructuralRole(role)) return true;
|
||||||
|
|
||||||
return cleaned(node.name) !== undefined;
|
return cleaned(node.name) !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVomReferenceNode(node: VomNode): boolean {
|
export function isVomReferenceNode(node: VomNode): boolean {
|
||||||
return INTERACTIVE_ROLES.has(normalizedRole(node)) || isUrlBearingStructuralAction(node);
|
return (
|
||||||
|
node.referenceable !== false &&
|
||||||
|
(INTERACTIVE_ROLES.has(normalizedRole(node)) || isUrlBearingStructuralAction(node))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRecoveryHandler(node: VomNode): boolean {
|
function hasRecoveryHandler(node: VomNode): boolean {
|
||||||
@@ -219,14 +226,30 @@ function isContentEditable(node: VomNode): boolean {
|
|||||||
return contentEditable === "true" || contentEditable === "plaintext-only";
|
return contentEditable === "true" || contentEditable === "plaintext-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconKeyword(value: string | undefined): string | undefined {
|
const ICON_REFERENCE_MARKERS = new Set(["glyph", "icon", "icons", "symbol"]);
|
||||||
const cleanedValue = cleaned(value);
|
const ICON_REFERENCE_DECORATORS = new Set(["fill", "filled", "line", "outline", "outlined"]);
|
||||||
if (!cleanedValue) return undefined;
|
|
||||||
const last = cleanedValue
|
function iconReferenceName(value: string | undefined): string | undefined {
|
||||||
.split(/[#/.\s:_-]+/)
|
const reference = cleaned(value);
|
||||||
|
if (!reference) return undefined;
|
||||||
|
const fragment = reference.includes("#") ? reference.slice(reference.lastIndexOf("#") + 1) : "";
|
||||||
|
const tokens = (fragment || reference)
|
||||||
|
?.split(/[#/.\s:_-]+/)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.pop();
|
.map((token) => token.toLowerCase());
|
||||||
return last && /^[a-z][a-z0-9_-]{1,32}$/i.test(last) ? last.toLowerCase() : undefined;
|
if (tokens.length === 0) return undefined;
|
||||||
|
if (fragment) {
|
||||||
|
const semanticTokens = tokens.filter(
|
||||||
|
(token) => !ICON_REFERENCE_MARKERS.has(token) && !ICON_REFERENCE_DECORATORS.has(token),
|
||||||
|
);
|
||||||
|
return semanticTokens.length > 0 ? semanticTokens.join(" ") : undefined;
|
||||||
|
}
|
||||||
|
const marker = tokens.findLastIndex((token) => ICON_REFERENCE_MARKERS.has(token));
|
||||||
|
if (marker < 0) return undefined;
|
||||||
|
const semanticTokens = tokens
|
||||||
|
.slice(marker + 1)
|
||||||
|
.filter((token) => !ICON_REFERENCE_DECORATORS.has(token));
|
||||||
|
return semanticTokens.length > 0 ? semanticTokens.join(" ") : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconHintFromSubtree(
|
function iconHintFromSubtree(
|
||||||
@@ -238,12 +261,12 @@ function iconHintFromSubtree(
|
|||||||
for (const child of children.get(node.id) ?? []) {
|
for (const child of children.get(node.id) ?? []) {
|
||||||
const attrs = child.attrs ?? {};
|
const attrs = child.attrs ?? {};
|
||||||
const hint =
|
const hint =
|
||||||
iconKeyword(attrs["aria-label"]) ??
|
cleaned(attrs["aria-label"]) ??
|
||||||
iconKeyword(attrs.title) ??
|
cleaned(attrs.title) ??
|
||||||
iconKeyword(attrs.href) ??
|
(normalizedRole(child) === "img" ? cleaned(child.name) : undefined) ??
|
||||||
iconKeyword(attrs["xlink:href"]) ??
|
(["img", "svg", "use"].includes(normalizedTag(child))
|
||||||
iconKeyword(attrs.class) ??
|
? (iconReferenceName(attrs.href) ?? iconReferenceName(attrs["xlink:href"]))
|
||||||
iconKeyword(child.text);
|
: undefined);
|
||||||
if (hint) return hint;
|
if (hint) return hint;
|
||||||
const nested = iconHintFromSubtree(child, children, depth + 1);
|
const nested = iconHintFromSubtree(child, children, depth + 1);
|
||||||
if (nested) return nested;
|
if (nested) return nested;
|
||||||
@@ -269,6 +292,7 @@ function recoveredRole(
|
|||||||
node: VomNode,
|
node: VomNode,
|
||||||
children?: Map<number | null, VomNode[]>,
|
children?: Map<number | null, VomNode[]>,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
const currentRole = normalizedRole(node);
|
||||||
const explicitRole = node.attrs?.role?.toLowerCase() ?? "";
|
const explicitRole = node.attrs?.role?.toLowerCase() ?? "";
|
||||||
const hasHandler = hasRecoveryHandler(node);
|
const hasHandler = hasRecoveryHandler(node);
|
||||||
const focusable = isFocusable(node);
|
const focusable = isFocusable(node);
|
||||||
@@ -290,7 +314,9 @@ function recoveredRole(
|
|||||||
: "button";
|
: "button";
|
||||||
}
|
}
|
||||||
if (hasHandler && hasName) return "button";
|
if (hasHandler && hasName) return "button";
|
||||||
if (node.cursor === "pointer" && hasName) return "button";
|
if ((!currentRole || SKIP_ROLES.has(currentRole)) && node.cursor === "pointer" && hasName) {
|
||||||
|
return "button";
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface VomNode {
|
|||||||
backendNodeId?: number;
|
backendNodeId?: number;
|
||||||
frameId?: string;
|
frameId?: string;
|
||||||
contextScopeId?: string;
|
contextScopeId?: string;
|
||||||
|
/** False for semantic-only nodes that cannot be addressed through CDP. */
|
||||||
|
referenceable?: boolean;
|
||||||
|
|
||||||
role?: string;
|
role?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
||||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
|
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
|
||||||
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
||||||
|
|
||||||
## Mandatory workflow
|
## Mandatory workflow
|
||||||
@@ -299,7 +299,7 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
|
|||||||
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
| `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 |
|
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
||||||
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
||||||
| `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`. |
|
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
|
||||||
|
|
||||||
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user