mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
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:
@@ -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