mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(vom): method update
This commit is contained in:
1 parent
9dbc553e33
commit
5e0d26ccb2
5 files changed
+770
-272
No files matched your search
@@ -394,37 +394,33 @@ describe("buildVomScene", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(buildVomScene(axNodes, captured)).toEqual({
|
const scene = buildVomScene(axNodes, captured);
|
||||||
viewport: { width: 1000, height: 800 },
|
expect(scene.viewport).toEqual({ width: 1000, height: 800 });
|
||||||
nodes: [
|
expect(scene.nodes[0]).toEqual(
|
||||||
{
|
expect.objectContaining({
|
||||||
id: 100,
|
id: 100,
|
||||||
parentId: null,
|
parentId: null,
|
||||||
role: "RootWebArea",
|
role: "RootWebArea",
|
||||||
name: "Example",
|
name: "Example",
|
||||||
tag: "body",
|
tag: "body",
|
||||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||||
paintOrder: 0,
|
domParentId: null,
|
||||||
position: "static",
|
domAncestorIds: [],
|
||||||
pointerEvents: "auto",
|
}),
|
||||||
modal: false,
|
);
|
||||||
sensitive: false,
|
expect(scene.nodes[1]).toEqual(
|
||||||
},
|
expect.objectContaining({
|
||||||
{
|
id: 200,
|
||||||
id: 200,
|
parentId: 100,
|
||||||
parentId: 100,
|
role: "button",
|
||||||
role: "button",
|
name: "Submit",
|
||||||
name: "Submit",
|
tag: "button",
|
||||||
tag: "button",
|
rect: { x: 20, y: 20, w: 120, h: 40 },
|
||||||
rect: { x: 20, y: 20, w: 120, h: 40 },
|
domParentId: 100,
|
||||||
paintOrder: 1,
|
domAncestorIds: [100],
|
||||||
position: "static",
|
attrs: {},
|
||||||
pointerEvents: "auto",
|
}),
|
||||||
modal: false,
|
);
|
||||||
sensitive: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the nearest backend AX ancestor as parentId", () => {
|
it("uses the nearest backend AX ancestor as parentId", () => {
|
||||||
@@ -916,8 +912,13 @@ describe("buildVomScene", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const node = buildVomScene(axNodes, captured).nodes.find((n) => n.id === 20);
|
const scene = buildVomScene(axNodes, captured);
|
||||||
expect(node).toEqual(expect.objectContaining({ id: 20, role: "button", name: "close" }));
|
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||||
|
expect.objectContaining({ id: 20, role: "generic", cursor: "pointer" }),
|
||||||
|
);
|
||||||
|
const rendered = renderVom(scene);
|
||||||
|
expect(rendered.text).toContain('@e1 button "close"');
|
||||||
|
expect(rendered.refs).toEqual([{ ref: "e1", backendNodeId: 20 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not promote a clickable container that wraps a real interactive control", () => {
|
it("does not promote a clickable container that wraps a real interactive control", () => {
|
||||||
@@ -1056,10 +1057,12 @@ describe("buildVomScene", () => {
|
|||||||
|
|
||||||
const scene = buildVomScene(axNodes, captured);
|
const scene = buildVomScene(axNodes, captured);
|
||||||
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||||
expect.objectContaining({ id: 20, role: "button", name: "收藏" }),
|
expect.objectContaining({ id: 20, role: "generic", attrs: { "aria-label": "收藏" } }),
|
||||||
);
|
);
|
||||||
// Inner clickable must NOT also become a button.
|
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("generic");
|
||||||
expect(scene.nodes.find((n) => n.id === 30)?.role).not.toBe("button");
|
const rendered = renderVom(scene);
|
||||||
|
expect(rendered.text).toContain('@e1 button "收藏"');
|
||||||
|
expect(rendered.refs).toEqual([{ ref: "e1", backendNodeId: 20 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks captured dialog elements as modal without AX role or aria-modal", () => {
|
it("marks captured dialog elements as modal without AX role or aria-modal", () => {
|
||||||
|
|||||||
@@ -342,43 +342,122 @@ function capturedIframeNameFor(node: CapturedNode): string | undefined {
|
|||||||
return id ? id : undefined;
|
return id ? id : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORM_CONTROL_TAGS = new Set(["input", "textarea", "select"]);
|
|
||||||
|
|
||||||
/** AX roles we treat as non-semantic wrappers eligible for clickable promotion. */
|
|
||||||
const PROMOTABLE_ROLES = new Set(["", "generic", "none", "presentation"]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interactive AX roles. Mirrors `packages/vom`'s INTERACTIVE_ROLES — kept
|
|
||||||
* local so the package stays a pure projection (design §2). Used to tell an
|
|
||||||
* atomic custom control (icon button / toggle) apart from a clickable
|
|
||||||
* *container* (card/row) that wraps real controls we must not collapse.
|
|
||||||
*/
|
|
||||||
const INTERACTIVE_ROLES_LC = new Set([
|
|
||||||
"button",
|
|
||||||
"checkbox",
|
|
||||||
"combobox",
|
|
||||||
"listbox",
|
|
||||||
"link",
|
|
||||||
"menuitem",
|
|
||||||
"menuitemcheckbox",
|
|
||||||
"menuitemradio",
|
|
||||||
"option",
|
|
||||||
"radio",
|
|
||||||
"scrollbar",
|
|
||||||
"searchbox",
|
|
||||||
"slider",
|
|
||||||
"spinbutton",
|
|
||||||
"switch",
|
|
||||||
"tab",
|
|
||||||
"textbox",
|
|
||||||
"treeitem",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function cleanAttr(value: string | undefined): string | undefined {
|
function cleanAttr(value: string | undefined): string | undefined {
|
||||||
const trimmed = value?.replace(/\s+/g, " ").trim();
|
const trimmed = value?.replace(/\s+/g, " ").trim();
|
||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_CONTROL_TAGS = new Set(["input", "textarea", "select"]);
|
||||||
|
|
||||||
|
const NATIVE_CONTROL_TAGS = new Set(["button", "input", "select", "textarea"]);
|
||||||
|
|
||||||
|
function buildCapturedChildren(capturedNodes: CapturedNode[]): Map<number, CapturedNode[]> {
|
||||||
|
const children = new Map<number, CapturedNode[]>();
|
||||||
|
for (const node of capturedNodes) {
|
||||||
|
if (node.parentBackendNodeId === null) continue;
|
||||||
|
const siblings = children.get(node.parentBackendNodeId);
|
||||||
|
if (siblings) siblings.push(node);
|
||||||
|
else children.set(node.parentBackendNodeId, [node]);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturedDomAncestorIds(
|
||||||
|
node: CapturedNode,
|
||||||
|
capturedByBackendId: Map<number, CapturedNode>,
|
||||||
|
): number[] {
|
||||||
|
const ancestors: number[] = [];
|
||||||
|
let parentId = node.parentBackendNodeId;
|
||||||
|
let guard = 0;
|
||||||
|
while (parentId !== null && guard < capturedByBackendId.size) {
|
||||||
|
ancestors.push(parentId);
|
||||||
|
parentId = capturedByBackendId.get(parentId)?.parentBackendNodeId ?? null;
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturedHasNativeDescendant(
|
||||||
|
node: CapturedNode,
|
||||||
|
childrenByParentId: Map<number, CapturedNode[]>,
|
||||||
|
): boolean {
|
||||||
|
const stack = [...(childrenByParentId.get(node.backendNodeId) ?? [])];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const child = stack.pop() as CapturedNode;
|
||||||
|
if (NATIVE_CONTROL_TAGS.has(normalizeTag(child.tag))) return true;
|
||||||
|
stack.push(...(childrenByParentId.get(child.backendNodeId) ?? []));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturedInsideNative(
|
||||||
|
node: CapturedNode,
|
||||||
|
capturedByBackendId: Map<number, CapturedNode>,
|
||||||
|
): boolean {
|
||||||
|
let parentId = node.parentBackendNodeId;
|
||||||
|
let guard = 0;
|
||||||
|
while (parentId !== null && guard < capturedByBackendId.size) {
|
||||||
|
const parent = capturedByBackendId.get(parentId);
|
||||||
|
if (!parent) break;
|
||||||
|
if (NATIVE_CONTROL_TAGS.has(normalizeTag(parent.tag))) return true;
|
||||||
|
parentId = parent.parentBackendNodeId;
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearbyTextFor(
|
||||||
|
node: CapturedNode,
|
||||||
|
childrenByParentId: Map<number, CapturedNode[]>,
|
||||||
|
): string | undefined {
|
||||||
|
if (node.parentBackendNodeId === null) return undefined;
|
||||||
|
const siblings = childrenByParentId.get(node.parentBackendNodeId) ?? [];
|
||||||
|
const index = siblings.findIndex((sibling) => sibling.backendNodeId === node.backendNodeId);
|
||||||
|
if (index < 0) return undefined;
|
||||||
|
|
||||||
|
const labels: string[] = [];
|
||||||
|
for (const sibling of siblings.slice(Math.max(0, index - 3), index)) {
|
||||||
|
const text = cleanAttr(sibling.textContent);
|
||||||
|
if (text) labels.push(text);
|
||||||
|
}
|
||||||
|
for (const sibling of siblings.slice(index + 1, index + 4)) {
|
||||||
|
const text = cleanAttr(sibling.textContent);
|
||||||
|
if (text) labels.push(text);
|
||||||
|
}
|
||||||
|
return labels.length > 0 ? labels.join(" ") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VomNodeDomSignals {
|
||||||
|
capturedByBackendId: Map<number, CapturedNode>;
|
||||||
|
childrenByParentId: Map<number, CapturedNode[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCapturedSignals(
|
||||||
|
node: VomNode,
|
||||||
|
capturedNode: CapturedNode | undefined,
|
||||||
|
signals: VomNodeDomSignals,
|
||||||
|
): VomNode {
|
||||||
|
if (!capturedNode) return node;
|
||||||
|
const attrs = capturedNode.attrs;
|
||||||
|
const text = cleanAttr(capturedNode.textContent);
|
||||||
|
const nearbyText = nearbyTextFor(capturedNode, signals.childrenByParentId);
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
domParentId: capturedNode.parentBackendNodeId,
|
||||||
|
domAncestorIds: capturedDomAncestorIds(capturedNode, signals.capturedByBackendId),
|
||||||
|
cursor: capturedNode.cursor,
|
||||||
|
attrs,
|
||||||
|
...(text ? { text } : {}),
|
||||||
|
...(nearbyText ? { nearbyText } : {}),
|
||||||
|
disabled:
|
||||||
|
Object.prototype.hasOwnProperty.call(attrs, "disabled") ||
|
||||||
|
(attrs["aria-disabled"] ?? "").toLowerCase() === "true",
|
||||||
|
inert: Object.prototype.hasOwnProperty.call(attrs, "inert"),
|
||||||
|
hasNativeDescendant: capturedHasNativeDescendant(capturedNode, signals.childrenByParentId),
|
||||||
|
insideNative: capturedInsideNative(capturedNode, signals.capturedByBackendId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Name for a native form control. VOM models the *perceived* viewport
|
* Name for a native form control. VOM models the *perceived* viewport
|
||||||
* (spec §1): an empty field displays its placeholder, so when the field has
|
* (spec §1): an empty field displays its placeholder, so when the field has
|
||||||
@@ -400,174 +479,30 @@ function formControlName(
|
|||||||
return axName ?? ariaLabel ?? placeholder ?? title;
|
return axName ?? ariaLabel ?? placeholder ?? title;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip a sprite/file reference like `#close` or `icon-close.svg` to `close`. */
|
function vomNodeFromCaptured(
|
||||||
function iconKeyword(href: string | undefined): string | undefined {
|
capturedNode: CapturedNode,
|
||||||
if (!href) return undefined;
|
parentId: number | null,
|
||||||
let s = href.trim();
|
signals: VomNodeDomSignals,
|
||||||
const hash = s.lastIndexOf("#");
|
): VomNode {
|
||||||
if (hash >= 0) s = s.slice(hash + 1);
|
|
||||||
s = s.split("/").pop() ?? s;
|
|
||||||
s = s.replace(/\.(svg|png|webp|gif|jpe?g)$/i, "");
|
|
||||||
s = s.replace(/^(icons?[-_]?|ic[-_]|svg[-_])/i, "");
|
|
||||||
s = s.replace(/[-_]+/g, " ").trim();
|
|
||||||
return s ? s : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** First icon-derived label found in a clickable element's captured subtree. */
|
|
||||||
function iconHint(
|
|
||||||
root: CapturedNode,
|
|
||||||
capChildren: Map<number, CapturedNode[]>,
|
|
||||||
): string | undefined {
|
|
||||||
const queue: Array<{ node: CapturedNode; depth: number }> = (
|
|
||||||
capChildren.get(root.backendNodeId) ?? []
|
|
||||||
).map((node) => ({ node, depth: 1 }));
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const { node, depth } = queue.shift() as { node: CapturedNode; depth: number };
|
|
||||||
const labelled =
|
|
||||||
cleanAttr(node.attrs["aria-label"]) ??
|
|
||||||
cleanAttr(node.attrs.title) ??
|
|
||||||
cleanAttr(node.attrs.alt);
|
|
||||||
if (labelled) return labelled;
|
|
||||||
if (node.tag === "use") {
|
|
||||||
const kw = iconKeyword(node.attrs["xlink:href"] ?? node.attrs.href);
|
|
||||||
if (kw) return kw;
|
|
||||||
}
|
|
||||||
if (depth < 4) {
|
|
||||||
for (const child of capChildren.get(node.backendNodeId) ?? []) {
|
|
||||||
queue.push({ node: child, depth: depth + 1 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best-effort name for a promoted clickable: own labels → text → icon. */
|
|
||||||
function clickableName(
|
|
||||||
captured: CapturedNode,
|
|
||||||
capChildren: Map<number, CapturedNode[]>,
|
|
||||||
): string | undefined {
|
|
||||||
return (
|
|
||||||
cleanAttr(captured.attrs["aria-label"]) ??
|
|
||||||
cleanAttr(captured.attrs.title) ??
|
|
||||||
cleanAttr(captured.textContent) ??
|
|
||||||
iconHint(captured, capChildren)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rectCoverage(
|
|
||||||
rect: { x: number; y: number; w: number; h: number } | null,
|
|
||||||
vw: number,
|
|
||||||
vh: number,
|
|
||||||
): number {
|
|
||||||
if (!rect || vw <= 0 || vh <= 0) return 0;
|
|
||||||
const ix = Math.max(0, Math.min(rect.x + rect.w, vw) - Math.max(rect.x, 0));
|
|
||||||
const iy = Math.max(0, Math.min(rect.y + rect.h, vh) - Math.max(rect.y, 0));
|
|
||||||
const overlap = ix * iy;
|
|
||||||
return overlap <= 0 ? 0 : Math.min(1, overlap / (vw * vh));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promote custom clickable controls the AX tree drops as `generic`/unnamed
|
|
||||||
* (icon buttons, CSS checkboxes, "send code" spans …) to a referenceable
|
|
||||||
* `button` so the snapshot can act on them. Guards keep noise out: must be
|
|
||||||
* `cursor: pointer`, have a box, not be a viewport-scale overlay, and not
|
|
||||||
* wrap any real interactive descendant (so clickable cards or rows stay
|
|
||||||
* containers and their inner controls keep their own refs).
|
|
||||||
*/
|
|
||||||
function promoteClickableControls(
|
|
||||||
nodes: VomNode[],
|
|
||||||
capturedById: Map<number, CapturedNode>,
|
|
||||||
capturedNodes: CapturedNode[],
|
|
||||||
vw: number,
|
|
||||||
vh: number,
|
|
||||||
): void {
|
|
||||||
// Snapshot original roles + parent/child links before any mutation so the
|
|
||||||
// guards below are immune to roles we flip to "button" mid-pass.
|
|
||||||
const originalRoleLc = new Map<number, string>(
|
|
||||||
nodes.map((node) => [node.id, (node.role ?? "").toLowerCase()]),
|
|
||||||
);
|
|
||||||
const parentOf = new Map<number, number | null>(nodes.map((node) => [node.id, node.parentId]));
|
|
||||||
const semChildren = new Map<number, VomNode[]>();
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.parentId === null) continue;
|
|
||||||
const siblings = semChildren.get(node.parentId);
|
|
||||||
if (siblings) siblings.push(node);
|
|
||||||
else semChildren.set(node.parentId, [node]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const capChildren = new Map<number, CapturedNode[]>();
|
|
||||||
for (const cap of capturedNodes) {
|
|
||||||
if (cap.parentBackendNodeId === null) continue;
|
|
||||||
const siblings = capChildren.get(cap.parentBackendNodeId);
|
|
||||||
if (siblings) siblings.push(cap);
|
|
||||||
else capChildren.set(cap.parentBackendNodeId, [cap]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A custom clickable: a non-semantic, pointer-cursor box small enough to be
|
|
||||||
// a control rather than a viewport-scale overlay.
|
|
||||||
const candidates = new Set<number>();
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!PROMOTABLE_ROLES.has(originalRoleLc.get(node.id) ?? "")) continue;
|
|
||||||
const cap = capturedById.get(node.id);
|
|
||||||
if (!cap || cap.cursor !== "pointer" || !node.rect) continue;
|
|
||||||
if (rectCoverage(node.rect, vw, vh) > 0.5) continue;
|
|
||||||
candidates.add(node.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// wraps a *real* interactive control → it's a clickable container, leave it
|
|
||||||
// (and its inner refs) alone.
|
|
||||||
const wrapsInteractive = (id: number): boolean => {
|
|
||||||
const stack = [...(semChildren.get(id) ?? [])];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const node = stack.pop() as VomNode;
|
|
||||||
if (INTERACTIVE_ROLES_LC.has(originalRoleLc.get(node.id) ?? "")) return true;
|
|
||||||
const kids = semChildren.get(node.id);
|
|
||||||
if (kids) stack.push(...kids);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// only the outermost clickable in a nested chain becomes the control, so an
|
|
||||||
// icon inside a custom button doesn't render as a button-in-a-button.
|
|
||||||
const hasCandidateAncestor = (id: number): boolean => {
|
|
||||||
let parent = parentOf.get(id) ?? null;
|
|
||||||
while (parent !== null) {
|
|
||||||
if (candidates.has(parent)) return true;
|
|
||||||
parent = parentOf.get(parent) ?? null;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!candidates.has(node.id)) continue;
|
|
||||||
if (wrapsInteractive(node.id) || hasCandidateAncestor(node.id)) continue;
|
|
||||||
const cap = capturedById.get(node.id);
|
|
||||||
if (!cap) continue;
|
|
||||||
|
|
||||||
node.role = "button";
|
|
||||||
if (!cleanAttr(node.name)) {
|
|
||||||
const derived = clickableName(cap, capChildren);
|
|
||||||
if (derived) node.name = derived;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function vomNodeFromCaptured(capturedNode: CapturedNode, parentId: number | null): VomNode {
|
|
||||||
const sensitive = isSensitive(capturedNode);
|
const sensitive = isSensitive(capturedNode);
|
||||||
const value = sensitive ? (capturedNode.attrs.value ?? "") : undefined;
|
const value = sensitive ? (capturedNode.attrs.value ?? "") : undefined;
|
||||||
const tag = normalizeTag(capturedNode.tag);
|
const tag = normalizeTag(capturedNode.tag);
|
||||||
const node: VomNode = {
|
const node = applyCapturedSignals(
|
||||||
id: capturedNode.backendNodeId,
|
{
|
||||||
parentId,
|
id: capturedNode.backendNodeId,
|
||||||
tag,
|
parentId,
|
||||||
rect: capturedNode.rect,
|
tag,
|
||||||
paintOrder: capturedNode.paintOrder,
|
rect: capturedNode.rect,
|
||||||
position: capturedNode.position || "static",
|
paintOrder: capturedNode.paintOrder,
|
||||||
pointerEvents: capturedNode.pointerEvents || "auto",
|
position: capturedNode.position || "static",
|
||||||
modal: isModalSignal(undefined, capturedNode),
|
pointerEvents: capturedNode.pointerEvents || "auto",
|
||||||
sensitive,
|
modal: isModalSignal(undefined, capturedNode),
|
||||||
...(value !== undefined ? { value } : {}),
|
sensitive,
|
||||||
};
|
...(value !== undefined ? { value } : {}),
|
||||||
|
},
|
||||||
|
capturedNode,
|
||||||
|
signals,
|
||||||
|
);
|
||||||
if (tag === "iframe") {
|
if (tag === "iframe") {
|
||||||
node.role = "Iframe";
|
node.role = "Iframe";
|
||||||
const name = capturedIframeNameFor(capturedNode);
|
const name = capturedIframeNameFor(capturedNode);
|
||||||
@@ -576,6 +511,56 @@ function vomNodeFromCaptured(capturedNode: CapturedNode, parentId: number | null
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function axVomNode(
|
||||||
|
axNode: CdpAxNode,
|
||||||
|
capturedNode: CapturedNode | undefined,
|
||||||
|
axById: Map<string, CdpAxNode>,
|
||||||
|
signals: VomNodeDomSignals,
|
||||||
|
): VomNode {
|
||||||
|
const role = axString(axNode.role);
|
||||||
|
const value = axValue(axNode.value);
|
||||||
|
const name = FORM_CONTROL_TAGS.has(normalizeTag(capturedNode?.tag))
|
||||||
|
? formControlName(capturedNode, axString(axNode.name), value)
|
||||||
|
: axString(axNode.name);
|
||||||
|
const node: VomNode = {
|
||||||
|
id: axNode.backendDOMNodeId as number,
|
||||||
|
parentId: nearestBackendParent(axNode, axById),
|
||||||
|
tag: normalizeTag(capturedNode?.tag),
|
||||||
|
rect: capturedNode?.rect ?? null,
|
||||||
|
paintOrder: capturedNode?.paintOrder ?? 0,
|
||||||
|
position: capturedNode?.position || "static",
|
||||||
|
pointerEvents: capturedNode?.pointerEvents || "auto",
|
||||||
|
modal: isModalSignal(axNode, capturedNode),
|
||||||
|
sensitive: isSensitive(capturedNode),
|
||||||
|
};
|
||||||
|
if (role) node.role = role;
|
||||||
|
if (name) node.name = name;
|
||||||
|
if (value !== undefined) node.value = value;
|
||||||
|
return applyCapturedSignals(node, capturedNode, signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function iframeSignals(iframeNodes: CapturedNode[]): VomNodeDomSignals {
|
||||||
|
const capturedByBackendId = new Map<number, CapturedNode>();
|
||||||
|
for (const node of iframeNodes) {
|
||||||
|
capturedByBackendId.set(node.backendNodeId, node);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
capturedByBackendId,
|
||||||
|
childrenByParentId: buildCapturedChildren(iframeNodes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturedOnlySignals(capturedNodes: CapturedNode[]): VomNodeDomSignals {
|
||||||
|
const capturedByBackendId = new Map<number, CapturedNode>();
|
||||||
|
for (const node of capturedNodes) {
|
||||||
|
capturedByBackendId.set(node.backendNodeId, node);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
capturedByBackendId,
|
||||||
|
childrenByParentId: buildCapturedChildren(capturedNodes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function axNodeInOverlaySubtree(
|
function axNodeInOverlaySubtree(
|
||||||
axNode: CdpAxNode,
|
axNode: CdpAxNode,
|
||||||
axById: Map<string, CdpAxNode>,
|
axById: Map<string, CdpAxNode>,
|
||||||
@@ -598,10 +583,8 @@ function axNodeInOverlaySubtree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildVomScene(axNodes: CdpAxNode[], captured: CapturedViewModel): VomScene {
|
export function buildVomScene(axNodes: CdpAxNode[], captured: CapturedViewModel): VomScene {
|
||||||
const capturedByBackendId = new Map<number, CapturedNode>();
|
const signals = capturedOnlySignals(captured.nodes);
|
||||||
for (const node of captured.nodes) {
|
const { capturedByBackendId } = signals;
|
||||||
capturedByBackendId.set(node.backendNodeId, node);
|
|
||||||
}
|
|
||||||
|
|
||||||
const axById = new Map<string, CdpAxNode>();
|
const axById = new Map<string, CdpAxNode>();
|
||||||
for (const node of axNodes) {
|
for (const node of axNodes) {
|
||||||
@@ -618,24 +601,7 @@ export function buildVomScene(axNodes: CdpAxNode[], captured: CapturedViewModel)
|
|||||||
|
|
||||||
const capturedNode = capturedByBackendId.get(axNode.backendDOMNodeId);
|
const capturedNode = capturedByBackendId.get(axNode.backendDOMNodeId);
|
||||||
const role = axString(axNode.role);
|
const role = axString(axNode.role);
|
||||||
const value = axValue(axNode.value);
|
const vomNode = axVomNode(axNode, capturedNode, axById, signals);
|
||||||
const name = FORM_CONTROL_TAGS.has(normalizeTag(capturedNode?.tag))
|
|
||||||
? formControlName(capturedNode, axString(axNode.name), value)
|
|
||||||
: axString(axNode.name);
|
|
||||||
const vomNode: VomNode = {
|
|
||||||
id: axNode.backendDOMNodeId,
|
|
||||||
parentId: nearestBackendParent(axNode, axById),
|
|
||||||
tag: normalizeTag(capturedNode?.tag),
|
|
||||||
rect: capturedNode?.rect ?? null,
|
|
||||||
paintOrder: capturedNode?.paintOrder ?? 0,
|
|
||||||
position: capturedNode?.position || "static",
|
|
||||||
pointerEvents: capturedNode?.pointerEvents || "auto",
|
|
||||||
modal: isModalSignal(axNode, capturedNode),
|
|
||||||
sensitive: isSensitive(capturedNode),
|
|
||||||
};
|
|
||||||
if (role) vomNode.role = role;
|
|
||||||
if (name) vomNode.name = name;
|
|
||||||
if (value !== undefined) vomNode.value = value;
|
|
||||||
|
|
||||||
// For link nodes, attach external hostname so the renderer can annotate it.
|
// For link nodes, attach external hostname so the renderer can annotate it.
|
||||||
// Relative hrefs and same-origin hrefs are omitted to avoid noise.
|
// Relative hrefs and same-origin hrefs are omitted to avoid noise.
|
||||||
@@ -658,10 +624,11 @@ export function buildVomScene(axNodes: CdpAxNode[], captured: CapturedViewModel)
|
|||||||
if (seenBackendIds.has(capturedNode.backendNodeId)) continue;
|
if (seenBackendIds.has(capturedNode.backendNodeId)) continue;
|
||||||
if (excludedBackendNodeIds.has(capturedNode.backendNodeId)) continue;
|
if (excludedBackendNodeIds.has(capturedNode.backendNodeId)) continue;
|
||||||
seenBackendIds.add(capturedNode.backendNodeId);
|
seenBackendIds.add(capturedNode.backendNodeId);
|
||||||
nodes.push(vomNodeFromCaptured(capturedNode, capturedNode.parentBackendNodeId));
|
nodes.push(vomNodeFromCaptured(capturedNode, capturedNode.parentBackendNodeId, signals));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [iframeBackendId, iframeNodes] of captured.iframeNodes) {
|
for (const [iframeBackendId, iframeNodes] of captured.iframeNodes) {
|
||||||
|
const signals = iframeSignals(iframeNodes);
|
||||||
for (const iframeNode of iframeNodes) {
|
for (const iframeNode of iframeNodes) {
|
||||||
if (
|
if (
|
||||||
seenBackendIds.has(iframeNode.backendNodeId) ||
|
seenBackendIds.has(iframeNode.backendNodeId) ||
|
||||||
@@ -676,7 +643,7 @@ export function buildVomScene(axNodes: CdpAxNode[], captured: CapturedViewModel)
|
|||||||
|
|
||||||
seenBackendIds.add(iframeNode.backendNodeId);
|
seenBackendIds.add(iframeNode.backendNodeId);
|
||||||
const vomNode: VomNode = {
|
const vomNode: VomNode = {
|
||||||
...vomNodeFromCaptured(iframeNode, iframeBackendId),
|
...vomNodeFromCaptured(iframeNode, iframeBackendId, signals),
|
||||||
role,
|
role,
|
||||||
};
|
};
|
||||||
if (name) vomNode.name = name;
|
if (name) vomNode.name = name;
|
||||||
@@ -684,14 +651,6 @@ export function buildVomScene(axNodes: CdpAxNode[], captured: CapturedViewModel)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
promoteClickableControls(
|
|
||||||
nodes,
|
|
||||||
capturedByBackendId,
|
|
||||||
captured.nodes,
|
|
||||||
captured.viewport.width,
|
|
||||||
captured.viewport.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { viewport: captured.viewport, nodes };
|
return { viewport: captured.viewport, nodes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,94 @@ describe("renderVom single-layer page", () => {
|
|||||||
const renderedRefs = new Set(Array.from(out.text.matchAll(/@(e\d+)/g), (m) => m[1]));
|
const renderedRefs = new Set(Array.from(out.text.matchAll(/@(e\d+)/g), (m) => m[1]));
|
||||||
expect(out.refs.every((r) => renderedRefs.has(r.ref))).toBe(true);
|
expect(out.refs.every((r) => renderedRefs.has(r.ref))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recovers custom clickable controls from DOM-only signals", () => {
|
||||||
|
const out = renderVom(
|
||||||
|
scene([
|
||||||
|
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||||
|
node({
|
||||||
|
id: 2,
|
||||||
|
parentId: 1,
|
||||||
|
role: "generic",
|
||||||
|
text: "Open settings",
|
||||||
|
cursor: "pointer",
|
||||||
|
attrs: { onclick: "handleOpen()" },
|
||||||
|
rect: { x: 20, y: 20, w: 160, h: 40 },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(out.text).toContain('@e1 button "Open settings"');
|
||||||
|
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the deepest custom control when wrapper and child both look clickable", () => {
|
||||||
|
const out = renderVom(
|
||||||
|
scene([
|
||||||
|
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||||
|
node({
|
||||||
|
id: 2,
|
||||||
|
parentId: 1,
|
||||||
|
role: "generic",
|
||||||
|
text: "Card",
|
||||||
|
cursor: "pointer",
|
||||||
|
attrs: { onclick: "handleCard()" },
|
||||||
|
rect: { x: 20, y: 20, w: 280, h: 120 },
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 3,
|
||||||
|
parentId: 2,
|
||||||
|
role: "generic",
|
||||||
|
text: "Details",
|
||||||
|
cursor: "pointer",
|
||||||
|
attrs: { onclick: "handleDetails()" },
|
||||||
|
rect: { x: 40, y: 70, w: 90, h: 32 },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(out.text).not.toContain('@e1 button "Card"');
|
||||||
|
expect(out.text).toContain('@e1 button "Details"');
|
||||||
|
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 3 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not recover clickable wrappers around native controls", () => {
|
||||||
|
const out = renderVom(
|
||||||
|
scene([
|
||||||
|
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||||
|
node({
|
||||||
|
id: 2,
|
||||||
|
parentId: 1,
|
||||||
|
role: "generic",
|
||||||
|
text: "Checkout",
|
||||||
|
cursor: "pointer",
|
||||||
|
attrs: { onclick: "handleCheckout()" },
|
||||||
|
hasNativeDescendant: true,
|
||||||
|
rect: { x: 20, y: 20, w: 280, h: 120 },
|
||||||
|
}),
|
||||||
|
node({ id: 3, parentId: 2, role: "button", name: "Pay", tag: "button" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(out.text).not.toContain('@e1 button "Checkout"');
|
||||||
|
expect(out.text).toContain('@e1 button "Pay"');
|
||||||
|
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 3 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds context to duplicate weak action labels", () => {
|
||||||
|
const out = renderVom(
|
||||||
|
scene([
|
||||||
|
node({ id: 1, role: "RootWebArea", name: "Admin" }),
|
||||||
|
node({ id: 2, parentId: 1, role: "section", name: "Project Alpha" }),
|
||||||
|
node({ id: 3, parentId: 2, role: "button", name: "View", tag: "button" }),
|
||||||
|
node({ id: 4, parentId: 1, role: "section", name: "Project Beta" }),
|
||||||
|
node({ id: 5, parentId: 4, role: "button", name: "View", tag: "button" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(out.text).toContain('@e1 button "View" [ctx: Project Alpha]');
|
||||||
|
expect(out.text).toContain('@e2 button "View" [ctx: Project Beta]');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("renderVom double-layer page", () => {
|
describe("renderVom double-layer page", () => {
|
||||||
|
|||||||
+445
-7
@@ -69,6 +69,41 @@ const SENSITIVE_MASK = "•••";
|
|||||||
const DEFAULT_MAX_DEPTH = Number.POSITIVE_INFINITY;
|
const DEFAULT_MAX_DEPTH = Number.POSITIVE_INFINITY;
|
||||||
const DEFAULT_MAX_TOKENS = Number.POSITIVE_INFINITY;
|
const DEFAULT_MAX_TOKENS = Number.POSITIVE_INFINITY;
|
||||||
const MAX_VALUE_LENGTH = 200;
|
const MAX_VALUE_LENGTH = 200;
|
||||||
|
const MAX_CUSTOM_RECOVERY_AREA = 180_000;
|
||||||
|
const MAX_RECOVERABLE_SINGLE_INTENT_AREA = 360_000;
|
||||||
|
const MAX_HANDLE_CONTEXT_ITEMS = 3;
|
||||||
|
|
||||||
|
const RECOVERY_HANDLER_ATTRS = ["onclick", "onmousedown", "onkeydown", "onkeyup", "onkeypress"];
|
||||||
|
const NATIVE_TAGS = new Set(["button", "input", "select", "textarea"]);
|
||||||
|
const GRAPHIC_ONLY_TAGS = new Set(["svg", "use", "path", "g", "symbol"]);
|
||||||
|
const EXPLICIT_RECOVERY_ROLES = new Set([
|
||||||
|
"button",
|
||||||
|
"menuitem",
|
||||||
|
"switch",
|
||||||
|
"tab",
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
]);
|
||||||
|
const WEAK_ACTION_LABELS = new Set([
|
||||||
|
"add",
|
||||||
|
"apply",
|
||||||
|
"cancel",
|
||||||
|
"delete",
|
||||||
|
"edit",
|
||||||
|
"go",
|
||||||
|
"more",
|
||||||
|
"open",
|
||||||
|
"remove",
|
||||||
|
"reset",
|
||||||
|
"save",
|
||||||
|
"select",
|
||||||
|
"submit",
|
||||||
|
"suspend",
|
||||||
|
"view",
|
||||||
|
"close",
|
||||||
|
"confirm",
|
||||||
|
"continue",
|
||||||
|
]);
|
||||||
|
|
||||||
function estimateTokens(text: string): number {
|
function estimateTokens(text: string): number {
|
||||||
return Math.ceil(text.length / 4);
|
return Math.ceil(text.length / 4);
|
||||||
@@ -83,6 +118,22 @@ function normalizedRole(node: VomNode): string {
|
|||||||
return node.role?.toLowerCase() ?? "";
|
return node.role?.toLowerCase() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeContextKey(s: string): string {
|
||||||
|
return s.split(/\s+/).join(" ").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProbableNavigationTarget(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.startsWith("http://") || trimmed.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUrlBearingStructuralAction(node: VomNode): boolean {
|
||||||
|
const role = normalizedRole(node);
|
||||||
|
if (!["row", "cell", "gridcell", "listitem", "rowheader"].includes(role)) return false;
|
||||||
|
const name = cleaned(node.name);
|
||||||
|
return name !== undefined && isProbableNavigationTarget(name);
|
||||||
|
}
|
||||||
|
|
||||||
function shouldRender(node: VomNode): boolean {
|
function shouldRender(node: VomNode): boolean {
|
||||||
if (!node.role) return false;
|
if (!node.role) return false;
|
||||||
|
|
||||||
@@ -95,7 +146,171 @@ function shouldRender(node: VomNode): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldReference(node: VomNode): boolean {
|
function shouldReference(node: VomNode): boolean {
|
||||||
return INTERACTIVE_ROLES.has(normalizedRole(node));
|
return INTERACTIVE_ROLES.has(normalizedRole(node)) || isUrlBearingStructuralAction(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRecoveryHandler(node: VomNode): boolean {
|
||||||
|
const attrs = node.attrs ?? {};
|
||||||
|
return RECOVERY_HANDLER_ATTRS.some((key) => Object.prototype.hasOwnProperty.call(attrs, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFocusable(node: VomNode): boolean {
|
||||||
|
const tabindex = node.attrs?.tabindex;
|
||||||
|
if (tabindex === undefined) return false;
|
||||||
|
const value = Number.parseInt(tabindex, 10);
|
||||||
|
return Number.isFinite(value) && value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContentEditable(node: VomNode): boolean {
|
||||||
|
const contentEditable = node.attrs?.contenteditable?.toLowerCase();
|
||||||
|
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:_-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop();
|
||||||
|
return last && /^[a-z][a-z0-9_-]{1,32}$/i.test(last) ? last.toLowerCase() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconHintFromSubtree(
|
||||||
|
node: VomNode,
|
||||||
|
children: Map<number | null, VomNode[]>,
|
||||||
|
depth = 0,
|
||||||
|
): string | undefined {
|
||||||
|
if (depth > 3) return undefined;
|
||||||
|
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);
|
||||||
|
if (hint) return hint;
|
||||||
|
const nested = iconHintFromSubtree(child, children, depth + 1);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoveryCandidateName(
|
||||||
|
node: VomNode,
|
||||||
|
children?: Map<number | null, VomNode[]>,
|
||||||
|
): string | undefined {
|
||||||
|
return (
|
||||||
|
cleaned(node.attrs?.["aria-label"]) ??
|
||||||
|
cleaned(node.attrs?.title) ??
|
||||||
|
cleaned(node.name) ??
|
||||||
|
cleaned(node.text) ??
|
||||||
|
cleaned(node.nearbyText) ??
|
||||||
|
(children ? iconHintFromSubtree(node, children) : undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoveredRole(
|
||||||
|
node: VomNode,
|
||||||
|
children?: Map<number | null, VomNode[]>,
|
||||||
|
): string | undefined {
|
||||||
|
const explicitRole = node.attrs?.role?.toLowerCase() ?? "";
|
||||||
|
const hasHandler = hasRecoveryHandler(node);
|
||||||
|
const focusable = isFocusable(node);
|
||||||
|
const hasName = recoveryCandidateName(node, children) !== undefined;
|
||||||
|
|
||||||
|
if (isContentEditable(node)) return "textbox";
|
||||||
|
if (explicitRole === "combobox" && (hasHandler || focusable)) return "combobox";
|
||||||
|
if (
|
||||||
|
EXPLICIT_RECOVERY_ROLES.has(explicitRole) &&
|
||||||
|
hasName &&
|
||||||
|
(hasHandler || focusable || node.cursor === "pointer")
|
||||||
|
) {
|
||||||
|
return explicitRole === "switch" ||
|
||||||
|
explicitRole === "checkbox" ||
|
||||||
|
explicitRole === "radio" ||
|
||||||
|
explicitRole === "tab" ||
|
||||||
|
explicitRole === "menuitem"
|
||||||
|
? explicitRole
|
||||||
|
: "button";
|
||||||
|
}
|
||||||
|
if (hasHandler && hasName) return "button";
|
||||||
|
if (node.cursor === "pointer" && hasName) return "button";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectArea(node: VomNode): number {
|
||||||
|
if (!node.rect) return 0;
|
||||||
|
return Math.max(0, node.rect.w) * Math.max(0, node.rect.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function passesRecoveryGuards(node: VomNode, children: Map<number | null, VomNode[]>): boolean {
|
||||||
|
const area = rectArea(node);
|
||||||
|
const tag = node.tag.toLowerCase();
|
||||||
|
|
||||||
|
if (node.pointerEvents === "none") return false;
|
||||||
|
if (node.disabled || node.inert || node.insideNative) return false;
|
||||||
|
if (node.hasNativeDescendant && !isContentEditable(node)) return false;
|
||||||
|
|
||||||
|
const singleIntentLargeCandidate =
|
||||||
|
area <= MAX_RECOVERABLE_SINGLE_INTENT_AREA &&
|
||||||
|
recoveryCandidateName(node, children) !== undefined &&
|
||||||
|
hasRecoveryHandler(node);
|
||||||
|
if (area > MAX_CUSTOM_RECOVERY_AREA && !isContentEditable(node) && !singleIntentLargeCandidate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (NATIVE_TAGS.has(tag) || tag === "a") return false;
|
||||||
|
if (GRAPHIC_ONLY_TAGS.has(tag)) return false;
|
||||||
|
return recoveredRole(node, children) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParentMap(nodes: VomNode[]): Map<number, number | null> {
|
||||||
|
const ids = new Set(nodes.map((node) => node.id));
|
||||||
|
const parentMap = new Map<number, number | null>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
parentMap.set(node.id, node.parentId !== null && ids.has(node.parentId) ? node.parentId : null);
|
||||||
|
}
|
||||||
|
return parentMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRecovery(nodes: VomNode[]): VomNode[] {
|
||||||
|
const parentMap = buildParentMap(nodes);
|
||||||
|
const children = buildChildren(nodes);
|
||||||
|
const recoveredIds = new Set<number>();
|
||||||
|
const recoveredRoleById = new Map<number, string>();
|
||||||
|
const recoveredNameById = new Map<number, string>();
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (shouldReference(node)) continue;
|
||||||
|
const role = recoveredRole(node, children);
|
||||||
|
if (!role || !passesRecoveryGuards(node, children)) continue;
|
||||||
|
recoveredIds.add(node.id);
|
||||||
|
recoveredRoleById.set(node.id, role);
|
||||||
|
const name = recoveryCandidateName(node, children);
|
||||||
|
if (name) recoveredNameById.set(node.id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!recoveredIds.has(node.id)) continue;
|
||||||
|
let current = parentMap.get(node.id) ?? null;
|
||||||
|
let guard = 0;
|
||||||
|
while (current !== null && guard <= parentMap.size) {
|
||||||
|
recoveredIds.delete(current);
|
||||||
|
current = parentMap.get(current) ?? null;
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (!recoveredIds.has(node.id)) return node;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
role: recoveredRoleById.get(node.id),
|
||||||
|
name: recoveredNameById.get(node.id) ?? node.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChildren(nodes: VomNode[]): Map<number | null, VomNode[]> {
|
function buildChildren(nodes: VomNode[]): Map<number | null, VomNode[]> {
|
||||||
@@ -112,11 +327,222 @@ function buildChildren(nodes: VomNode[]): Map<number | null, VomNode[]> {
|
|||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNodeLine(node: VomNode, depth: number, ref: string | undefined): string {
|
function duplicateReferenceNames(nodes: VomNode[]): Set<string> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!shouldReference(node)) continue;
|
||||||
|
const name = cleaned(node.name);
|
||||||
|
if (!name) continue;
|
||||||
|
const key = normalizeContextKey(name);
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return new Set([...counts].filter(([, count]) => count > 1).map(([name]) => name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsHandleContext(nodeName: string, duplicateRefNames: Set<string>): boolean {
|
||||||
|
const key = normalizeContextKey(nodeName);
|
||||||
|
if (!key) return false;
|
||||||
|
return duplicateRefNames.has(key) || WEAK_ACTION_LABELS.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextLabel(node: VomNode, targetName: string): string | undefined {
|
||||||
|
if (
|
||||||
|
!["heading", "article", "section", "group", "region", "row", "form"].includes(
|
||||||
|
normalizedRole(node),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return weakContextText(
|
||||||
|
cleaned(node.name) ?? cleaned(node.text) ?? cleaned(node.nearbyText),
|
||||||
|
targetName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function weakLabelContext(node: VomNode, targetName: string): string | undefined {
|
||||||
|
if (shouldReference(node)) return undefined;
|
||||||
|
return weakContextText(
|
||||||
|
cleaned(node.name) ?? cleaned(node.text) ?? cleaned(node.nearbyText),
|
||||||
|
targetName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function weakContextText(label: string | undefined, targetName: string): string | undefined {
|
||||||
|
const clean = cleaned(label);
|
||||||
|
if (!clean) return undefined;
|
||||||
|
const key = normalizeContextKey(clean);
|
||||||
|
if (
|
||||||
|
!key ||
|
||||||
|
key === normalizeContextKey(targetName) ||
|
||||||
|
key.startsWith("ctx_") ||
|
||||||
|
key === "section" ||
|
||||||
|
WEAK_ACTION_LABELS.has(key)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextSource = "owned" | "same" | "dom";
|
||||||
|
|
||||||
|
interface ContextCandidate {
|
||||||
|
text: string;
|
||||||
|
source: ContextSource;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextSourceRank(source: ContextSource): number {
|
||||||
|
return source === "owned" ? 0 : source === "same" ? 1 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextCategory(key: string): string {
|
||||||
|
return key.match(/^[a-z\s]+/)?.[0].trim() ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextRank(key: string): number {
|
||||||
|
if (key.startsWith("level ")) return 0;
|
||||||
|
if (key.startsWith("group ")) return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeContexts(contexts: ContextCandidate[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const seenCategories = new Set<string>();
|
||||||
|
const sorted = [...contexts].sort(
|
||||||
|
(a, b) =>
|
||||||
|
contextSourceRank(a.source) - contextSourceRank(b.source) ||
|
||||||
|
contextRank(normalizeContextKey(a.text)) - contextRank(normalizeContextKey(b.text)) ||
|
||||||
|
a.order - b.order,
|
||||||
|
);
|
||||||
|
const unique: string[] = [];
|
||||||
|
for (const ctx of sorted) {
|
||||||
|
const clean = cleaned(ctx.text);
|
||||||
|
if (!clean) continue;
|
||||||
|
const key = normalizeContextKey(clean);
|
||||||
|
const category = `${ctx.source}:${contextCategory(key)}`;
|
||||||
|
if (!key || seen.has(key) || seenCategories.has(category)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
seenCategories.add(category);
|
||||||
|
unique.push(clean);
|
||||||
|
if (unique.length >= MAX_HANDLE_CONTEXT_ITEMS) break;
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContextBoundary(node: VomNode): boolean {
|
||||||
|
return ["region", "form", "section", "article", "row", "listitem", "group"].includes(
|
||||||
|
normalizedRole(node),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWeakLabelsFromSubtree(
|
||||||
|
node: VomNode,
|
||||||
|
nodeName: string,
|
||||||
|
state: RenderState,
|
||||||
|
labels: string[],
|
||||||
|
): void {
|
||||||
|
if (shouldReference(node)) return;
|
||||||
|
const label = weakLabelContext(node, nodeName);
|
||||||
|
if (label) {
|
||||||
|
labels.push(label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const child of state.children.get(node.id) ?? []) {
|
||||||
|
collectWeakLabelsFromSubtree(child, nodeName, state, labels);
|
||||||
|
if (labels.length >= MAX_HANDLE_CONTEXT_ITEMS) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectOwnedContext(
|
||||||
|
node: VomNode,
|
||||||
|
nodeName: string,
|
||||||
|
state: RenderState,
|
||||||
|
): ContextCandidate[] {
|
||||||
|
const contexts: ContextCandidate[] = [];
|
||||||
|
let current = state.parentMap.get(node.id) ?? null;
|
||||||
|
let guard = 0;
|
||||||
|
while (current !== null && guard <= state.parentMap.size) {
|
||||||
|
const parent = state.nodesById.get(current);
|
||||||
|
if (!parent) break;
|
||||||
|
const label = contextLabel(parent, nodeName);
|
||||||
|
if (label) contexts.push({ text: label, source: "owned", order: contexts.length });
|
||||||
|
current = state.parentMap.get(current) ?? null;
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
return contexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSameContainerContext(
|
||||||
|
node: VomNode,
|
||||||
|
nodeName: string,
|
||||||
|
state: RenderState,
|
||||||
|
): ContextCandidate[] {
|
||||||
|
const labels: string[] = [];
|
||||||
|
let childId = node.id;
|
||||||
|
let parentId = state.parentMap.get(node.id) ?? null;
|
||||||
|
let guard = 0;
|
||||||
|
while (parentId !== null && guard <= state.parentMap.size) {
|
||||||
|
const siblings = state.children.get(parentId) ?? [];
|
||||||
|
for (const sibling of siblings) {
|
||||||
|
if (sibling.id === childId) break;
|
||||||
|
collectWeakLabelsFromSubtree(sibling, nodeName, state, labels);
|
||||||
|
while (labels.length > MAX_HANDLE_CONTEXT_ITEMS) labels.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = state.nodesById.get(parentId);
|
||||||
|
if (!parent || isContextBoundary(parent)) break;
|
||||||
|
childId = parentId;
|
||||||
|
parentId = state.parentMap.get(parentId) ?? null;
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
return labels.map((text, index) => ({ text, source: "same", order: index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDomContext(
|
||||||
|
node: VomNode,
|
||||||
|
nodeName: string,
|
||||||
|
state: RenderState,
|
||||||
|
): ContextCandidate[] {
|
||||||
|
const ancestorIds = node.domAncestorIds ?? [];
|
||||||
|
if (ancestorIds.length === 0) return [];
|
||||||
|
const ancestors = new Set(ancestorIds);
|
||||||
|
const contexts: ContextCandidate[] = [];
|
||||||
|
|
||||||
|
for (const candidate of state.nodesById.values()) {
|
||||||
|
if (candidate.id === node.id) continue;
|
||||||
|
if (isContextBoundary(candidate) && !ancestorIds.includes(candidate.id)) continue;
|
||||||
|
const parentId = candidate.domParentId;
|
||||||
|
if (parentId === undefined || parentId === null || !ancestors.has(parentId)) continue;
|
||||||
|
const label = contextLabel(candidate, nodeName);
|
||||||
|
if (!label) continue;
|
||||||
|
const rank = ancestorIds.indexOf(parentId);
|
||||||
|
contexts.push({ text: label, source: "dom", order: rank < 0 ? contexts.length : rank });
|
||||||
|
}
|
||||||
|
|
||||||
|
return contexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContext(node: VomNode, state: RenderState): string[] {
|
||||||
|
const name = cleaned(node.name);
|
||||||
|
if (!name || !needsHandleContext(name, state.duplicateRefNames)) return [];
|
||||||
|
return dedupeContexts([
|
||||||
|
...collectOwnedContext(node, name, state),
|
||||||
|
...collectSameContainerContext(node, name, state),
|
||||||
|
...collectDomContext(node, name, state),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeLine(
|
||||||
|
node: VomNode,
|
||||||
|
depth: number,
|
||||||
|
ref: string | undefined,
|
||||||
|
context: string[] = [],
|
||||||
|
): string {
|
||||||
let line = `${" ".repeat(depth)}${ref ? `@${ref} ` : ""}${node.role}`;
|
let line = `${" ".repeat(depth)}${ref ? `@${ref} ` : ""}${node.role}`;
|
||||||
|
|
||||||
const name = cleaned(node.name);
|
const name = cleaned(node.name);
|
||||||
if (name) line += ` ${JSON.stringify(name)}`;
|
if (name) line += ` ${JSON.stringify(name)}`;
|
||||||
|
if (context.length > 0) line += ` [ctx: ${context.join(" > ")}]`;
|
||||||
|
|
||||||
// For link nodes with an external href, annotate so the agent can
|
// For link nodes with an external href, annotate so the agent can
|
||||||
// distinguish external navigation from same-origin links.
|
// distinguish external navigation from same-origin links.
|
||||||
@@ -139,6 +565,10 @@ interface RenderState {
|
|||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
stopped: boolean;
|
stopped: boolean;
|
||||||
|
children: Map<number | null, VomNode[]>;
|
||||||
|
parentMap: Map<number, number | null>;
|
||||||
|
nodesById: Map<number, VomNode>;
|
||||||
|
duplicateRefNames: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTree(
|
function renderTree(
|
||||||
@@ -164,7 +594,8 @@ function renderTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ref = shouldReference(node) ? `e${state.nextRef}` : undefined;
|
const ref = shouldReference(node) ? `e${state.nextRef}` : undefined;
|
||||||
const line = renderNodeLine(node, depth, ref);
|
const context = ref ? handleContext(node, state) : [];
|
||||||
|
const line = renderNodeLine(node, depth, ref, context);
|
||||||
const nextTokens = state.tokens + estimateTokens(line);
|
const nextTokens = state.tokens + estimateTokens(line);
|
||||||
if (nextTokens > state.maxTokens) {
|
if (nextTokens > state.maxTokens) {
|
||||||
state.truncated = true;
|
state.truncated = true;
|
||||||
@@ -184,6 +615,7 @@ function renderTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderNodes(nodes: VomNode[], options: VomOptions, initialLines: string[]): RenderState {
|
function renderNodes(nodes: VomNode[], options: VomOptions, initialLines: string[]): RenderState {
|
||||||
|
const children = buildChildren(nodes);
|
||||||
const state: RenderState = {
|
const state: RenderState = {
|
||||||
lines: [...initialLines],
|
lines: [...initialLines],
|
||||||
refs: [],
|
refs: [],
|
||||||
@@ -193,9 +625,13 @@ function renderNodes(nodes: VomNode[], options: VomOptions, initialLines: string
|
|||||||
maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
stopped: false,
|
stopped: false,
|
||||||
|
children,
|
||||||
|
parentMap: buildParentMap(nodes),
|
||||||
|
nodesById: new Map(nodes.map((node) => [node.id, node])),
|
||||||
|
duplicateRefNames: duplicateReferenceNames(nodes),
|
||||||
};
|
};
|
||||||
|
|
||||||
renderTree(buildChildren(nodes), null, 1, state);
|
renderTree(children, null, 1, state);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -247,10 +683,12 @@ function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOp
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderVom(scene: VomScene, options: VomOptions = {}): VomResult {
|
export function renderVom(scene: VomScene, options: VomOptions = {}): VomResult {
|
||||||
const layer = detectBlockingLayer(scene.nodes, scene.viewport);
|
const nodes = applyRecovery(scene.nodes);
|
||||||
if (layer) return renderDoubleLayer(scene, layer, options);
|
const renderScene = { ...scene, nodes };
|
||||||
|
const layer = detectBlockingLayer(nodes, scene.viewport);
|
||||||
|
if (layer) return renderDoubleLayer(renderScene, layer, options);
|
||||||
|
|
||||||
const state = renderNodes(scene.nodes, options, [
|
const state = renderNodes(nodes, options, [
|
||||||
"@vom 1",
|
"@vom 1",
|
||||||
`@view ${scene.viewport.width}x${scene.viewport.height}`,
|
`@view ${scene.viewport.width}x${scene.viewport.height}`,
|
||||||
"@layers 1 focus=L1",
|
"@layers 1 focus=L1",
|
||||||
|
|||||||
@@ -26,15 +26,25 @@ export interface VomNode {
|
|||||||
name?: string;
|
name?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
href?: string; // hostname of external link target; omitted for same-origin links
|
href?: string; // hostname of external link target; omitted for same-origin links
|
||||||
|
text?: string;
|
||||||
|
nearbyText?: string;
|
||||||
|
|
||||||
tag: string;
|
tag: string;
|
||||||
rect: Rect | null;
|
rect: Rect | null;
|
||||||
paintOrder: number;
|
paintOrder: number;
|
||||||
position: string;
|
position: string;
|
||||||
pointerEvents: string;
|
pointerEvents: string;
|
||||||
|
cursor?: string;
|
||||||
|
attrs?: Record<string, string>;
|
||||||
|
domParentId?: number | null;
|
||||||
|
domAncestorIds?: number[];
|
||||||
|
|
||||||
modal?: boolean;
|
modal?: boolean;
|
||||||
sensitive?: boolean;
|
sensitive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
inert?: boolean;
|
||||||
|
hasNativeDescendant?: boolean;
|
||||||
|
insideNative?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VomScene {
|
export interface VomScene {
|
||||||
|
|||||||
Reference in new issue
Block a user