Merge pull request #122 from Tencent/feat/vom-functional-refactor

feat(vom): refactor semantic structure generation for grouped observations | 重构语义结构生成,支持分组式 VOM 观察
This commit is contained in:
Hannah Liu
2026-08-28 11:24:34 +08:00
committed by GitHub
21 changed files with 3305 additions and 853 deletions
+78
View File
@@ -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([
+6 -1
View File
@@ -1,4 +1,9 @@
export { applyVomInteractionRecovery, isVomReferenceNode, renderVom } from "./render";
export {
applyVomInteractionRecovery,
isVomReferenceNode,
isVomStructuralRole,
renderVom,
} from "./render";
export type {
ActiveScopeBlock,
BlockingLayer,
+42 -16
View File
@@ -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;
}
+2
View File
@@ -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;