mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7f976335 | ||
|
|
d1d3eb6a94 | ||
|
|
38fd7d3f4c | ||
|
|
b5062ac19b | ||
|
|
41cb576256 | ||
|
|
f060a7c074 | ||
|
|
60b62d5420 | ||
|
|
70b851a3e8 | ||
|
|
da3534d066 | ||
|
|
a004291848 | ||
|
|
4b6cdde168 |
Generated
+2
-2
@@ -134,7 +134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bsk-protocol",
|
||||
@@ -168,7 +168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk-protocol"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"semver",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ members = [
|
||||
default-members = ["crates/bsk-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browser-skill/extension",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1656,7 +1656,7 @@ describe("buildVomScene", () => {
|
||||
|
||||
const scene = buildVomScene(axNodes, captured);
|
||||
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||
expect.objectContaining({ id: 20, role: "generic", cursor: "pointer" }),
|
||||
expect.objectContaining({ id: 20, role: "button", name: "close", cursor: "pointer" }),
|
||||
);
|
||||
const rendered = renderVom(scene);
|
||||
expect(rendered.text).toContain('@e1 button "close"');
|
||||
@@ -1732,7 +1732,7 @@ describe("buildVomScene", () => {
|
||||
};
|
||||
|
||||
const scene = buildVomScene(axNodes, captured);
|
||||
expect(scene.nodes.find((n) => n.id === 20)?.role).toBe("generic");
|
||||
expect(scene.nodes.find((n) => n.id === 20)).toBeUndefined();
|
||||
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("link");
|
||||
});
|
||||
|
||||
@@ -1801,9 +1801,9 @@ describe("buildVomScene", () => {
|
||||
|
||||
const scene = buildVomScene(axNodes, captured);
|
||||
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||
expect.objectContaining({ id: 20, role: "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);
|
||||
expect(rendered.text).toContain('@e1 button "收藏"');
|
||||
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([
|
||||
@@ -2131,7 +2131,7 @@ describe("buildVomScene", () => {
|
||||
});
|
||||
|
||||
expect(scene.surfaces).toEqual([
|
||||
{ triggerId: 21, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
|
||||
{ triggerId: 20, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
|
||||
]);
|
||||
expect(renderVom(scene).text).toContain(
|
||||
'@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 () => {
|
||||
const secrets = {
|
||||
email: "user@example.com",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ function fakeSnapshotReply() {
|
||||
},
|
||||
layout: {
|
||||
nodeIndex: [1, 2, 3],
|
||||
// styles columns follow requested computedStyles order: [position, pointer-events]
|
||||
// Legacy fixture: omitted style columns fall back to visible defaults.
|
||||
styles: [
|
||||
[i("static"), i("auto")],
|
||||
[i("fixed"), i("auto")],
|
||||
@@ -682,6 +682,46 @@ describe("captureViewModel", () => {
|
||||
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
|
||||
});
|
||||
|
||||
it("captures painted visibility as DOM fallback evidence", async () => {
|
||||
const S = ["html", "body", "button", "static", "auto", "visible", "hidden", "1", "0"];
|
||||
const i = (value: string) => S.indexOf(value);
|
||||
const snapshot = {
|
||||
strings: S,
|
||||
documents: [
|
||||
{
|
||||
nodes: {
|
||||
parentIndex: [-1, 0, 1, 1, 1],
|
||||
nodeName: [i("html"), i("body"), i("button"), i("button"), i("button")],
|
||||
backendNodeId: [10, 11, 12, 13, 14],
|
||||
attributes: [[], [], [], [], []],
|
||||
},
|
||||
layout: {
|
||||
nodeIndex: [1, 2, 3, 4],
|
||||
styles: [
|
||||
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
|
||||
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
|
||||
[i("static"), i("auto"), i("auto"), i("hidden"), i("1")],
|
||||
[i("static"), i("auto"), i("auto"), i("visible"), i("0")],
|
||||
],
|
||||
bounds: [
|
||||
[0, 0, 1000, 800],
|
||||
[10, 10, 100, 30],
|
||||
[10, 50, 100, 30],
|
||||
[10, 90, 100, 30],
|
||||
],
|
||||
paintOrders: [0, 1, 2, 3],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
|
||||
|
||||
expect(nodes.find((node) => node.backendNodeId === 12)?.rendered).toBe(true);
|
||||
expect(nodes.find((node) => node.backendNodeId === 13)?.rendered).toBe(false);
|
||||
expect(nodes.find((node) => node.backendNodeId === 14)?.rendered).toBe(false);
|
||||
});
|
||||
|
||||
it("subtracts scroll offset to make rects viewport-relative", async () => {
|
||||
const cdp = {
|
||||
send: vi.fn(async (_t: number, method: string) => {
|
||||
|
||||
@@ -132,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
|
||||
// → CapturedNode[] + Viewport. This is the ONLY VOM module that touches
|
||||
// raw CDP. The captureSnapshot reply is columnar (parallel arrays + a
|
||||
// shared string table); we request exactly three computed styles so the
|
||||
// `styles` columns are [position, pointer-events, cursor] in that order.
|
||||
// shared string table); requested computed styles are decoded through
|
||||
// STYLE_COL so capture and visibility policy share one explicit contract.
|
||||
|
||||
import type { Rect, Viewport } from "@browser-skill/vom";
|
||||
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
||||
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { clearHover, waitForHover } from "./hover-perception";
|
||||
|
||||
const REQUESTED_STYLES = ["position", "pointer-events", "cursor"] as const;
|
||||
const REQUESTED_STYLES = ["position", "pointer-events", "cursor", "visibility", "opacity"] as const;
|
||||
const STYLE_COL = Object.fromEntries(
|
||||
REQUESTED_STYLES.map((name, index) => [name, index]),
|
||||
) 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.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
* Whether the live DOM snapshot provides a painted, non-hidden box for this
|
||||
* node. Semantic resolution uses this only for DOM fallback nodes; AX-backed
|
||||
* nodes remain authoritative even when they are outside the viewport.
|
||||
*/
|
||||
rendered?: boolean;
|
||||
textContent?: string;
|
||||
formState?: "empty" | "filled" | "default";
|
||||
formValue?: string;
|
||||
@@ -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 {
|
||||
const value =
|
||||
node.attrs["aria-label"] ?? node.attrs.title ?? node.attrs.alt ?? node.textContent ?? "";
|
||||
@@ -669,7 +646,7 @@ async function probeHoverSurfaces(
|
||||
try {
|
||||
await clearHover(cdp, tabId);
|
||||
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", {
|
||||
expression: hoverStateExpression(),
|
||||
returnByValue: true,
|
||||
@@ -684,7 +661,7 @@ async function probeHoverSurfaces(
|
||||
y: candidate.y,
|
||||
});
|
||||
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", {
|
||||
expression: hoverStateExpression(),
|
||||
returnByValue: true,
|
||||
@@ -817,6 +794,8 @@ function parseDocumentNodes(
|
||||
const posCol = STYLE_COL.position;
|
||||
const peCol = STYLE_COL["pointer-events"];
|
||||
const cursorCol = STYLE_COL.cursor;
|
||||
const visibilityCol = STYLE_COL.visibility;
|
||||
const opacityCol = STYLE_COL.opacity;
|
||||
const inputValues = sparseIndexMap(dn.inputValue);
|
||||
const textValues = sparseIndexMap(dn.textValue);
|
||||
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
|
||||
@@ -861,6 +840,7 @@ function parseDocumentNodes(
|
||||
let position = "static";
|
||||
let pointerEvents = "auto";
|
||||
let cursor = "auto";
|
||||
let rendered = false;
|
||||
const li = layoutByNode.get(n);
|
||||
if (li !== undefined) {
|
||||
const b = dl?.bounds?.[li];
|
||||
@@ -881,6 +861,13 @@ function parseDocumentNodes(
|
||||
position = str(strings, styleRow[posCol]) || "static";
|
||||
pointerEvents = str(strings, styleRow[peCol]) || "auto";
|
||||
cursor = str(strings, styleRow[cursorCol]) || "auto";
|
||||
const visibility = str(strings, styleRow[visibilityCol]) || "visible";
|
||||
const opacity = str(strings, styleRow[opacityCol]) || "1";
|
||||
rendered =
|
||||
localRect !== null &&
|
||||
visibility !== "hidden" &&
|
||||
visibility !== "collapse" &&
|
||||
(Number.parseFloat(opacity) || 0) > 0;
|
||||
}
|
||||
|
||||
// Skip non-element nodes (#text, #cdata-section, etc.) — they carry no
|
||||
@@ -916,6 +903,7 @@ function parseDocumentNodes(
|
||||
position,
|
||||
pointerEvents,
|
||||
cursor,
|
||||
rendered,
|
||||
textContent,
|
||||
...(formValue !== undefined ? { formValue } : {}),
|
||||
...(tag === "input" || tag === "textarea"
|
||||
@@ -1020,7 +1008,13 @@ export async function captureViewModel(
|
||||
options: CaptureViewModelOptions = {},
|
||||
): Promise<CapturedViewModel> {
|
||||
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);
|
||||
const dpr = devicePixelRatio(metrics);
|
||||
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
||||
|
||||
@@ -33,25 +33,19 @@ async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFra
|
||||
|
||||
function transformFrameNodes(
|
||||
nodes: CapturedNode[],
|
||||
projection: GeometryProjection,
|
||||
projection: GeometryProjection | null,
|
||||
): CapturedNode[] {
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
rect: (() => {
|
||||
const bounds = projectRectToViewport(node.rect, projection);
|
||||
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
||||
})(),
|
||||
rect: projection
|
||||
? (() => {
|
||||
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(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
@@ -65,14 +59,20 @@ async function captureMissingFrameDocuments(
|
||||
|
||||
for (const frame of graph.frames) {
|
||||
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
|
||||
const owner = frameOwnerNode(captured, frame);
|
||||
if (!owner?.rect || frame.ownerBackendNodeId === undefined) continue;
|
||||
try {
|
||||
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
|
||||
signal,
|
||||
});
|
||||
const projection = await resolveFrameProjection(cdp, graph, frame.frameId);
|
||||
if (!projection) continue;
|
||||
let projection: GeometryProjection | null = null;
|
||||
try {
|
||||
projection = await resolveFrameProjection(cdp, graph, frame.frameId);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) throw err;
|
||||
console.debug("[bsk observation] child frame geometry projection failed", {
|
||||
frameId: frame.frameId,
|
||||
err,
|
||||
});
|
||||
}
|
||||
const rootFrameId = child.rootFrameId ?? frame.frameId;
|
||||
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
|
||||
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
|
||||
@@ -85,11 +85,13 @@ async function captureMissingFrameDocuments(
|
||||
for (const [childFrameId, nodes] of childFrames) {
|
||||
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
|
||||
}
|
||||
captured.iframeNodes.set(
|
||||
frame.ownerBackendNodeId,
|
||||
captured.frameNodes.get(frame.frameId) ?? [],
|
||||
);
|
||||
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
||||
if (frame.ownerBackendNodeId !== undefined) {
|
||||
captured.iframeNodes.set(
|
||||
frame.ownerBackendNodeId,
|
||||
captured.frameNodes.get(frame.frameId) ?? [],
|
||||
);
|
||||
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
||||
}
|
||||
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
|
||||
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
|
||||
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Archive-triggered bsk session cleanup. DSH conversation archival writes
|
||||
* the workspace registry's durable global state, which the storage domain
|
||||
* broadcasts as `domain/changed` ({domain: 'workspace', table: ''}) with
|
||||
* the new `archivedSessionIds`. The watcher diffs that set and stops every
|
||||
* bsk session owned by a freshly archived conversation — archived sessions
|
||||
* are hidden from every surface, so their Agent Windows would otherwise
|
||||
* linger with no way back to them.
|
||||
*
|
||||
* Ownership lineage: browser_session_start records the calling agent's
|
||||
* session id PLUS every ancestor along `header.parentSession` (a subagent's
|
||||
* browsers are reaped when any ancestor is archived, the root conversation
|
||||
* included). Lineage is resolved through the host session store; an
|
||||
* unloaded ancestor simply ends the walk.
|
||||
*/
|
||||
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import type { ObservationService } from "./observation";
|
||||
import type { SessionRegistry } from "./sessions";
|
||||
|
||||
/** The wire shape of a `domain/changed` frame (see dsh-storage-domain). */
|
||||
interface DomainChange {
|
||||
domain?: string;
|
||||
table?: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/** The workspace registry's durable global singleton slice we read. */
|
||||
interface WorkspaceGlobal {
|
||||
archivedSessionIds?: unknown;
|
||||
}
|
||||
|
||||
/** Structural host session store face: only the lineage read is needed. */
|
||||
interface SessionStoreLike {
|
||||
get(id: string): { header: { parentSession?: string } } | undefined;
|
||||
}
|
||||
|
||||
/** Cap on lineage walks — defensive against a malformed parent chain. */
|
||||
const MAX_LINEAGE_DEPTH = 16;
|
||||
|
||||
/**
|
||||
* The DSH session ids that own a tool call's browser sessions: the calling
|
||||
* agent's own session plus every ancestor along the seed lineage. Empty
|
||||
* when the call carried no agent identity (those sessions outlive any
|
||||
* archive cleanup by design — nothing can name their owner).
|
||||
*/
|
||||
export function ownerSessionIds(ctx: Context, agentId: string | undefined): string[] {
|
||||
if (agentId === undefined) return [];
|
||||
const store = ctx.get("sessions") as SessionStoreLike | undefined;
|
||||
const ids: string[] = [];
|
||||
let current: string | undefined = agentId;
|
||||
for (let depth = 0; current !== undefined && depth < MAX_LINEAGE_DEPTH; depth += 1) {
|
||||
if (ids.includes(current)) break; // a cycle in stored headers must not loop
|
||||
ids.push(current);
|
||||
current = store?.get(current)?.header.parentSession;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch conversation archival and stop the bsk sessions it opened. Returns
|
||||
* the disposer (plugin unload). In compositions without the workspace
|
||||
* domain (headless), the event simply never fires — and a context without
|
||||
* the events mixin degrades to a no-op like the other optional seams.
|
||||
*/
|
||||
export function armArchiveCleanup(
|
||||
ctx: Context,
|
||||
registry: SessionRegistry,
|
||||
observation: ObservationService,
|
||||
): () => void {
|
||||
// 'domain/changed' lives outside the vendored Events type map, so the
|
||||
// listener goes through a structural view of the events mixin.
|
||||
const on = (
|
||||
ctx as { on?: (event: string, listener: (change: DomainChange) => void) => () => void }
|
||||
).on;
|
||||
if (typeof on !== "function") return () => {};
|
||||
|
||||
/** Archived ids already accounted for; lazily seeded from the registry. */
|
||||
let seen: Set<string> | undefined;
|
||||
const initialize = (): Set<string> => {
|
||||
if (seen === undefined) {
|
||||
const registryService = ctx.get("workspaceRegistry") as
|
||||
| { archivedSessionIds?: readonly string[] }
|
||||
| undefined;
|
||||
seen = new Set(registryService?.archivedSessionIds ?? []);
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
return on.call(ctx, "domain/changed", (change: DomainChange) => {
|
||||
if (change?.domain !== "workspace" || change?.table !== "") return;
|
||||
const archived = (change.value as WorkspaceGlobal | undefined)?.archivedSessionIds;
|
||||
if (!Array.isArray(archived)) return;
|
||||
const previous = initialize();
|
||||
const fresh = archived.filter(
|
||||
(id): id is string => typeof id === "string" && !previous.has(id),
|
||||
);
|
||||
seen = new Set(archived.filter((id): id is string => typeof id === "string"));
|
||||
for (const dshSessionId of fresh) {
|
||||
for (const sessionId of registry.ownedByDsh(dshSessionId)) {
|
||||
// stopSession owns the full teardown (kill in-flight tools, queue
|
||||
// the daemon stop, drop registry + observation entries); a failure
|
||||
// just leaves the session for idle timeout or unload cleanup.
|
||||
void observation.stopSession(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -30,17 +30,51 @@
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
/* Sidebar carrier: fill the better-sidebar tab area and let the body flex
|
||||
(the tab content container owns the outer sizing). */
|
||||
.sidebar-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-tab > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The BSK product mark in the sidebar tab strip (the source artwork already
|
||||
carries rounded corners — just soften the square to match). */
|
||||
.brand-icon {
|
||||
display: block;
|
||||
border-radius: 22%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Only the floating card drags by its header — the sidebar tab does not. */
|
||||
.header[data-draggable] {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
/* In the sidebar the status row is chrome, not a card edge: drop its bottom
|
||||
border. A fixed-height bordered row can never track the shell header's
|
||||
hairline across the panel boundary (its height varies per window), and
|
||||
the BSK warm border tint clashes with the sidebar's neutral hairlines —
|
||||
borderless, the stage's own background provides the separation. */
|
||||
.sidebar-tab .header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -131,6 +165,12 @@
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -165,8 +205,14 @@
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
/* Hover the wrap (not just :hover on the button) so the tooltip target and
|
||||
the left-corner resize overlay still light the icon up. */
|
||||
/* Stop session: danger-tinted like interrupt. */
|
||||
.tool-stop {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
/* Hover the wrap (not just :hover on the button) so the tooltip target
|
||||
lights the icon up too. The corner resize handles sit above this bar, so
|
||||
the outer sliver of the corner-most buttons starts a resize instead. */
|
||||
.tool-wrap:hover .tool-button:not(:disabled) {
|
||||
color: var(--foreground);
|
||||
background: var(--accent);
|
||||
@@ -181,6 +227,15 @@
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* The armed (confirm) state of the stop button: solid destructive fill so
|
||||
the second click reads as final. Written at the wrap-hover specificity so
|
||||
the hover rules above never wash it out. */
|
||||
.tool-button.tool-stop-armed,
|
||||
.tool-wrap:hover .tool-button.tool-stop-armed {
|
||||
color: var(--destructive-foreground);
|
||||
background: var(--destructive);
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
@@ -203,13 +258,16 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Invisible hit targets — no grip glyph; all four corners resize. */
|
||||
/* Invisible hit targets — no grip glyph; all four corners resize. Must sit
|
||||
above the bottom .actions bar (z-index 3): it spans the full card width
|
||||
and would otherwise swallow every pointer event aimed at the sw/se
|
||||
corners. The top corners already win because .header is not positioned. */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
touch-action: none;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.resize-handle[data-corner="nw"] {
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
// composer's way); collapsed = a status capsule; "pop out" upgrades the same
|
||||
// content to a native Document PiP window (user gesture required by the
|
||||
// browser). Multi-session renders a meeting-style strip under the focus view.
|
||||
// Visuals follow the BrowserSkill product family: @browser-skill/ui components
|
||||
// and oklch tokens on a .bsk-obs scope root, so the card reads as BSK's own
|
||||
// surface without leaking styles into (or inheriting themes from) the shell.
|
||||
// When the dsh-better-sidebar plugin is installed the tracking view moves
|
||||
// into a sidebar tab instead (see observation-sidebar.tsx) and this floating
|
||||
// card hides itself via the sidebar-mode flag. Visuals follow the
|
||||
// BrowserSkill product family: @browser-skill/ui components and oklch tokens
|
||||
// on a .bsk-obs scope root, so the card reads as BSK's own surface without
|
||||
// leaking styles into (or inheriting themes from) the shell.
|
||||
|
||||
import { cn } from "@browser-skill/ui";
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
RiCloseCircleLine,
|
||||
RiErrorWarningLine,
|
||||
RiPictureInPicture2Line,
|
||||
RiPushpinFill,
|
||||
@@ -25,6 +30,8 @@ const IconPip = asIcon(RiPictureInPicture2Line);
|
||||
const IconDown = asIcon(RiArrowDownSLine);
|
||||
const IconWarn = asIcon(RiErrorWarningLine);
|
||||
const IconPin = asIcon(RiPushpinFill);
|
||||
const IconCloseSession = asIcon(RiCloseCircleLine);
|
||||
const IconCheck = asIcon(RiCheckLine);
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
@@ -39,11 +46,12 @@ import { createPortal } from "react-dom";
|
||||
import type { SessionObservation } from "../observation";
|
||||
import css from "./ObservationOverlay.module.css";
|
||||
import type { ObservationClientStore } from "./observation-store";
|
||||
import { focusOf, statusOf, useObservationView, usePip } from "./observation-view";
|
||||
import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode";
|
||||
|
||||
/** Minimal Document PiP surface (TS lib.dom lacks it). */
|
||||
interface DocumentPip {
|
||||
requestWindow(options?: { width?: number; height?: number }): Promise<Window>;
|
||||
}
|
||||
// The pure view helpers live in observation-view (shared with the sidebar
|
||||
// tab); re-export so existing imports of this module keep working.
|
||||
export { focusOf, statusOf };
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
@@ -61,11 +69,6 @@ const EDGE_MARGIN = 16;
|
||||
/** Default dock: top-right, just under the shell's top bar (no spacing tokens exist in dsh 0.1). */
|
||||
const TOP_OFFSET = 64;
|
||||
|
||||
function pipApi(): DocumentPip | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return (window as unknown as { documentPictureInPicture?: DocumentPip }).documentPictureInPicture;
|
||||
}
|
||||
|
||||
function clampSize(size: Size, viewport: Size): Size {
|
||||
const maxW = viewport.w * 0.8;
|
||||
const maxH = viewport.h * 0.8;
|
||||
@@ -114,23 +117,6 @@ function formatElapsed(sinceMs: number, nowMs: number): string {
|
||||
return `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-follow focus: the most recently touched session, but never yank focus
|
||||
* to a session whose latest action failed (errors flag the strip item, they
|
||||
* do not steal the stage) or one already reported dead.
|
||||
*/
|
||||
export function focusOf(sessions: readonly SessionObservation[]): SessionObservation | undefined {
|
||||
if (sessions.length === 0) return undefined;
|
||||
const byRecency = [...sessions].sort((a, b) => b.since - a.since);
|
||||
const healthy = byRecency.find((s) => s.lastError === undefined && s.dead !== true);
|
||||
return healthy ?? byRecency[0];
|
||||
}
|
||||
|
||||
export function statusOf(obs: SessionObservation): "active" | "idle" | "error" {
|
||||
if (obs.lastError !== undefined && obs.action === "idle") return "error";
|
||||
return obs.action === "idle" ? "idle" : "active";
|
||||
}
|
||||
|
||||
/** Compact toolbar icon: no label, hover bubble for the name / semantics. */
|
||||
function IconAction(props: {
|
||||
label: string;
|
||||
@@ -166,6 +152,83 @@ function IconAction(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/** How long the stop button stays armed before the confirm click expires. */
|
||||
const STOP_ARM_MS = 3000;
|
||||
|
||||
/**
|
||||
* Stop-session button with a lightweight two-click confirm: the first click
|
||||
* arms the button (solid red, check icon, short expiry), the second executes
|
||||
* the stop. No dialog, no layout shift — and a stray single click can never
|
||||
* close an Agent Window.
|
||||
*/
|
||||
function StopSessionAction(props: {
|
||||
sessionId: string | undefined;
|
||||
onStop: (sessionId: string) => Promise<boolean>;
|
||||
}) {
|
||||
const { sessionId, onStop } = props;
|
||||
const [hover, setHover] = useState(false);
|
||||
const [armed, setArmed] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!armed) return;
|
||||
const timer = setTimeout(() => setArmed(false), STOP_ARM_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [armed]);
|
||||
|
||||
// The focused session can vanish mid-arm (stopped elsewhere): disarm.
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) setArmed(false);
|
||||
}, [sessionId]);
|
||||
|
||||
const disabled = sessionId === undefined || stopping;
|
||||
const label = stopping
|
||||
? "Stopping session…"
|
||||
: armed
|
||||
? `Confirm stop session ${sessionId ?? ""}`
|
||||
: `Stop session ${sessionId ?? ""}`;
|
||||
const hint = stopping
|
||||
? "Closing the Agent Window…"
|
||||
: armed
|
||||
? `Click again to stop session ${sessionId ?? ""} and close its Agent Window.`
|
||||
: `Stop session ${sessionId ?? ""} and close its Agent Window.`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={css["tool-wrap"]}
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(css["tool-button"], css["tool-stop"], armed && css["tool-stop-armed"])}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={armed}
|
||||
data-armed={armed || undefined}
|
||||
onClick={() => {
|
||||
if (sessionId === undefined || stopping) return;
|
||||
if (!armed) {
|
||||
setArmed(true);
|
||||
return;
|
||||
}
|
||||
setArmed(false);
|
||||
setStopping(true);
|
||||
const target = sessionId;
|
||||
void onStop(target).finally(() => setStopping(false));
|
||||
}}
|
||||
>
|
||||
{armed ? <IconCheck size={16} /> : <IconCloseSession size={16} />}
|
||||
</button>
|
||||
{hover ? (
|
||||
<span className={css.hint} role="tooltip">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */
|
||||
function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) {
|
||||
const color =
|
||||
@@ -246,8 +309,8 @@ function StripItem(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/** The floating card / PiP shared content. */
|
||||
function OverlayBody(props: {
|
||||
/** The floating card / PiP / sidebar-tab shared content. */
|
||||
export function OverlayBody(props: {
|
||||
store: ObservationClientStore;
|
||||
focus: SessionObservation | undefined;
|
||||
sessions: readonly SessionObservation[];
|
||||
@@ -310,6 +373,7 @@ function OverlayBody(props: {
|
||||
<div
|
||||
className={css.header}
|
||||
data-testid="obs-header"
|
||||
data-draggable={onHeaderPointerDown !== undefined || undefined}
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -363,15 +427,21 @@ function OverlayBody(props: {
|
||||
</div>
|
||||
) : null}
|
||||
<div className={css.actions}>
|
||||
<IconAction
|
||||
label={interrupting ? "Interrupting…" : "Interrupt the current browser action"}
|
||||
hint="Stop the current browser action."
|
||||
disabled={!canInterrupt}
|
||||
danger
|
||||
onClick={onInterrupt}
|
||||
>
|
||||
<IconStop size={16} />
|
||||
</IconAction>
|
||||
<div className={css["actions-group"]}>
|
||||
<IconAction
|
||||
label={interrupting ? "Interrupting…" : "Interrupt the current browser action"}
|
||||
hint="Stop the current browser action."
|
||||
disabled={!canInterrupt}
|
||||
danger
|
||||
onClick={onInterrupt}
|
||||
>
|
||||
<IconStop size={16} />
|
||||
</IconAction>
|
||||
<StopSessionAction
|
||||
sessionId={focus?.sessionId}
|
||||
onStop={(sessionId) => store.stopSession(sessionId)}
|
||||
/>
|
||||
</div>
|
||||
{onPopOut !== undefined ? (
|
||||
<IconAction
|
||||
label="Pop out into a mini window"
|
||||
@@ -387,25 +457,17 @@ function OverlayBody(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Clone the host document's style/link nodes into a PiP window. */
|
||||
function cloneStylesInto(pipWindow: Window): void {
|
||||
for (const node of document.querySelectorAll('link[rel="stylesheet"], style')) {
|
||||
pipWindow.document.head.appendChild(node.cloneNode(true));
|
||||
}
|
||||
}
|
||||
|
||||
export function ObservationOverlay({ store }: { store: ObservationClientStore }) {
|
||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||
useEffect(() => {
|
||||
store.start();
|
||||
return () => store.stop();
|
||||
}, [store]);
|
||||
const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store);
|
||||
// While the better-sidebar plugin carries the tracking view, this floating
|
||||
// card (and its capsule) stays out of the way; an already-open PiP window
|
||||
// keeps its portal until the user closes it.
|
||||
const sidebarMode = useSyncExternalStore(subscribeSidebarMode, getSidebarMode);
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [pos, setPos] = useState<Point | null>(null);
|
||||
const [size, setSize] = useState<Size>(DEFAULT_SIZE);
|
||||
const [pipWindow, setPipWindow] = useState<Window | null>(null);
|
||||
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
||||
const { pipWindow, pipSupported, popOut } = usePip();
|
||||
const dragRef = useRef<{
|
||||
kind: "move" | "resize";
|
||||
corner?: ResizeCorner;
|
||||
@@ -414,23 +476,6 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
||||
base: Point & Size;
|
||||
} | null>(null);
|
||||
|
||||
// Elapsed-time ticker: 1s while anything is active.
|
||||
const anyActive = snapshot.sessions.some((s) => s.action !== "idle");
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!anyActive) return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [anyActive]);
|
||||
|
||||
// Focus: the pinned session wins while it still exists; otherwise auto-follow.
|
||||
const pinned =
|
||||
pinnedId !== null ? snapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined;
|
||||
const focus = pinned ?? focusOf(snapshot.sessions);
|
||||
const onTogglePin = useCallback((sessionId: string) => {
|
||||
setPinnedId((current) => (current === sessionId ? null : sessionId));
|
||||
}, []);
|
||||
|
||||
const viewport = (): Size => ({ w: window.innerWidth, h: window.innerHeight });
|
||||
|
||||
const onPointerMove = useCallback((event: PointerEvent) => {
|
||||
@@ -511,34 +556,21 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
|
||||
const popOut = async (): Promise<void> => {
|
||||
const pip = pipApi();
|
||||
if (pip === undefined) return;
|
||||
try {
|
||||
const win = await pip.requestWindow({ width: size.w, height: size.h });
|
||||
cloneStylesInto(win);
|
||||
win.addEventListener("pagehide", () => setPipWindow(null));
|
||||
setPipWindow(win);
|
||||
} catch {
|
||||
// requestWindow rejects without a user gesture (or when the window was
|
||||
// closed mid-request): stay on the in-page card, no state change.
|
||||
}
|
||||
};
|
||||
|
||||
// Hidden while no owned session exists (and no PiP is up).
|
||||
if (snapshot.sessions.length === 0 && pipWindow === null) return null;
|
||||
|
||||
const body = (
|
||||
<OverlayBody
|
||||
store={store}
|
||||
focus={focus}
|
||||
sessions={snapshot.sessions}
|
||||
available={snapshot.available}
|
||||
pinnedId={pinned !== undefined ? pinnedId : null}
|
||||
pinnedId={pinnedId}
|
||||
onTogglePin={onTogglePin}
|
||||
now={now}
|
||||
inPip={pipWindow !== null}
|
||||
onPopOut={pipWindow === null && pipApi() !== undefined ? () => void popOut() : undefined}
|
||||
onPopOut={
|
||||
pipWindow === null && pipSupported
|
||||
? () => popOut({ width: size.w, height: size.h })
|
||||
: undefined
|
||||
}
|
||||
onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined}
|
||||
onHeaderPointerDown={pipWindow === null ? beginMove : undefined}
|
||||
/>
|
||||
@@ -548,6 +580,12 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
||||
return createPortal(body, pipWindow.document.body);
|
||||
}
|
||||
|
||||
// The sidebar tab is the carrier now — no floating card, no capsule.
|
||||
if (sidebarMode) return null;
|
||||
|
||||
// Hidden while no owned session exists (and no PiP is up).
|
||||
if (snapshot.sessions.length === 0) return null;
|
||||
|
||||
if (collapsed) {
|
||||
const state = focus !== undefined ? statusOf(focus) : "idle";
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The BrowserSkill product mark (apps/extension/assets/logo.png, downscaled
|
||||
* to 32px and inlined): the sidebar tab icon, so the tracking view reads as
|
||||
* BSK's own surface next to better-sidebar's built-in "browser" tab.
|
||||
* Regenerate with: resize the source to 32x32 PNG and replace the payload.
|
||||
*/
|
||||
export const BSK_LOGO_URL =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAJGklEQVR42m2X248c2V3HP79zTlV19/TcPOOZWY+vsdmQFdngTQJswgs3hQ15iCJslAey+8xDAIHgH+CBNxIpj0gkEXnAKyKQWITEJlKWXSssEMfJRmuMHa/W9njHMz3Tl+nprsv5/XiomlugpdN16lR1n9/1e75fAbAbeLlO3Pz2ly4vjO7+sRbF58oiP49FEREOPwaIYQjS3IvUy0fP66kAZoYJIM7SpPVY0uy14cyzXzn9u9+4c+MG/vp1otiNa16uvxr7X/+1L3bK3lcTyU9P80ilR/uKCGYHOxxd6s2kmdaGIVYb1qyY1e8HD63UUblstwirf9J95fW/sRvXvAD0v/6Z6/O68Xf5/pS8kigiTsTkuCfIwfzAxQN37chjaAyt594JIXE4gbKMhog6Nd/qthi7lVe6L3/nG7Jz4/fPJ9u3b4U4WSwiKog/EcsTLh+PSuO3GXYs/D44QnBgyniQs7OV0+8bOxPPM6c9l660VEyF0Nmrlp9/IYT+/S/PUJ4a5C46EW8HnjVhBKjXaHJfz9Xq95xzhMThHZR5xXBrwtPNnP6eRzvzhJWzrF1d5UI759//9iYra5lrt1ycDeXsaOf+n4Y4HX8+r9SciasLS/6P12LHoyF47wipYKpM9gqePpyyvVMxjBn+1DILH17l0rkZlhYcLetT3n/A5FGfTtvTCh4ibjpVI81/J0il62V1rNStLp6j4jowxwiJh2iMevtsbU3pjYQim6Nz9hJLH13i8mrGbJgSxjuUvQfEjT2GOw4Zddh0BakPtFPH/jRKqaCSrwetKi9mKIIYhyFuOg6TurCcF7ae5ty5t48/vcTiz3+Ei+szLC06WnGA7TykfNCnmObk5nAtj+53yDcTZteMwZOChbkMVa3/14DCJBBBFBxyopndUUBqwwxu/XjAJ778Bc4ul+R3fgyj9yg/GDMpFZxHgoe0hQ9QDRzFI48zqHzJeKhcvNKiLAyiQ1BwgsOcqNZ9Xo96rgaVgmqdiBACH740y/1/+j4b7w1x80tUkwLM4VptJAQMcN6o+p7iYQIqhBQmsUIrR7cd0NI12ODAwJnWTpvZoRGqdRrS1CECeR7Z2NzHRBjeecgbX/kut39UUlx5ATe3gFUlAM5Tb/4oHCJl0oadUcFsK8E5sFh7ZaqgEFBr3KyhxAySxDMplDv3BuztlmhhZC6QqONDrXkW28Kbf/1d7A9+i6vPr7N/e5vQDZQ7jvxxUqdPwFRwHaP3sGR1boZYGabWwHdd7MHMMBUMh5mRJp6tnZx33tllJbS53J1jZj6BKpKPpkhZ8e5mj3Nf/FV+8ZOnmNz6Pr6TUvUcxeOAc0cN6xwUoWIyUhZXMqqq6bKmrQ0hWBQzdXX+nGOwV3LnJ30+trjE8mxGUUVG/Qn7owkt7/hpb5fwmat84qXz5P95EwmOqh8oNkKTSkFqICTJYDgpCOppZYGyjIfgdoApQRScCtEgDZ5b93dxuWd3OuWD8T5pqbSLyEyWcHdrh+S3r/Kpz15i/z/eQhIh9hOKjQRTyBYgFhAndSpDW+gNC+ZbGR6jjM3BVsNsXQNqhqpiOPIicuXcLFvdKT7xtNXTG+X0Bopt9Wi9dJUXX7rI5O23kNQRB4F8I0CEzgr0/B7t2YB/2iJOQDrK8HHJ+dkZqqI+JfUAYRXMGc6pM1MBBa2Ubttz5ews51c6XFjr8OJHT5N0HPIbz/Ppz11h/+03IXVUg4T8ccAidFaNp7LHzdd2uXdvj866gQq5L8nHMD+TUJUG0aAyJBqidZQcUfFqeK2vlEo1rYh5iUXl6eMB5eoKv37tOUb/9q+4LBD7gXKj3ry7Ak/iHrdeH/ArC7P07ykPekMWzkFvkNMmkHkgagNoDciogYKzaGg0YgSNYLEJj0ndfiGj6o/Z3i5or5+j2BTKJwlawcwKbMQ9fvK9Ib+QtgitQMg8d384wVYLdocls1mCad1+Fg1TDgeV4UxFTB2ow44NjQ5rwcwZ+MjqmLe/dZOBLOHHCeUUZteED+Ie73xvyHNpmyx1TERozXieW1/mO/+yzWhTWVtsU5aCqKvRT4+GmRDEHGJ6yPPMBHNGeqbCdwqs02X9l14g/a8H3PzWm/zyp1ZZXgn8tDfgnbeGPN/qIEDSyhh7w2OcPZWRsESWCq3EoVFPksZDMuUIps7qLjDUQBxkZ0tcu8KSGdIXPo3OLbOSdfj4YMIPbg6ZXxux9wRmWimFKe0k0J7v8H5vQHchpSoiC536bKhKEDlCJzM7JLJqhqtxoEYoUcEFw/Yduh8Jl38OP7eAlBOm29ssziV87JlluqMOL15ZZWVlhu0yMrfYxQQmZUU3TbCmnog1mRE1xOp9xOoaEwVRI9QnUGOECLoPNlcRlucJ6xeIgx3K/3kX23pINWnRdnBxtYNWkbkkMJ5JSLKEwaSgRFlsJWh1mNBDfsEBzZGaYIiBIgRtImCmmArhlBFOR1Q9xZ3b6OOHWFnifEq1U/84TpXgHL4hUh7hv3t91pY6eCeUpTYs2o6RisYEO6B5hpoSRCWaanNAGP5UVZ+r+YB4dxcbZ4QVx/Q9wSYg/uDYNvKyoorwgyfbtDuBC6c65HmFa5Jsh56f3NgO4iGeYC68n8Ty2WkphAUVv2hoLuggoXgUcJkRh0rVF7wHi3Vw1YxuSMh1zMpcmw8tdynzCmmCL1JjzQG3lpNU37xAdNnjYC79xywUfzbJNboEX214iqeeuN/IrkKw6BAvNUiJ1a2K0HKOT144jQBFoTWRayrc9LhWa1TTUTNqJ038wCf/LO//xZfWWw9u/zCt8uWpSiTixZ3UeJzQPkerdkyq1RryZwVN87CJBALR0ExULMtG4/VnPy4AG3/4m19YGG/9fT7NmZpEhzg5lCNHOo//774u7UYp2WEt1XrSftYMTcF32ynbrfmXz3ztjW86u3bNn/nq69/eaZ/6PUlbm/PB+YSapUozMG2uB0M5fI7WSKoKasjBu6qHczEjwWQ2iJc03XmaLb5y5mtvfNOuNeLUruHlVeK7f/7yxbXBu39U5sXnq7I8L2pH6rwRgCZyUpeKnZSOB5K2EbT1l5hPk0c+zV7bXXzmry7/5T/cvXENf/1V4v8CSYIrURx3kHgAAAAASUVORK5CYII=";
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* dsh-plugin-browserskill browser half: the `browser_screenshot` keyed
|
||||
* toolview plus the observation overlay (live thumbnails + interrupt) floating
|
||||
* over the shell via the `shell.overlay` seat.
|
||||
* toolview plus the observation overlay (live thumbnails + interrupt). The
|
||||
* default carrier is a floating card on the `shell.overlay` seat; when the
|
||||
* dsh-better-sidebar plugin provides its `betterSidebar` service, the view
|
||||
* moves into a sidebar tab instead (see observation-sidebar.tsx).
|
||||
*/
|
||||
|
||||
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
||||
@@ -15,6 +17,7 @@ import { createElement } from "react";
|
||||
import "./bsk-tokens.nomodule.css";
|
||||
import "./bsk-ui.nomodule.css";
|
||||
import { ObservationOverlay } from "./ObservationOverlay";
|
||||
import { type BetterSidebarLike, registerObservationSidebar } from "./observation-sidebar";
|
||||
import { type EventSourceLike, ObservationClientStore } from "./observation-store";
|
||||
import { ScreenshotToolView } from "./ScreenshotToolView";
|
||||
|
||||
@@ -81,4 +84,14 @@ export function apply(ctx: ClientContext): void {
|
||||
createElement(ObservationOverlay, { store }),
|
||||
),
|
||||
);
|
||||
// Optional carrier upgrade: when the dsh-better-sidebar plugin is installed,
|
||||
// its service moves the tracking view into a sidebar tab (the floating
|
||||
// overlay hides itself through the sidebar-mode flag). In profiles without
|
||||
// the sidebar plugin this fiber never runs and nothing changes.
|
||||
ctx.inject(["betterSidebar"], (injected) =>
|
||||
registerObservationSidebar(
|
||||
(injected as unknown as { betterSidebar: BetterSidebarLike }).betterSidebar,
|
||||
store,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// better-sidebar carrier for the observation view. When the
|
||||
// dsh-better-sidebar plugin is installed its client publishes a
|
||||
// `betterSidebar` cordis service; the client entry then runs the
|
||||
// registration below and the tracking view moves from the floating card
|
||||
// into a single-instance sidebar tab (Document PiP pop-out stays available
|
||||
// from inside the tab). Detection is purely service-based — profiles
|
||||
// without the sidebar plugin never start this fiber and keep the floating
|
||||
// overlay.
|
||||
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { BSK_LOGO_URL } from "./brand-icon";
|
||||
import { OverlayBody } from "./ObservationOverlay";
|
||||
import css from "./ObservationOverlay.module.css";
|
||||
import type { ObservationClientStore } from "./observation-store";
|
||||
import { useObservationView, usePip, visibleToScope } from "./observation-view";
|
||||
import { setSidebarMode } from "./sidebar-mode";
|
||||
|
||||
/** The tab title — "Browser Skill", distinct from the sidebar's built-in "browser" tab. */
|
||||
const TAB_TITLE = "Browser Skill";
|
||||
|
||||
/** Tab strip icon: the BrowserSkill product mark at the requested size. */
|
||||
function TabIcon({ size }: { size: number }) {
|
||||
return (
|
||||
<img
|
||||
src={BSK_LOGO_URL}
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className={css["brand-icon"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural mirrors of dsh-better-sidebar's client service surface (only
|
||||
* the slices this integration touches — see the upstream
|
||||
* lib/types/client/service.d.ts). Declared locally so the plugin carries no
|
||||
* dependency on the sidebar package; the service contract has been stable
|
||||
* since v0.4.0 and newer capabilities arrive behind its `features` list.
|
||||
*/
|
||||
export interface SidebarTabLike {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface SidebarLeafLike {
|
||||
kind: "leaf";
|
||||
id: string;
|
||||
tabs: SidebarTabLike[];
|
||||
active: string | null;
|
||||
}
|
||||
|
||||
export interface SidebarSplitLike {
|
||||
kind: "split";
|
||||
id: string;
|
||||
dir: "row" | "col";
|
||||
sizes: number[];
|
||||
children: SidebarNodeLike[];
|
||||
}
|
||||
|
||||
export type SidebarNodeLike = SidebarLeafLike | SidebarSplitLike;
|
||||
|
||||
export interface SidebarStateLike {
|
||||
splits: SidebarNodeLike;
|
||||
bottomSplits: SidebarNodeLike;
|
||||
/** Whether the right panel is expanded (the merged drawer on narrow screens). */
|
||||
panelOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarSnapshotLike {
|
||||
sessionId?: string;
|
||||
state?: SidebarStateLike;
|
||||
}
|
||||
|
||||
/** Props every tab component receives (the slices we read). */
|
||||
export interface TabComponentPropsLike {
|
||||
/** The conversation this sidebar instance belongs to. */
|
||||
scope: { sessionId: string };
|
||||
}
|
||||
|
||||
export interface TabDescriptorLike {
|
||||
id: string;
|
||||
title: string | (() => string);
|
||||
icon?: ReactNode | ((size: number) => ReactNode);
|
||||
order?: number;
|
||||
/** Single-instance: opening focuses the existing tab instead of duplicating. */
|
||||
single?: boolean;
|
||||
/** Small pill on the tab strip; null/undefined hides it. */
|
||||
badge?: (
|
||||
ctx: unknown,
|
||||
scope: { sessionId: string },
|
||||
state: unknown,
|
||||
) => string | number | null | undefined;
|
||||
component: (props: TabComponentPropsLike) => ReactNode;
|
||||
}
|
||||
|
||||
export interface BetterSidebarLike {
|
||||
registerTab(descriptor: TabDescriptorLike): () => void;
|
||||
openTab(seed: {
|
||||
type: string;
|
||||
title?: string;
|
||||
/** Content seed (lands on tab.path); content opens expand the panel. */
|
||||
path?: string;
|
||||
}): void;
|
||||
getSnapshot(): SidebarSnapshotLike;
|
||||
/** Sidebar state feed (v0.12+): session switches, state and prefs changes. */
|
||||
subscribeState?: (listener: () => void) => () => void;
|
||||
isTabEnabled(id: string): boolean;
|
||||
}
|
||||
|
||||
/** The registered tab type id (also the SidebarTab.type value). */
|
||||
export const OBSERVATION_TAB_TYPE = "browserskill:observation";
|
||||
|
||||
/**
|
||||
* Inert content seed carried on auto-opened tabs: its mere presence makes
|
||||
* the sidebar treat the open as a content open (expanding the hosting panel
|
||||
* so the tracking view lands in sight). Never read by our component.
|
||||
*/
|
||||
export const OBSERVATION_TAB_PATH = "browser-skill:observation";
|
||||
|
||||
/**
|
||||
* The observation tab body: the same OverlayBody the floating card renders,
|
||||
* minus the card chrome (no drag header, no collapse — the sidebar tab bar
|
||||
* owns those), plus the PiP pop-out upgrade. The view is scoped to the
|
||||
* sidebar's conversation: only browser sessions started by it (or its
|
||||
* descendants) show here — the floating card keeps the global view.
|
||||
*/
|
||||
export function ObservationSidebarTab({
|
||||
store,
|
||||
scopeId,
|
||||
}: {
|
||||
store: ObservationClientStore;
|
||||
scopeId: string;
|
||||
}) {
|
||||
const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store, scopeId);
|
||||
const { pipWindow, pipSupported, popOut } = usePip();
|
||||
|
||||
const body = (
|
||||
<OverlayBody
|
||||
store={store}
|
||||
focus={focus}
|
||||
sessions={snapshot.sessions}
|
||||
available={snapshot.available}
|
||||
pinnedId={pinnedId}
|
||||
onTogglePin={onTogglePin}
|
||||
now={now}
|
||||
inPip={pipWindow !== null}
|
||||
onPopOut={pipWindow === null && pipSupported ? () => popOut() : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (pipWindow !== null) {
|
||||
return createPortal(body, pipWindow.document.body);
|
||||
}
|
||||
return <div className={css["sidebar-tab"]}>{body}</div>;
|
||||
}
|
||||
|
||||
function* leafNodes(node: SidebarNodeLike): Generator<SidebarLeafLike> {
|
||||
if (node.kind === "leaf") {
|
||||
yield node;
|
||||
return;
|
||||
}
|
||||
for (const child of node.children) yield* leafNodes(child);
|
||||
}
|
||||
|
||||
/** Whether a tab of our type is already open in either sidebar workbench. */
|
||||
export function observationTabOpen(state: SidebarStateLike | undefined): boolean {
|
||||
if (state === undefined) return false;
|
||||
for (const root of [state.splits, state.bottomSplits]) {
|
||||
for (const leaf of leafNodes(root)) {
|
||||
if (leaf.tabs.some((tab) => tab.type === OBSERVATION_TAB_TYPE)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the observation sidebar tab and flip the carrier flag. Returns
|
||||
* the disposer the cordis fiber invokes when the sidebar service goes away
|
||||
* (plugin unload/HMR): the floating overlay then resumes as the carrier.
|
||||
*/
|
||||
export function registerObservationSidebar(
|
||||
service: BetterSidebarLike,
|
||||
store: ObservationClientStore,
|
||||
): () => void {
|
||||
setSidebarMode(true);
|
||||
// Hold the feed for the whole sidebar lifetime so the auto-open watcher
|
||||
// sees new sessions even while the tab itself is closed.
|
||||
store.acquire();
|
||||
const disposeTab = service.registerTab({
|
||||
id: OBSERVATION_TAB_TYPE,
|
||||
title: TAB_TITLE,
|
||||
icon: (size: number) => createElement(TabIcon, { size }),
|
||||
single: true,
|
||||
// The badge counts only sessions visible to the tab strip's own
|
||||
// conversation (global sessions from other conversations stay hidden,
|
||||
// mirroring the tab body's scoped view).
|
||||
badge: (_ctx, scope) => {
|
||||
const count = store
|
||||
.getSnapshot()
|
||||
.sessions.filter((s) => visibleToScope(s, scope.sessionId)).length;
|
||||
return count > 0 ? count : null;
|
||||
},
|
||||
component: (props) =>
|
||||
createElement(ObservationSidebarTab, { store, scopeId: props.scope.sessionId }),
|
||||
});
|
||||
|
||||
// Auto-open the tab when a browser session VISIBLE TO the active
|
||||
// conversation appears (and right away when one is already live). The
|
||||
// evaluation runs on observation publishes AND on sidebar state changes:
|
||||
// the latter covers the sidebar store's async init (its state is
|
||||
// undefined for the first beats after page load) and conversation
|
||||
// switches. The open carries a content seed (`path`): only content opens
|
||||
// land in sight — the sidebar expands the hosting panel for them. While
|
||||
// the panel is OPEN an existing tab is never re-focused — the user may
|
||||
// be reading another page on purpose; while it is collapsed a NEW
|
||||
// session (0→N) nudges the tab back into sight, mirroring how the
|
||||
// floating card used to reappear.
|
||||
const activeVisibleCount = (): number => {
|
||||
const activeId = service.getSnapshot().sessionId;
|
||||
if (activeId === undefined) return 0;
|
||||
return store.getSnapshot().sessions.filter((s) => visibleToScope(s, activeId)).length;
|
||||
};
|
||||
let previousVisible = activeVisibleCount();
|
||||
const evaluate = (): void => {
|
||||
const count = activeVisibleCount();
|
||||
const { state, sessionId } = service.getSnapshot();
|
||||
if (
|
||||
state !== undefined &&
|
||||
sessionId !== undefined &&
|
||||
service.isTabEnabled(OBSERVATION_TAB_TYPE)
|
||||
) {
|
||||
const open = observationTabOpen(state);
|
||||
if (count > 0 && !open) {
|
||||
service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH });
|
||||
} else if (previousVisible === 0 && count > 0 && open && state.panelOpen === false) {
|
||||
service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH });
|
||||
}
|
||||
}
|
||||
previousVisible = count;
|
||||
};
|
||||
evaluate();
|
||||
const unsubscribe = store.subscribe(evaluate);
|
||||
const unsubscribeState = service.subscribeState?.(evaluate);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeState?.();
|
||||
disposeTab();
|
||||
store.release();
|
||||
setSidebarMode(false);
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export interface OverlaySnapshot {
|
||||
const STATE_URL = "/bsk-observation/state";
|
||||
const EVENTS_URL = "/bsk-observation/events";
|
||||
const INTERRUPT_URL = "/bsk-observation/interrupt";
|
||||
const STOP_URL = "/bsk-observation/stop";
|
||||
|
||||
function revoke(url: string | undefined): void {
|
||||
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
|
||||
@@ -78,6 +79,8 @@ export class ObservationClientStore {
|
||||
};
|
||||
private available = true;
|
||||
private started = false;
|
||||
/** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */
|
||||
private consumers = 0;
|
||||
|
||||
constructor(private readonly deps: ObservationClientDeps) {}
|
||||
|
||||
@@ -189,6 +192,23 @@ export class ObservationClientStore {
|
||||
this.publish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the feed for one consumer's lifetime: the stream starts with the
|
||||
* first holder and stops with the last release. Several carriers can share
|
||||
* the store (the floating card, the better-sidebar tab, and the sidebar
|
||||
* integration fiber) without one unmount killing the others' updates.
|
||||
*/
|
||||
acquire(): void {
|
||||
this.consumers += 1;
|
||||
if (this.consumers === 1) this.start();
|
||||
}
|
||||
|
||||
release(): void {
|
||||
if (this.consumers === 0) return;
|
||||
this.consumers -= 1;
|
||||
if (this.consumers === 0) this.stop();
|
||||
}
|
||||
|
||||
/** Forget one session's tracked + held frames, revoking their blob URLs. */
|
||||
private dropThumb(sessionId: string): void {
|
||||
const attachmentId = this.thumbBySession.get(sessionId);
|
||||
@@ -292,4 +312,23 @@ export class ObservationClientStore {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop one session and close its Agent Window. The session's removal
|
||||
* arrives through the SSE remove event — no local state is touched here.
|
||||
*/
|
||||
async stopSession(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.deps.fetchFn(STOP_URL, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { stopped?: boolean };
|
||||
return body.stopped === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Shared view logic for the observation carriers (the floating overlay card
|
||||
* and the better-sidebar tab): the store-backed view model (snapshot, focus
|
||||
* pinning, elapsed ticker) and the Document PiP pop-out. Extracted from
|
||||
* ObservationOverlay so both carriers run the same focus/interrupt behavior
|
||||
* without duplicating hooks.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import type { SessionObservation } from "../observation";
|
||||
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
|
||||
|
||||
/**
|
||||
* Auto-follow focus: the most recently touched session, but never yank focus
|
||||
* to a session whose latest action failed (errors flag the strip item, they
|
||||
* do not steal the stage) or one already reported dead.
|
||||
*/
|
||||
export function focusOf(sessions: readonly SessionObservation[]): SessionObservation | undefined {
|
||||
if (sessions.length === 0) return undefined;
|
||||
const byRecency = [...sessions].sort((a, b) => b.since - a.since);
|
||||
const healthy = byRecency.find((s) => s.lastError === undefined && s.dead !== true);
|
||||
return healthy ?? byRecency[0];
|
||||
}
|
||||
|
||||
export function statusOf(obs: SessionObservation): "active" | "idle" | "error" {
|
||||
if (obs.lastError !== undefined && obs.action === "idle") return "error";
|
||||
return obs.action === "idle" ? "idle" : "active";
|
||||
}
|
||||
|
||||
/** Minimal Document PiP surface (TS lib.dom lacks it). */
|
||||
export interface DocumentPip {
|
||||
requestWindow(options?: { width?: number; height?: number }): Promise<Window>;
|
||||
}
|
||||
|
||||
export function pipApi(): DocumentPip | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return (window as unknown as { documentPictureInPicture?: DocumentPip }).documentPictureInPicture;
|
||||
}
|
||||
|
||||
/** Clone the host document's style/link nodes into a PiP window. */
|
||||
export function cloneStylesInto(pipWindow: Window): void {
|
||||
for (const node of document.querySelectorAll('link[rel="stylesheet"], style')) {
|
||||
pipWindow.document.head.appendChild(node.cloneNode(true));
|
||||
}
|
||||
}
|
||||
|
||||
export interface ObservationView {
|
||||
readonly snapshot: OverlaySnapshot;
|
||||
/** Pinned session wins while it still exists; otherwise auto-follow. */
|
||||
readonly focus: SessionObservation | undefined;
|
||||
readonly pinnedId: string | null;
|
||||
readonly onTogglePin: (sessionId: string) => void;
|
||||
/** 1s ticker while anything is active (drives the elapsed readout). */
|
||||
readonly now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one session is visible on a surface scoped to one DSH
|
||||
* conversation: sessions started by that conversation or its descendants
|
||||
* (the lineage ancestors ride along on the entry). Untracked sessions (no
|
||||
* owner recorded) are hidden from scoped surfaces but stay in global views.
|
||||
*/
|
||||
export function visibleToScope(obs: SessionObservation, scopeId: string): boolean {
|
||||
return obs.dshSessionIds?.includes(scopeId) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The store-backed observation view model. Holds the feed for the component
|
||||
* lifetime (refcounted — overlapping carriers never kill each other's
|
||||
* stream). `scopeId` narrows the view to one DSH conversation's sessions
|
||||
* (the better-sidebar tab); undefined keeps the global view (floating
|
||||
* card, PiP).
|
||||
*/
|
||||
export function useObservationView(
|
||||
store: ObservationClientStore,
|
||||
scopeId?: string,
|
||||
): ObservationView {
|
||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||
useEffect(() => {
|
||||
store.acquire();
|
||||
return () => store.release();
|
||||
}, [store]);
|
||||
|
||||
const scopedSnapshot: OverlaySnapshot =
|
||||
scopeId === undefined
|
||||
? snapshot
|
||||
: {
|
||||
...snapshot,
|
||||
sessions: snapshot.sessions.filter((s) => visibleToScope(s, scopeId)),
|
||||
};
|
||||
|
||||
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
||||
const pinned =
|
||||
pinnedId !== null ? scopedSnapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined;
|
||||
const focus = pinned ?? focusOf(scopedSnapshot.sessions);
|
||||
const onTogglePin = useCallback((sessionId: string) => {
|
||||
setPinnedId((current) => (current === sessionId ? null : sessionId));
|
||||
}, []);
|
||||
|
||||
const anyActive = scopedSnapshot.sessions.some((s) => s.action !== "idle");
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!anyActive) return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [anyActive]);
|
||||
|
||||
return {
|
||||
snapshot: scopedSnapshot,
|
||||
focus,
|
||||
pinnedId: pinned !== undefined ? pinnedId : null,
|
||||
onTogglePin,
|
||||
now,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PipHandle {
|
||||
/** The open PiP window, or null while the content renders in its carrier. */
|
||||
readonly pipWindow: Window | null;
|
||||
/** Whether the browser exposes Document PiP (Chrome-only today). */
|
||||
readonly pipSupported: boolean;
|
||||
/**
|
||||
* Upgrade the content into a native PiP window. Rejects silently without a
|
||||
* user gesture (or when closed mid-request): the carrier keeps the content.
|
||||
*/
|
||||
readonly popOut: (size?: { width: number; height: number }) => void;
|
||||
}
|
||||
|
||||
export function usePip(): PipHandle {
|
||||
const [pipWindow, setPipWindow] = useState<Window | null>(null);
|
||||
const popOut = useCallback((size?: { width: number; height: number }): void => {
|
||||
const pip = pipApi();
|
||||
if (pip === undefined) return;
|
||||
void pip
|
||||
.requestWindow(size)
|
||||
.then((win) => {
|
||||
cloneStylesInto(win);
|
||||
win.addEventListener("pagehide", () => setPipWindow(null));
|
||||
setPipWindow(win);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
// Unmounting the carrier (tab close, conversation switch, plugin HMR) does
|
||||
// not destroy the PiP browsing context: close it ourselves or it survives
|
||||
// as a blank window. close() on an already-closed window is a no-op.
|
||||
useEffect(() => () => pipWindow?.close(), [pipWindow]);
|
||||
return { pipWindow, pipSupported: pipApi() !== undefined, popOut };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Carrier switch for the observation view: while the better-sidebar
|
||||
* integration fiber is alive (the dsh-better-sidebar plugin provides its
|
||||
* `betterSidebar` service), the tracking view lives in a sidebar tab and the
|
||||
* floating overlay card/capsule hides itself. The flag is a tiny external
|
||||
* store so the overlay can read it through useSyncExternalStore and flip
|
||||
* without a remount when the sidebar plugin (un)loads.
|
||||
*/
|
||||
|
||||
let active = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getSidebarMode(): boolean {
|
||||
return active;
|
||||
}
|
||||
|
||||
export function subscribeSidebarMode(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function setSidebarMode(next: boolean): void {
|
||||
if (active === next) return;
|
||||
active = next;
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import Schema from "@deepseek-ai/schemastery";
|
||||
import { armArchiveCleanup } from "./archive-cleanup";
|
||||
import { armLazyTools } from "./lazy-tools";
|
||||
import { ObservationService } from "./observation";
|
||||
import { registerObservationRoutes } from "./observation-http";
|
||||
@@ -107,6 +108,10 @@ export function apply(
|
||||
removeRoutes = registerObservationRoutes(injected, observation);
|
||||
return () => removeRoutes();
|
||||
});
|
||||
// Reap a conversation's browsers when the conversation itself is archived:
|
||||
// archived sessions are hidden from every surface, so their Agent Windows
|
||||
// would otherwise linger unreachable until idle timeout or unload.
|
||||
const disarmArchiveCleanup = armArchiveCleanup(ctx, registry, observation);
|
||||
|
||||
// Non-blocking install probe: warn early when bsk is missing instead of
|
||||
// failing the first tool call with a bare spawn error. Uses --version on
|
||||
@@ -132,6 +137,7 @@ export function apply(
|
||||
removeSuite();
|
||||
unregisterSkill();
|
||||
removeRoutes();
|
||||
disarmArchiveCleanup();
|
||||
observation.dispose();
|
||||
runner.killAll();
|
||||
const stops = registry
|
||||
@@ -144,6 +150,7 @@ export function apply(
|
||||
});
|
||||
}
|
||||
|
||||
export { armArchiveCleanup, ownerSessionIds } from "./archive-cleanup";
|
||||
export type { ObservationEvent, ObservationOptions, SessionObservation } from "./observation";
|
||||
export { ObservationService } from "./observation";
|
||||
export { registerObservationRoutes } from "./observation-http";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* GET /bsk-observation/state → { sessions, available }
|
||||
* GET /bsk-observation/events → SSE stream of ObservationEvent
|
||||
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
|
||||
* POST /bsk-observation/stop → body {sessionId} → {stopped: boolean}
|
||||
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes
|
||||
*
|
||||
* Routes exist only when a webServer service is mounted (web composition);
|
||||
@@ -166,6 +167,42 @@ export function registerObservationRoutes(
|
||||
});
|
||||
},
|
||||
}),
|
||||
webServer.register({
|
||||
kind: "exact",
|
||||
path: `${ROUTE_BASE}/stop`,
|
||||
handler: (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
sendJson(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
if (fenceRejected(req, res)) return;
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(body || "{}") as { sessionId?: unknown };
|
||||
if (typeof parsed.sessionId === "string" && parsed.sessionId !== "") {
|
||||
sessionId = parsed.sessionId;
|
||||
}
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
// A destructive call always names its target (never "current").
|
||||
if (sessionId === undefined) {
|
||||
sendJson(res, 400, { error: "sessionId required" });
|
||||
return;
|
||||
}
|
||||
void observation.stopSession(sessionId).then(
|
||||
(stopped) => sendJson(res, 200, { stopped }),
|
||||
() => sendJson(res, 500, { error: "stop failed" }),
|
||||
);
|
||||
});
|
||||
},
|
||||
}),
|
||||
webServer.register({
|
||||
kind: "prefix",
|
||||
path: `${ROUTE_BASE}/thumbnail`,
|
||||
|
||||
@@ -39,6 +39,13 @@ export interface SessionObservation {
|
||||
* greys it out until it is stopped/removed. No more frames are requested.
|
||||
*/
|
||||
dead?: boolean;
|
||||
/**
|
||||
* The DSH conversations this session belongs to (the starting agent's
|
||||
* session plus its seed-lineage ancestors), recorded at start. Scoped
|
||||
* surfaces (the better-sidebar tab) filter by it; absent means untracked
|
||||
* ownership — visible only in the global (unscoped) view.
|
||||
*/
|
||||
dshSessionIds?: string[];
|
||||
}
|
||||
|
||||
/** Incremental event carried to subscribers (SSE on the wire). */
|
||||
@@ -153,11 +160,13 @@ export class ObservationService {
|
||||
/** Register a fresh owned session (called from browser_session_start). */
|
||||
addSession(sessionId: string, url?: string): void {
|
||||
if (!this.deps.options.enabled || this.disposed) return;
|
||||
const dshSessionIds = this.deps.registry.dshOwnersOf(sessionId);
|
||||
this.put({
|
||||
sessionId,
|
||||
...(url !== undefined ? { url } : {}),
|
||||
action: "idle",
|
||||
since: this.scheduler.now(),
|
||||
...(dshSessionIds.length > 0 ? { dshSessionIds } : {}),
|
||||
});
|
||||
this.lastActivity.set(sessionId, this.scheduler.now());
|
||||
this.scheduleCapture(sessionId, 0);
|
||||
@@ -228,6 +237,36 @@ export class ObservationService {
|
||||
return this.deps.runner.killFor(target) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop one owned session and close its Agent Window (the overlay's stop
|
||||
* button — same end state as `browser_session_stop`). Never waits behind a
|
||||
* hung in-flight command: tool children are killed first so the session's
|
||||
* keyed queue drains immediately, and no further captures queue up. A
|
||||
* session the daemon already forgot (dead strip entries) stops
|
||||
* idempotently — the goal state (entry gone) is identical.
|
||||
* @returns whether the session ended up stopped/removed.
|
||||
*/
|
||||
async stopSession(sessionId: string): Promise<boolean> {
|
||||
if (!this.deps.registry.isOwned(sessionId)) return false;
|
||||
this.cancelCapture(sessionId);
|
||||
this.deps.runner.killFor(sessionId);
|
||||
const result = await this.deps.queue.run(sessionId, () =>
|
||||
this.deps.runner.run(["session", "stop", sessionId], { timeoutMs: 30_000 }),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
let code: string | undefined;
|
||||
try {
|
||||
code = (JSON.parse(result.stdout) as { code?: string }).code;
|
||||
} catch {
|
||||
code = undefined;
|
||||
}
|
||||
if (code !== "session_not_found") return false;
|
||||
}
|
||||
this.deps.registry.remove(sessionId);
|
||||
this.removeSession(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one captured thumbnail from the in-process ring. Powers the plugin's
|
||||
* own HTTP thumbnail route — frames are plugin-owned runtime data, never
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface TrackedSession {
|
||||
export class SessionRegistry {
|
||||
private readonly sessions = new Map<string, TrackedSession>();
|
||||
private currentId: string | undefined;
|
||||
/**
|
||||
* bsk session id → the DSH session ids that may clean it up: the agent
|
||||
* session that started it plus every ancestor along the seed lineage, so
|
||||
* archiving a conversation at ANY level of the chain reaps its browsers
|
||||
* (see archive-cleanup.ts).
|
||||
*/
|
||||
private readonly dshOwners = new Map<string, ReadonlySet<string>>();
|
||||
/**
|
||||
* Slots reserved by in-flight starts. reserveStart/completeStart/abandonStart
|
||||
* run synchronously around the async spawn, so concurrent starts can never
|
||||
@@ -72,12 +79,37 @@ export class SessionRegistry {
|
||||
/** Forget a session; falls back to the most recent remaining one. */
|
||||
remove(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
this.dshOwners.delete(sessionId);
|
||||
if (this.currentId === sessionId) {
|
||||
const rest = [...this.sessions.values()];
|
||||
this.currentId = rest.length > 0 ? rest[rest.length - 1].sessionId : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record which DSH conversation(s) a freshly started bsk session belongs
|
||||
* to (the starting agent's session plus its ancestors). No-op without ids
|
||||
* — e.g. a start whose caller carried no agent identity.
|
||||
*/
|
||||
trackOwner(sessionId: string, dshSessionIds: readonly string[]): void {
|
||||
if (dshSessionIds.length === 0 || !this.sessions.has(sessionId)) return;
|
||||
this.dshOwners.set(sessionId, new Set(dshSessionIds));
|
||||
}
|
||||
|
||||
/** bsk session ids owned by the given DSH conversation (or its descendants). */
|
||||
ownedByDsh(dshSessionId: string): string[] {
|
||||
const owned: string[] = [];
|
||||
for (const [sessionId, owners] of this.dshOwners) {
|
||||
if (owners.has(dshSessionId)) owned.push(sessionId);
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
/** The DSH conversation ids owning one bsk session (empty when untracked). */
|
||||
dshOwnersOf(sessionId: string): string[] {
|
||||
return [...(this.dshOwners.get(sessionId) ?? [])];
|
||||
}
|
||||
|
||||
/** Mark an owned session as most recently used (recency order refresh). */
|
||||
private touch(sessionId: string): void {
|
||||
const existing = this.sessions.get(sessionId);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ToolResult,
|
||||
type ToolRunContext,
|
||||
} from "@deepseek-ai/dsh-tools";
|
||||
import { ownerSessionIds } from "./archive-cleanup";
|
||||
import { trySaveScreenshot } from "./image";
|
||||
import { actionForLabel, type ObservationService } from "./observation";
|
||||
import type { KeyedExecutor } from "./queue";
|
||||
@@ -245,6 +246,9 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
browserInstanceId: reply.browser_instance_id,
|
||||
startedAtMs: Date.now(),
|
||||
});
|
||||
// Ownership for archive cleanup: the calling conversation and its
|
||||
// ancestors reap this session when any of them is archived.
|
||||
registry.trackOwner(reply.session_id, ownerSessionIds(deps.ctx, exec.agent?.id));
|
||||
deps.observation.addSession(reply.session_id, args.url);
|
||||
try {
|
||||
if (args.device !== undefined) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Archive-triggered cleanup: lineage resolution for browser-session
|
||||
// ownership, the registry's owner index, and the domain/changed watcher
|
||||
// that reaps a freshly archived conversation's bsk sessions.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { armArchiveCleanup, ownerSessionIds } from "../src/archive-cleanup";
|
||||
import type { ObservationService } from "../src/observation";
|
||||
import { SessionRegistry } from "../src/sessions";
|
||||
|
||||
/** A ctx stub carrying a session store with the given lineage headers. */
|
||||
function ctxWithSessions(headers: Record<string, { parentSession?: string }>) {
|
||||
return {
|
||||
get: (key: string) =>
|
||||
key === "sessions"
|
||||
? {
|
||||
get: (id: string) => (headers[id] === undefined ? undefined : { header: headers[id] }),
|
||||
}
|
||||
: undefined,
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("ownerSessionIds", () => {
|
||||
it("walks the seed lineage to the root; empty without an agent identity", () => {
|
||||
expect(ownerSessionIds(ctxWithSessions({}), undefined)).toEqual([]);
|
||||
const ctx = ctxWithSessions({
|
||||
child: { parentSession: "parent" },
|
||||
parent: { parentSession: "root" },
|
||||
root: {},
|
||||
});
|
||||
expect(ownerSessionIds(ctx, "child")).toEqual(["child", "parent", "root"]);
|
||||
});
|
||||
|
||||
it("stops the walk at an unloaded ancestor", () => {
|
||||
const ctx = ctxWithSessions({ child: { parentSession: "gone" } });
|
||||
expect(ownerSessionIds(ctx, "child")).toEqual(["child", "gone"]);
|
||||
});
|
||||
|
||||
it("never loops on a malformed parent cycle", () => {
|
||||
const ctx = ctxWithSessions({
|
||||
a: { parentSession: "b" },
|
||||
b: { parentSession: "a" },
|
||||
});
|
||||
expect(ownerSessionIds(ctx, "a")).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRegistry owner tracking", () => {
|
||||
function start(registry: SessionRegistry, sessionId: string): void {
|
||||
registry.reserveStart();
|
||||
registry.completeStart({ sessionId, startedAtMs: 1 });
|
||||
}
|
||||
|
||||
it("indexes owners and forgets them on remove; ignores empty/unknown ownership", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
start(registry, "bsk1");
|
||||
start(registry, "bsk2");
|
||||
registry.trackOwner("bsk1", ["conv-a", "root"]);
|
||||
registry.trackOwner("bsk2", ["conv-b"]);
|
||||
expect(registry.ownedByDsh("root")).toEqual(["bsk1"]);
|
||||
expect(registry.ownedByDsh("conv-a")).toEqual(["bsk1"]);
|
||||
expect(registry.ownedByDsh("conv-b")).toEqual(["bsk2"]);
|
||||
expect(registry.ownedByDsh("nobody")).toEqual([]);
|
||||
registry.remove("bsk1");
|
||||
expect(registry.ownedByDsh("root")).toEqual([]);
|
||||
// Empty owner lists and unknown session ids record nothing.
|
||||
registry.trackOwner("bsk2", []);
|
||||
registry.trackOwner("ghost", ["conv-c"]);
|
||||
expect(registry.ownedByDsh("conv-c")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("armArchiveCleanup", () => {
|
||||
function harness(opts: { archived?: string[] } = {}) {
|
||||
const registry = new SessionRegistry(5);
|
||||
const observation = { stopSession: vi.fn(async () => true) };
|
||||
const listeners = new Set<(change: unknown) => void>();
|
||||
const ctx = {
|
||||
get: (key: string) =>
|
||||
key === "workspaceRegistry" ? { archivedSessionIds: opts.archived ?? [] } : undefined,
|
||||
on: (event: string, listener: (change: unknown) => void) => {
|
||||
if (event === "domain/changed") listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
const emit = (change: unknown) => {
|
||||
for (const listener of [...listeners]) listener(change);
|
||||
};
|
||||
return {
|
||||
registry,
|
||||
observation: observation as unknown as ObservationService,
|
||||
stopSession: observation.stopSession,
|
||||
emit,
|
||||
arm: () =>
|
||||
armArchiveCleanup(ctx as never, registry, observation as unknown as ObservationService),
|
||||
};
|
||||
}
|
||||
|
||||
function startOwned(registry: SessionRegistry, bskId: string, owners: string[]): void {
|
||||
registry.reserveStart();
|
||||
registry.completeStart({ sessionId: bskId, startedAtMs: 1 });
|
||||
registry.trackOwner(bskId, owners);
|
||||
}
|
||||
|
||||
it("stops every bsk session owned by a freshly archived conversation, until disarmed", () => {
|
||||
const h = harness();
|
||||
startOwned(h.registry, "bsk1", ["conv-a", "root"]);
|
||||
startOwned(h.registry, "bsk2", ["conv-b"]);
|
||||
const disarm = h.arm();
|
||||
h.emit({
|
||||
domain: "workspace",
|
||||
table: "",
|
||||
value: { archivedSessionIds: ["root"] },
|
||||
});
|
||||
expect(h.stopSession).toHaveBeenCalledTimes(1);
|
||||
expect(h.stopSession).toHaveBeenCalledWith("bsk1");
|
||||
// After the disposer runs the watcher is silent again.
|
||||
disarm();
|
||||
h.emit({
|
||||
domain: "workspace",
|
||||
table: "",
|
||||
value: { archivedSessionIds: ["root", "conv-b"] },
|
||||
});
|
||||
expect(h.stopSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores pre-archived ids, foreign domains, and malformed frames", () => {
|
||||
const h = harness({ archived: ["old-conv"] });
|
||||
startOwned(h.registry, "bsk1", ["old-conv"]);
|
||||
startOwned(h.registry, "bsk2", ["conv-a"]);
|
||||
h.arm();
|
||||
// Seeded from the registry: the pre-archived id must not retro-fire.
|
||||
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["old-conv"] } });
|
||||
h.emit({ domain: "settings", table: "", value: { archivedSessionIds: ["conv-a"] } });
|
||||
h.emit({ domain: "workspace", table: "rows", value: { archivedSessionIds: ["conv-a"] } });
|
||||
h.emit({ domain: "workspace", table: "", value: {} });
|
||||
expect(h.stopSession).not.toHaveBeenCalled();
|
||||
// A genuinely new archive still fires.
|
||||
h.emit({
|
||||
domain: "workspace",
|
||||
table: "",
|
||||
value: { archivedSessionIds: ["old-conv", "conv-a"] },
|
||||
});
|
||||
expect(h.stopSession).toHaveBeenCalledWith("bsk2");
|
||||
});
|
||||
|
||||
it("treats a re-archived session as fresh again after unarchive", () => {
|
||||
const h = harness();
|
||||
startOwned(h.registry, "bsk1", ["conv-a"]);
|
||||
h.arm();
|
||||
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } });
|
||||
expect(h.stopSession).toHaveBeenCalledTimes(1);
|
||||
// Unarchive, then re-archive: the second archival cleans up again.
|
||||
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: [] } });
|
||||
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } });
|
||||
expect(h.stopSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
// upgrade/fallback (mocked documentPictureInPicture). NOTE: use RTL's waitFor
|
||||
// (act-flushing), not vi.waitFor, when asserting store-driven UI updates.
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { applyResize, ObservationOverlay } from "../../src/client/ObservationOverlay";
|
||||
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
||||
@@ -157,6 +157,45 @@ describe("ObservationOverlay", () => {
|
||||
await screen.findByText(/s1 · clicking/);
|
||||
});
|
||||
|
||||
it("stop session: the first click only arms the confirm, the second posts", async () => {
|
||||
const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]);
|
||||
render(<ObservationOverlay store={h.store} />);
|
||||
await screen.findByText(/s1 · idle/);
|
||||
const stop = screen.getByRole("button", { name: "Stop session s1" });
|
||||
fireEvent.click(stop);
|
||||
// Armed: a plain click must never close an Agent Window by itself.
|
||||
expect(h.fetches.some((f) => f.url === "/bsk-observation/stop")).toBe(false);
|
||||
const confirm = await screen.findByRole("button", { name: "Confirm stop session s1" });
|
||||
expect(confirm.getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
h.fetches.some(
|
||||
(f) => f.url === "/bsk-observation/stop" && f.init?.body === '{"sessionId":"s1"}',
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
it("disarms the stop confirm after the expiry window", async () => {
|
||||
const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]);
|
||||
render(<ObservationOverlay store={h.store} />);
|
||||
const stop = await screen.findByRole("button", { name: "Stop session s1" });
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
fireEvent.click(stop);
|
||||
// Synchronous queries only: waitFor-style finds hang under fake timers.
|
||||
expect(screen.getByRole("button", { name: "Confirm stop session s1" })).toBeTruthy();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3100);
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Stop session s1" })).toBeTruthy();
|
||||
expect(h.fetches.some((f) => f.url === "/bsk-observation/stop")).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resizes within min/max clamps", async () => {
|
||||
const h = makeHarness([BUSY]);
|
||||
render(<ObservationOverlay store={h.store} />);
|
||||
@@ -222,6 +261,7 @@ describe("ObservationOverlay", () => {
|
||||
addEventListener: (name: string, fn: () => void) => {
|
||||
listeners.set(name, [...(listeners.get(name) ?? []), fn]);
|
||||
},
|
||||
close: vi.fn(),
|
||||
} as unknown as Window;
|
||||
});
|
||||
(window as unknown as Record<string, unknown>).documentPictureInPicture = { requestWindow };
|
||||
@@ -236,6 +276,28 @@ describe("ObservationOverlay", () => {
|
||||
await screen.findByTestId("obs-card");
|
||||
});
|
||||
|
||||
it("closes the PiP window when the carrier unmounts", async () => {
|
||||
const h = makeHarness([BUSY]);
|
||||
const pipDoc = document.implementation.createHTMLDocument("pip");
|
||||
const close = vi.fn();
|
||||
const requestWindow = vi.fn(async () => {
|
||||
return {
|
||||
document: pipDoc,
|
||||
addEventListener: () => {},
|
||||
close,
|
||||
} as unknown as Window;
|
||||
});
|
||||
(window as unknown as Record<string, unknown>).documentPictureInPicture = { requestWindow };
|
||||
const { unmount } = render(<ObservationOverlay store={h.store} />);
|
||||
const popout = await screen.findByRole("button", { name: /Pop out/ });
|
||||
fireEvent.click(popout);
|
||||
await waitFor(() => expect(pipDoc.body.textContent).toContain("s1"));
|
||||
// Unmounting (tab close, conversation switch, plugin HMR) closes the PiP
|
||||
// instead of leaving a blank window behind.
|
||||
unmount();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the strip for two sessions and pins focus on click", async () => {
|
||||
const older: SessionObservation = {
|
||||
sessionId: "s1",
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
// @vitest-environment happy-dom
|
||||
// better-sidebar carrier: tab registration + auto-open, the sidebar-mode
|
||||
// flag hiding the floating overlay, PiP pop-out from the tab, and the
|
||||
// store's refcounted acquire/release across overlapping carriers.
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ObservationOverlay } from "../../src/client/ObservationOverlay";
|
||||
import {
|
||||
type BetterSidebarLike,
|
||||
OBSERVATION_TAB_PATH,
|
||||
OBSERVATION_TAB_TYPE,
|
||||
ObservationSidebarTab,
|
||||
observationTabOpen,
|
||||
registerObservationSidebar,
|
||||
type SidebarNodeLike,
|
||||
type SidebarStateLike,
|
||||
type TabDescriptorLike,
|
||||
} from "../../src/client/observation-sidebar";
|
||||
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
||||
import { getSidebarMode, setSidebarMode } from "../../src/client/sidebar-mode";
|
||||
import type { SessionObservation } from "../../src/observation";
|
||||
|
||||
// Owned by the sidebar mock's active conversation ("conv-1") so the scoped
|
||||
// visibility logic lets it through; FOREIGN belongs to another conversation.
|
||||
const BUSY: SessionObservation = {
|
||||
sessionId: "s1",
|
||||
action: "clicking",
|
||||
since: Date.now() - 7000,
|
||||
dshSessionIds: ["conv-1"],
|
||||
};
|
||||
const FOREIGN: SessionObservation = {
|
||||
sessionId: "s9",
|
||||
action: "clicking",
|
||||
since: Date.now() - 5000,
|
||||
dshSessionIds: ["other-conv"],
|
||||
};
|
||||
|
||||
interface Harness {
|
||||
store: ObservationClientStore;
|
||||
es: () => EventSourceLike | undefined;
|
||||
push: (sessions: SessionObservation[]) => void;
|
||||
}
|
||||
|
||||
function makeHarness(initial: SessionObservation[]): Harness {
|
||||
let es: EventSourceLike | undefined;
|
||||
let current = initial;
|
||||
const store = new ObservationClientStore({
|
||||
fetchFn: async (url: string) => {
|
||||
if (url === "/bsk-observation/state") {
|
||||
return { ok: true, json: async () => ({ sessions: current }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({ interrupted: true }) };
|
||||
},
|
||||
eventSourceFactory: () => {
|
||||
es = { onmessage: null, close: vi.fn() };
|
||||
return es;
|
||||
},
|
||||
loadImage: async (id: string) => `blob:${id}`,
|
||||
});
|
||||
return {
|
||||
store,
|
||||
es: () => es,
|
||||
push: (sessions) => {
|
||||
current = sessions;
|
||||
for (const s of sessions) {
|
||||
es?.onmessage?.({ data: JSON.stringify({ type: "upsert", session: s }) });
|
||||
}
|
||||
if (sessions.length === 0) es?.onmessage?.({ data: JSON.stringify({ type: "reset" }) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function leafWith(type: string, panelOpen = true): SidebarStateLike {
|
||||
return {
|
||||
panelOpen,
|
||||
splits: {
|
||||
kind: "split",
|
||||
id: "root",
|
||||
dir: "row",
|
||||
sizes: [1],
|
||||
children: [
|
||||
{
|
||||
kind: "leaf",
|
||||
id: "pane-1",
|
||||
active: type,
|
||||
tabs: [{ id: type, type, title: type }],
|
||||
},
|
||||
],
|
||||
},
|
||||
bottomSplits: { kind: "leaf", id: "pane-2", active: null, tabs: [] },
|
||||
};
|
||||
}
|
||||
|
||||
interface SidebarMock {
|
||||
service: BetterSidebarLike;
|
||||
descriptor: () => TabDescriptorLike;
|
||||
openTab: ReturnType<typeof vi.fn>;
|
||||
disposeTab: ReturnType<typeof vi.fn>;
|
||||
setState: (state: SidebarStateLike | undefined) => void;
|
||||
}
|
||||
|
||||
/** Insert a tab into the first leaf of the main tree (mirrors a real open). */
|
||||
function withTabOpened(state: SidebarStateLike, type: string): SidebarStateLike {
|
||||
let done = false;
|
||||
const walk = (node: SidebarNodeLike): SidebarNodeLike => {
|
||||
if (done) return node;
|
||||
if (node.kind === "leaf") {
|
||||
done = true;
|
||||
return { ...node, active: type, tabs: [...node.tabs, { id: type, type, title: type }] };
|
||||
}
|
||||
return { ...node, children: node.children.map(walk) };
|
||||
};
|
||||
return { ...state, splits: walk(state.splits) };
|
||||
}
|
||||
|
||||
function makeSidebar(
|
||||
initialState: SidebarStateLike | undefined = leafWith("explorer"),
|
||||
): SidebarMock {
|
||||
let descriptor: TabDescriptorLike | undefined;
|
||||
let state: SidebarStateLike | undefined = initialState;
|
||||
const openTab = vi.fn((seed: { type: string }) => {
|
||||
// Mirror the real feedback loop: a created tab lands in the state, so
|
||||
// later evaluations see it open (single-instance dedupe re-focuses).
|
||||
if (state !== undefined && !observationTabOpen(state)) {
|
||||
state = withTabOpened(state, seed.type);
|
||||
}
|
||||
});
|
||||
const disposeTab = vi.fn();
|
||||
const service: BetterSidebarLike = {
|
||||
registerTab: (next) => {
|
||||
descriptor = next;
|
||||
return disposeTab;
|
||||
},
|
||||
openTab,
|
||||
getSnapshot: () => ({ sessionId: "conv-1", state }),
|
||||
isTabEnabled: () => true,
|
||||
};
|
||||
return {
|
||||
service,
|
||||
descriptor: () => {
|
||||
if (descriptor === undefined) throw new Error("no tab registered");
|
||||
return descriptor;
|
||||
},
|
||||
openTab,
|
||||
disposeTab,
|
||||
setState: (next) => {
|
||||
state = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setSidebarMode(false);
|
||||
});
|
||||
|
||||
describe("registerObservationSidebar", () => {
|
||||
it("registers a single-instance tab and holds the feed for its lifetime", () => {
|
||||
const h = makeHarness([]);
|
||||
const sidebar = makeSidebar();
|
||||
expect(getSidebarMode()).toBe(false);
|
||||
const dispose = registerObservationSidebar(sidebar.service, h.store);
|
||||
expect(getSidebarMode()).toBe(true);
|
||||
// The fiber holds the observation feed itself, so the auto-open watcher
|
||||
// sees new sessions even while the tab is closed.
|
||||
expect(h.es()).toBeDefined();
|
||||
const descriptor = sidebar.descriptor();
|
||||
expect(descriptor.id).toBe(OBSERVATION_TAB_TYPE);
|
||||
expect(descriptor.single).toBe(true);
|
||||
expect(descriptor.title).toBe("Browser Skill");
|
||||
dispose();
|
||||
expect(getSidebarMode()).toBe(false);
|
||||
expect(sidebar.disposeTab).toHaveBeenCalledTimes(1);
|
||||
expect(h.es()?.close).toHaveBeenCalled?.();
|
||||
});
|
||||
|
||||
it("opens the tab right away when a session is already live at activation", async () => {
|
||||
const h = makeHarness([BUSY]);
|
||||
const sidebar = makeSidebar();
|
||||
registerObservationSidebar(sidebar.service, h.store);
|
||||
// The initial state fetch lands asynchronously; the 0→N watcher then fires.
|
||||
await waitFor(() =>
|
||||
expect(sidebar.openTab).toHaveBeenCalledWith({
|
||||
type: OBSERVATION_TAB_TYPE,
|
||||
path: OBSERVATION_TAB_PATH,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-opens on the first session but never re-focuses an open tab", async () => {
|
||||
const h = makeHarness([]);
|
||||
const sidebar = makeSidebar();
|
||||
registerObservationSidebar(sidebar.service, h.store);
|
||||
expect(sidebar.openTab).not.toHaveBeenCalled();
|
||||
// First session arrives: the tab lands in the sidebar.
|
||||
h.push([BUSY]);
|
||||
await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1));
|
||||
// The sidebar now reports the tab open (persisted/restored): further
|
||||
// session arrivals must not steal focus from whatever the user reads.
|
||||
sidebar.setState(leafWith(OBSERVATION_TAB_TYPE));
|
||||
h.push([]);
|
||||
h.push([{ ...BUSY, sessionId: "s2" }]);
|
||||
h.push([]);
|
||||
h.push([{ ...BUSY, sessionId: "s3" }]);
|
||||
await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1));
|
||||
expect(sidebar.openTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not open while the sidebar has no active session state", () => {
|
||||
const h = makeHarness([BUSY]);
|
||||
const sidebar = makeSidebar(undefined);
|
||||
registerObservationSidebar(sidebar.service, h.store);
|
||||
expect(sidebar.openTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("nudges an existing tab back into sight when the panel is collapsed", async () => {
|
||||
const h = makeHarness([]);
|
||||
// Tab already open (persisted), but the panel is collapsed: a new
|
||||
// session should surface the tracking view again (focus + expand via a
|
||||
// content open), like the floating card reappearing.
|
||||
const sidebar = makeSidebar(leafWith(OBSERVATION_TAB_TYPE, false));
|
||||
registerObservationSidebar(sidebar.service, h.store);
|
||||
h.push([BUSY]);
|
||||
await waitFor(() =>
|
||||
expect(sidebar.openTab).toHaveBeenCalledWith({
|
||||
type: OBSERVATION_TAB_TYPE,
|
||||
path: OBSERVATION_TAB_PATH,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the visible session count as the tab badge", () => {
|
||||
const h = makeHarness([]);
|
||||
const sidebar = makeSidebar();
|
||||
registerObservationSidebar(sidebar.service, h.store);
|
||||
const badge = sidebar.descriptor().badge;
|
||||
const scope = { sessionId: "conv-1" };
|
||||
expect(badge?.(undefined, scope, undefined)).toBeNull();
|
||||
h.store.acquire();
|
||||
h.push([BUSY, FOREIGN]);
|
||||
expect(badge?.(undefined, scope, undefined)).toBe(1);
|
||||
expect(badge?.(undefined, { sessionId: "other-conv" }, undefined)).toBe(1);
|
||||
expect(badge?.(undefined, { sessionId: "nobody" }, undefined)).toBeNull();
|
||||
h.store.release();
|
||||
});
|
||||
|
||||
it("does not auto-open for sessions owned by other conversations", async () => {
|
||||
const h = makeHarness([]);
|
||||
const sidebar = makeSidebar();
|
||||
registerObservationSidebar(sidebar.service, h.store);
|
||||
h.push([FOREIGN]);
|
||||
await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1));
|
||||
expect(sidebar.openTab).not.toHaveBeenCalled();
|
||||
// A session visible to the active conversation does open the tab.
|
||||
h.push([BUSY]);
|
||||
await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("observationTabOpen", () => {
|
||||
it("finds the tab in either workbench tree", () => {
|
||||
expect(observationTabOpen(undefined)).toBe(false);
|
||||
expect(observationTabOpen(leafWith("explorer"))).toBe(false);
|
||||
expect(observationTabOpen(leafWith(OBSERVATION_TAB_TYPE))).toBe(true);
|
||||
const inBottom: SidebarStateLike = {
|
||||
splits: { kind: "leaf", id: "p1", active: null, tabs: [] },
|
||||
bottomSplits: {
|
||||
kind: "leaf",
|
||||
id: "p2",
|
||||
active: OBSERVATION_TAB_TYPE,
|
||||
tabs: [{ id: OBSERVATION_TAB_TYPE, type: OBSERVATION_TAB_TYPE, title: "Browser" }],
|
||||
},
|
||||
};
|
||||
expect(observationTabOpen(inBottom)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObservationSidebarTab", () => {
|
||||
it("renders the tracking view without card chrome (no collapse, no drag header)", async () => {
|
||||
const h = makeHarness([BUSY]);
|
||||
render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
|
||||
await screen.findByText(/s1 · clicking/);
|
||||
expect(screen.queryByRole("button", { name: "Collapse" })).toBeNull();
|
||||
expect(screen.getByTestId("obs-header").dataset.draggable).toBeUndefined();
|
||||
// No resize handles — the sidebar owns the geometry.
|
||||
expect(screen.queryByTestId("obs-resize-se")).toBeNull();
|
||||
// Session controls ride along: interrupt + stop-with-confirm.
|
||||
expect(screen.getByRole("button", { name: "Stop session s1" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("scopes the view to the sidebar's conversation", async () => {
|
||||
const h = makeHarness([BUSY, FOREIGN]);
|
||||
render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
|
||||
// Focus and content belong to the owned session; the foreign one never
|
||||
// reaches the stage, the status line, or the strip (which needs 2+).
|
||||
await screen.findByText(/s1 · clicking/);
|
||||
expect(screen.queryByText(/s9/)).toBeNull();
|
||||
expect(screen.queryByTestId("obs-strip")).toBeNull();
|
||||
// Scoping to the other conversation swaps what's visible.
|
||||
render(<ObservationSidebarTab store={h.store} scopeId="other-conv" />);
|
||||
await screen.findByText(/s9 · clicking/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("carrier switching", () => {
|
||||
it("hides the floating overlay while sidebar mode is active", async () => {
|
||||
const h = makeHarness([]);
|
||||
const { container } = render(<ObservationOverlay store={h.store} />);
|
||||
h.push([BUSY]);
|
||||
await screen.findByTestId("obs-card");
|
||||
setSidebarMode(true);
|
||||
await waitFor(() => expect(container.firstChild).toBeNull());
|
||||
setSidebarMode(false);
|
||||
await screen.findByTestId("obs-card");
|
||||
});
|
||||
|
||||
it("keeps the feed alive across overlapping carriers (refcount)", async () => {
|
||||
const h = makeHarness([]);
|
||||
const overlay = render(<ObservationOverlay store={h.store} />);
|
||||
await waitFor(() => expect(h.es()).toBeDefined());
|
||||
// A second carrier mounts (the sidebar tab), then the overlay unmounts:
|
||||
// the stream must survive for the remaining consumer.
|
||||
const tab = render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
|
||||
overlay.unmount();
|
||||
h.push([BUSY]);
|
||||
await screen.findByText(/s1 · clicking/);
|
||||
expect(h.es()?.close ?? vi.fn()).not.toHaveBeenCalled();
|
||||
tab.unmount();
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,9 @@ describe("client plugin registration", () => {
|
||||
const sessions = { binding: () => undefined };
|
||||
const ctx = {
|
||||
get: (key: string) => (key === "sessions" ? sessions : undefined),
|
||||
// cordis ctx.inject: the betterSidebar carrier upgrade stays dormant in
|
||||
// this composition (the callback only runs once the service exists).
|
||||
inject: (_deps: string[], _fn: (injected: unknown) => unknown) => {},
|
||||
slots: {
|
||||
inject: (_name: string, fn: () => unknown) => fn(),
|
||||
register: (slot: { name: string; key?: string; id?: string }, view: unknown) => {
|
||||
|
||||
@@ -61,6 +61,8 @@ function fakeRunner(
|
||||
opts: {
|
||||
screenshotFails?: boolean;
|
||||
screenshotNotFound?: boolean;
|
||||
stopNotFound?: boolean;
|
||||
stopFails?: boolean;
|
||||
killCount?: number;
|
||||
screenshotBytes?: () => Uint8Array;
|
||||
} = {},
|
||||
@@ -72,6 +74,21 @@ function fakeRunner(
|
||||
killed,
|
||||
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
|
||||
calls.push({ args, options });
|
||||
if (args[0] === "session" && args[1] === "stop") {
|
||||
if (opts.stopNotFound) {
|
||||
return {
|
||||
code: 4,
|
||||
stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
if (opts.stopFails) {
|
||||
return { code: 1, stdout: "", stderr: "boom", timedOut: false, aborted: false };
|
||||
}
|
||||
return { code: 0, stdout: "{}", stderr: "", timedOut: false, aborted: false };
|
||||
}
|
||||
if (args[0] === "screenshot") {
|
||||
if (opts.screenshotNotFound) {
|
||||
return {
|
||||
@@ -180,6 +197,23 @@ describe("state machine", () => {
|
||||
expect(events[events.length - 1].type).toBe("reset");
|
||||
});
|
||||
|
||||
it("stamps the registry-recorded DSH owners onto new entries", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
own(registry, "s2");
|
||||
registry.trackOwner("s1", ["conv-a", "root"]);
|
||||
const { service } = setup({ registry });
|
||||
service.addSession("s1");
|
||||
service.addSession("s2");
|
||||
expect(service.getState()).toEqual([
|
||||
{ sessionId: "s1", action: "idle", since: 1_000_000, dshSessionIds: ["conv-a", "root"] },
|
||||
{ sessionId: "s2", action: "idle", since: 1_000_000 },
|
||||
]);
|
||||
// The owner stamp survives later upserts (action/url/frame churn).
|
||||
service.beginAction("s1", "clicking");
|
||||
expect(service.getState()[0].dshSessionIds).toEqual(["conv-a", "root"]);
|
||||
});
|
||||
|
||||
it("ignores instrumentation for unknown sessions (owned-only by construction)", () => {
|
||||
const { service } = setup({});
|
||||
service.beginAction("foreign", "clicking");
|
||||
@@ -307,6 +341,57 @@ describe("interrupt routing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopSession", () => {
|
||||
it("stops an owned session: kills in-flight tools, runs session stop, removes the entry", async () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
const runner = fakeRunner();
|
||||
const { service, events } = setup({ registry, runner });
|
||||
service.addSession("s1");
|
||||
expect(registry.isOwned("s1")).toBe(true);
|
||||
|
||||
await expect(service.stopSession("s1")).resolves.toBe(true);
|
||||
expect(runner.killed).toEqual(["s1"]);
|
||||
const stop = runner.calls.find((c) => c.args[0] === "session" && c.args[1] === "stop");
|
||||
expect(stop?.args).toEqual(["session", "stop", "s1"]);
|
||||
expect(registry.isOwned("s1")).toBe(false);
|
||||
expect(service.getState()).toEqual([]);
|
||||
expect(events[events.length - 1]).toMatchObject({ type: "remove" });
|
||||
});
|
||||
|
||||
it("refuses foreign sessions without touching the runner", async () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
const runner = fakeRunner();
|
||||
const { service } = setup({ registry, runner });
|
||||
await expect(service.stopSession("foreign")).resolves.toBe(false);
|
||||
expect(runner.killed).toEqual([]);
|
||||
expect(runner.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("stops idempotently when the daemon already forgot the session (dead entries)", async () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
const runner = fakeRunner({ stopNotFound: true });
|
||||
const { service, events } = setup({ registry, runner });
|
||||
service.addSession("s1");
|
||||
await expect(service.stopSession("s1")).resolves.toBe(true);
|
||||
expect(registry.isOwned("s1")).toBe(false);
|
||||
expect(events[events.length - 1]).toMatchObject({ type: "remove" });
|
||||
});
|
||||
|
||||
it("returns false and keeps the entry when the stop itself fails", async () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
const runner = fakeRunner({ stopFails: true });
|
||||
const { service } = setup({ registry, runner });
|
||||
service.addSession("s1");
|
||||
await expect(service.stopSession("s1")).resolves.toBe(false);
|
||||
expect(registry.isOwned("s1")).toBe(true);
|
||||
expect(service.getState().map((s) => s.sessionId)).toEqual(["s1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP/SSE interface", () => {
|
||||
interface RecordedRoute {
|
||||
path: string;
|
||||
@@ -405,11 +490,45 @@ describe("HTTP/SSE interface", () => {
|
||||
await interruptRoute?.handler(postReq, interruptRes.res as never);
|
||||
expect(JSON.parse(interruptRes.res.body())).toEqual({ interrupted: true });
|
||||
|
||||
// stop (POST {sessionId}): owned session stops and leaves the state
|
||||
const stopRoute = routes.get("/bsk-observation/stop");
|
||||
const stopRes = fakeRes();
|
||||
await stopRoute?.handler(postReq, stopRes.res as never);
|
||||
// The stop is async behind the response-less handler — poll the state.
|
||||
await waitFor(() => service.getState().length === 0);
|
||||
expect(JSON.parse(stopRes.res.body())).toEqual({ stopped: true });
|
||||
|
||||
dispose();
|
||||
expect(routes.size).toBe(0);
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("refuses a stop without a sessionId", async () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
const { service } = setup({ registry });
|
||||
const { routes, webServer } = routeHarness();
|
||||
const ctx = { get: (key: string) => (key === "webServer" ? webServer : undefined) } as never;
|
||||
const dispose = registerObservationRoutes(ctx, service);
|
||||
|
||||
const stopRoute = routes.get("/bsk-observation/stop");
|
||||
const res = fakeRes();
|
||||
const emptyReq = fakeReq({
|
||||
method: "POST",
|
||||
headers: { host: "127.0.0.1:3999", "content-type": "application/json" },
|
||||
on: (event: string, fn: (chunk?: string) => void) => {
|
||||
if (event === "data") fn("{}");
|
||||
if (event === "end") fn();
|
||||
},
|
||||
});
|
||||
await stopRoute?.handler(emptyReq, res.res as never);
|
||||
expect(res.res.status).toBe(400);
|
||||
expect(registry.isOwned("s1")).toBe(true);
|
||||
|
||||
dispose();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("fences off non-loopback, cross-site, and simple-request traffic", async () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
|
||||
@@ -333,6 +333,84 @@ describe("renderVom single-layer page", () => {
|
||||
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", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export { applyVomInteractionRecovery, isVomReferenceNode, renderVom } from "./render";
|
||||
export {
|
||||
applyVomInteractionRecovery,
|
||||
isVomReferenceNode,
|
||||
isVomStructuralRole,
|
||||
renderVom,
|
||||
} from "./render";
|
||||
export type {
|
||||
ActiveScopeBlock,
|
||||
BlockingLayer,
|
||||
|
||||
+42
-16
@@ -75,6 +75,10 @@ const STRUCTURAL_ROLES = new Set([
|
||||
"webarea",
|
||||
]);
|
||||
|
||||
export function isVomStructuralRole(role: string | undefined): boolean {
|
||||
return STRUCTURAL_ROLES.has(role?.toLowerCase() ?? "");
|
||||
}
|
||||
|
||||
const SENSITIVE_MASK = "•••";
|
||||
const DEFAULT_MAX_DEPTH = Number.POSITIVE_INFINITY;
|
||||
const DEFAULT_MAX_TOKENS = Number.POSITIVE_INFINITY;
|
||||
@@ -193,13 +197,16 @@ function shouldRender(node: VomNode): boolean {
|
||||
const role = normalizedRole(node);
|
||||
if (SKIP_ROLES.has(role)) return false;
|
||||
if (INTERACTIVE_ROLES.has(role)) return true;
|
||||
if (STRUCTURAL_ROLES.has(role)) return true;
|
||||
if (isVomStructuralRole(role)) return true;
|
||||
|
||||
return cleaned(node.name) !== undefined;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -219,14 +226,30 @@ function isContentEditable(node: VomNode): boolean {
|
||||
return contentEditable === "true" || contentEditable === "plaintext-only";
|
||||
}
|
||||
|
||||
function iconKeyword(value: string | undefined): string | undefined {
|
||||
const cleanedValue = cleaned(value);
|
||||
if (!cleanedValue) return undefined;
|
||||
const last = cleanedValue
|
||||
.split(/[#/.\s:_-]+/)
|
||||
const ICON_REFERENCE_MARKERS = new Set(["glyph", "icon", "icons", "symbol"]);
|
||||
const ICON_REFERENCE_DECORATORS = new Set(["fill", "filled", "line", "outline", "outlined"]);
|
||||
|
||||
function iconReferenceName(value: string | undefined): string | undefined {
|
||||
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)
|
||||
.pop();
|
||||
return last && /^[a-z][a-z0-9_-]{1,32}$/i.test(last) ? last.toLowerCase() : undefined;
|
||||
.map((token) => token.toLowerCase());
|
||||
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(
|
||||
@@ -238,12 +261,12 @@ function iconHintFromSubtree(
|
||||
for (const child of children.get(node.id) ?? []) {
|
||||
const attrs = child.attrs ?? {};
|
||||
const hint =
|
||||
iconKeyword(attrs["aria-label"]) ??
|
||||
iconKeyword(attrs.title) ??
|
||||
iconKeyword(attrs.href) ??
|
||||
iconKeyword(attrs["xlink:href"]) ??
|
||||
iconKeyword(attrs.class) ??
|
||||
iconKeyword(child.text);
|
||||
cleaned(attrs["aria-label"]) ??
|
||||
cleaned(attrs.title) ??
|
||||
(normalizedRole(child) === "img" ? cleaned(child.name) : undefined) ??
|
||||
(["img", "svg", "use"].includes(normalizedTag(child))
|
||||
? (iconReferenceName(attrs.href) ?? iconReferenceName(attrs["xlink:href"]))
|
||||
: undefined);
|
||||
if (hint) return hint;
|
||||
const nested = iconHintFromSubtree(child, children, depth + 1);
|
||||
if (nested) return nested;
|
||||
@@ -269,6 +292,7 @@ function recoveredRole(
|
||||
node: VomNode,
|
||||
children?: Map<number | null, VomNode[]>,
|
||||
): string | undefined {
|
||||
const currentRole = normalizedRole(node);
|
||||
const explicitRole = node.attrs?.role?.toLowerCase() ?? "";
|
||||
const hasHandler = hasRecoveryHandler(node);
|
||||
const focusable = isFocusable(node);
|
||||
@@ -290,7 +314,9 @@ function recoveredRole(
|
||||
: "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;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface VomNode {
|
||||
backendNodeId?: number;
|
||||
frameId?: string;
|
||||
contextScopeId?: string;
|
||||
/** False for semantic-only nodes that cannot be addressed through CDP. */
|
||||
referenceable?: boolean;
|
||||
|
||||
role?: string;
|
||||
name?: string;
|
||||
|
||||
Reference in New Issue
Block a user