Skip to content

Commit b17abe9

Browse files
authored
Merge pull request #482 from Tencent/fix/dynamic-menu-refs
fix: recover refs for JavaScript click listeners
2 parents b15d1d5 + 2c05a46 commit b17abe9

10 files changed

Lines changed: 321 additions & 12 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// @vitest-environment node
2+
// Opt in with BSK_CLICK_CHROME; the harness owns an isolated browser/profile.
3+
import { describe, expect, it } from "vitest";
4+
import { SessionManager } from "@/session-manager/manager";
5+
import { handleClick } from "../interaction";
6+
import { handleObserve, handleSnapshot } from "../observation";
7+
import type { CdpRunner } from "../shared";
8+
9+
type Send = <T = Record<string, unknown>>(
10+
method: string,
11+
params?: object,
12+
sessionId?: string,
13+
) => Promise<T>;
14+
15+
describe.skipIf(!process.env.BSK_CLICK_CHROME)("click listener observations", () => {
16+
it.each([
17+
{ name: "snapshot", observe: handleSnapshot },
18+
{ name: "observe", observe: handleObserve },
19+
])("$name exposes current dynamic menu items without inline handlers", async ({ observe }) => {
20+
const { withChrome } = await import(
21+
new URL(
22+
"../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs",
23+
import.meta.url,
24+
).href
25+
);
26+
await withChrome(
27+
{ executable: process.env.BSK_CLICK_CHROME, deviceScale: 1, zoom: 1 },
28+
async (send: Send) => {
29+
const { targetId } = await send<{ targetId: string }>("Target.createTarget", {
30+
url: "about:blank",
31+
});
32+
const { sessionId } = await send<{ sessionId: string }>("Target.attachToTarget", {
33+
targetId,
34+
flatten: true,
35+
});
36+
const local: Send = (method, params) => send(method, params, sessionId);
37+
const evaluate = async <T>(expression: string) => {
38+
const reply = await local<{ result: { value: T }; exceptionDetails?: unknown }>(
39+
"Runtime.evaluate",
40+
{ expression, returnByValue: true, awaitPromise: true },
41+
);
42+
expect(reply.exceptionDetails).toBeUndefined();
43+
return reply.result.value;
44+
};
45+
const cdp: CdpRunner = {
46+
send: (_tabId, method, params) => local(method, params),
47+
sendToTarget: (_target, method, params) => local(method, params),
48+
getAttachmentId: () => sessionId,
49+
getFrameGraph: async () => {
50+
const { frameTree } = await local<{ frameTree: { frame: { id: string } } }>(
51+
"Page.getFrameTree",
52+
);
53+
return {
54+
rootFrameId: frameTree.frame.id,
55+
frames: [{ frameId: frameTree.frame.id, target: { tabId: 4 } }],
56+
};
57+
},
58+
};
59+
const manager = new SessionManager({
60+
agentWindow: {
61+
create: async () => ({ windowId: 100, initialTabIds: [4] }),
62+
remove: async () => {},
63+
ensureActiveTab: async () => 4,
64+
},
65+
});
66+
const ctx = await manager.start("click-listener-observation");
67+
const tab = { id: 4, windowId: 100, active: true, url: "about:blank" } as chrome.tabs.Tab;
68+
const deps = { cdp, tabsApi: { get: async () => tab, query: async () => [tab] } };
69+
const params = { session_id: ctx.sessionId };
70+
const read = async () => {
71+
const result = await observe(manager, params, deps);
72+
if (!("text" in result)) throw new Error(JSON.stringify(result));
73+
return result.text;
74+
};
75+
const ref = (text: string, label: string) =>
76+
text
77+
.split("\n")
78+
.find((line) => line.includes(`"${label}"`) && /@e\d+/.test(line))
79+
?.match(/@e\d+/)?.[0];
80+
81+
await evaluate(`
82+
document.body.innerHTML = '<button id="open">Open menu</button><div id="menu"></div>';
83+
window.clicks = [];
84+
window.recordClick = event => clicks.push({ id: event.currentTarget.id, trusted: event.isTrusted });
85+
document.addEventListener('click', () => {});
86+
document.querySelector('#open').addEventListener('click', async () => {
87+
await Promise.resolve();
88+
document.querySelector('#menu').innerHTML = \`
89+
<div id="custom-div">Div action</div>
90+
<ul>
91+
<li id="custom-li">List action</li>
92+
<li id="pointer-li" style="cursor:pointer">Pointer action</li>
93+
<li id="decorative" style="cursor:pointer">Section label</li>
94+
<li id="semantic" role="menuitem">Semantic action</li>
95+
</ul>
96+
<div id="disabled" aria-disabled="true">Disabled action</div>
97+
<div id="hidden" hidden>Hidden action</div>
98+
<div id="inert" inert>Inert action</div>
99+
<div inert><div id="inert-child">Inert child action</div></div>
100+
<div hidden><div id="hidden-child">Hidden child action</div></div>
101+
<div aria-hidden="true"><div id="aria-hidden-child">ARIA hidden child action</div></div>
102+
<div id="transparent" style="pointer-events:none">Transparent action</div>
103+
<div id="wrapper" aria-label="Wrapper action"><button id="native">Native action</button></div>
104+
\`;
105+
for (const id of ['custom-div', 'custom-li', 'pointer-li', 'semantic', 'disabled', 'hidden', 'inert', 'inert-child', 'hidden-child', 'aria-hidden-child', 'transparent', 'wrapper', 'native']) {
106+
document.getElementById(id).addEventListener('click', recordClick);
107+
}
108+
});
109+
`);
110+
const before = await read();
111+
expect(ref(before, "Div action")).toBeUndefined();
112+
const open = ref(before, "Open menu");
113+
expect(open, before).toBeDefined();
114+
expect(await handleClick(manager, { ...params, ref: open }, deps)).not.toHaveProperty(
115+
"code",
116+
);
117+
await expect.poll(() => evaluate("!!document.querySelector('#custom-li')")).toBe(true);
118+
const after = await read();
119+
const actions = [
120+
"Div action",
121+
"List action",
122+
"Pointer action",
123+
"Semantic action",
124+
"Native action",
125+
];
126+
for (const label of actions) expect(ref(after, label), after).toBeDefined();
127+
for (const label of [
128+
"Section label",
129+
"Disabled action",
130+
"Hidden action",
131+
"Inert action",
132+
"Inert child action",
133+
"Hidden child action",
134+
"ARIA hidden child action",
135+
"Transparent action",
136+
"Wrapper action",
137+
]) {
138+
expect(ref(after, label), after).toBeUndefined();
139+
}
140+
const firstNode = ctx.refStore.resolve(ref(after, "Div action")!);
141+
for (const label of actions.slice(0, 4)) {
142+
expect(
143+
await handleClick(manager, { ...params, ref: ref(after, label) }, deps),
144+
).not.toHaveProperty("code");
145+
}
146+
expect(await evaluate("clicks")).toEqual([
147+
{ id: "custom-div", trusted: true },
148+
{ id: "custom-li", trusted: true },
149+
{ id: "pointer-li", trusted: true },
150+
{ id: "semantic", trusted: true },
151+
]);
152+
153+
await evaluate(
154+
"document.querySelector('#custom-div').removeEventListener('click', recordClick)",
155+
);
156+
expect(ref(await read(), "Div action")).toBeUndefined();
157+
await evaluate(`
158+
const previous = document.querySelector('#custom-div');
159+
const replacement = previous.cloneNode(true);
160+
replacement.addEventListener('click', recordClick);
161+
previous.replaceWith(replacement);
162+
`);
163+
const refreshed = ref(await read(), "Div action");
164+
expect(refreshed).toBeDefined();
165+
expect(ctx.refStore.resolve(refreshed!)).not.toBe(firstNode);
166+
expect(await handleClick(manager, { ...params, ref: refreshed }, deps)).not.toHaveProperty(
167+
"code",
168+
);
169+
expect(await evaluate("clicks.at(-1)")).toEqual({ id: "custom-div", trusted: true });
170+
171+
// showModal escapes an inert ancestor; its live AX nodes must remain actionable.
172+
await evaluate(`
173+
const container = document.createElement('div');
174+
container.inert = true;
175+
container.innerHTML = '<dialog><div id="modal-item">Modal action</div></dialog>';
176+
document.body.append(container);
177+
document.querySelector('#modal-item').addEventListener('click', recordClick);
178+
container.querySelector('dialog').showModal();
179+
`);
180+
const modal = ref(await read(), "Modal action");
181+
expect(modal).toBeDefined();
182+
expect(await handleClick(manager, { ...params, ref: modal }, deps)).not.toHaveProperty(
183+
"code",
184+
);
185+
expect(await evaluate("clicks.at(-1)")).toEqual({ id: "modal-item", trusted: true });
186+
},
187+
);
188+
}, 30_000);
189+
});

‎apps/extension/src/tools/vom/__tests__/facts.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,26 @@ describe("document facts", () => {
9191
expect(nodes[0]).not.toHaveProperty("excluded");
9292
});
9393

94+
it.each([
95+
undefined,
96+
{ index: [1] },
97+
])("decodes sparse clickability by node index: %j", async (isClickable) => {
98+
const { nodes } = await decodeDocument(
99+
{
100+
nodes: {
101+
backendNodeId: [41, 72, 103],
102+
parentIndex: [-1, 0, 0],
103+
nodeName: [0, 1, 1],
104+
isClickable,
105+
},
106+
},
107+
["ul", "li"],
108+
);
109+
expect(nodes.filter((node) => node.clickable).map((node) => node.backendNodeId)).toEqual(
110+
isClickable ? [72] : [],
111+
);
112+
});
113+
94114
it("keeps short blocks synchronous and checks cancellation after yielding", async () => {
95115
const clock = vi.spyOn(performance, "now").mockReturnValue(0);
96116
try {

‎apps/extension/src/tools/vom/__tests__/semantic-graph.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ function document(
4141
}
4242

4343
describe("semantic VOM graph", () => {
44+
it.each<Record<string, string>>([
45+
{ hidden: "" },
46+
{ inert: "" },
47+
{ "aria-hidden": "true" },
48+
])("does not recover clickable DOM fallbacks inside excluded subtrees: %j", (attrs) => {
49+
const scene = buildSemanticVomScene({
50+
viewport: { width: 800, height: 600 },
51+
rootFrameId: "main",
52+
documents: [
53+
document(
54+
"main",
55+
[],
56+
[
57+
{ ...dom(3, 2, "div", {}, "Blocked action"), clickable: true },
58+
dom(2, 1, "div"),
59+
dom(1, null, "div", attrs),
60+
{ ...dom(4, null, "div", {}, "Available action"), clickable: true },
61+
],
62+
),
63+
],
64+
});
65+
expect(renderVom(scene).refs.map((ref) => ref.backendNodeId)).toEqual([4]);
66+
});
67+
4468
it.each([
4569
{ tag: "dialog", role: "dialog", axModal: true, expected: true },
4670
{ tag: "dialog", role: "dialog", axModal: false, expected: false },

‎apps/extension/src/tools/vom/capture-types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface CapturedNode {
2323
* `textContent`: the live parser always sets it, hand-built fixtures may not.
2424
*/
2525
cursor?: string;
26+
/** Browser-reported clickability, including JavaScript event listeners. */
27+
clickable?: boolean;
2628
/**
2729
* Whether the live DOM snapshot provides a painted, non-hidden box for this
2830
* node. Semantic resolution uses this only for DOM fallback nodes; AX-backed

‎apps/extension/src/tools/vom/semantic-graph/resolve.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ export function resolveSemanticGraph(
493493
position: node.dom?.position || "static",
494494
pointerEvents: node.dom?.pointerEvents || "auto",
495495
...(node.dom?.cursor ? { cursor: node.dom.cursor } : {}),
496+
...(node.dom?.clickable ? { clickable: true } : {}),
496497
...(role ? { role } : {}),
497498
...(name ? { name } : {}),
498499
...(value !== undefined ? { value } : {}),

‎apps/extension/src/tools/vom/semantic-graph/structure.ts‎

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,36 @@ function hasUsableAxSemantics(node: ResolvedSemanticNode): boolean {
5353
return node.ax !== undefined && node.ax.ignored !== true;
5454
}
5555

56-
function isRenderedDomFallback(node: ResolvedSemanticNode): boolean {
57-
if (!node.dom) return false;
58-
const attrs = node.dom.attrs;
59-
if (
60-
Object.prototype.hasOwnProperty.call(attrs, "hidden") ||
61-
Object.prototype.hasOwnProperty.call(attrs, "inert") ||
62-
(attrs["aria-hidden"] ?? "").toLowerCase() === "true"
63-
) {
64-
return false;
56+
function blockedDomFallbacks(
57+
nodes: ResolvedSemanticNode[],
58+
domChildren: ReadonlyMap<SemanticNodeId, SemanticNodeId[]>,
59+
): Set<SemanticNodeId> {
60+
const blocked = new Set<SemanticNodeId>();
61+
for (const node of nodes) {
62+
const attrs = node.dom?.attrs ?? {};
63+
if (
64+
Object.prototype.hasOwnProperty.call(attrs, "hidden") ||
65+
Object.prototype.hasOwnProperty.call(attrs, "inert") ||
66+
(attrs["aria-hidden"] ?? "").toLowerCase() === "true"
67+
)
68+
blocked.add(node.id);
6569
}
70+
const queue = [...blocked];
71+
for (let index = 0; index < queue.length; index++) {
72+
for (const childId of domChildren.get(queue[index]) ?? []) {
73+
if (blocked.has(childId)) continue;
74+
blocked.add(childId);
75+
queue.push(childId);
76+
}
77+
}
78+
return blocked;
79+
}
80+
81+
function isRenderedDomFallback(
82+
node: ResolvedSemanticNode,
83+
blocked: ReadonlySet<SemanticNodeId>,
84+
): boolean {
85+
if (!node.dom || blocked.has(node.id)) return false;
6686
return node.dom.rendered ?? (node.dom.localRect != null || node.dom.rect != null);
6787
}
6888

@@ -156,10 +176,12 @@ function baseDecision(
156176
output: VomNode,
157177
frameOwnerIds: ReadonlySet<SemanticNodeId>,
158178
redundant: ReadonlySet<SemanticNodeId>,
179+
blockedFallbacks: ReadonlySet<SemanticNodeId>,
159180
): StructureDecision {
160181
if (node.excluded) return { kind: "excluded", reason: "excluded" };
161182
if (redundant.has(node.id)) return { kind: "excluded", reason: "redundant-source" };
162-
if (!hasUsableAxSemantics(node) && !isRenderedDomFallback(node)) {
183+
// Live AX semantics remain authoritative, including modal dialogs escaping inert ancestors.
184+
if (!hasUsableAxSemantics(node) && !isRenderedDomFallback(node, blockedFallbacks)) {
163185
return { kind: "excluded", reason: "non-rendered-dom-fallback" };
164186
}
165187
if (frameOwnerIds.has(node.id)) return { kind: "keep", reason: "frame-owner" };
@@ -403,15 +425,23 @@ export function normalizeSemanticStructure(graph: ResolvedSemanticGraph): Struct
403425
const frameOwnerIds = new Set(
404426
[...graph.frames.values()].flatMap((frame) => frame.ownerNodeId ?? []),
405427
);
428+
const domChildren = buildChildren(nodes, "domParentId");
429+
const blockedFallbacks = blockedDomFallbacks(nodes, domChildren);
406430
const decisions = new Map<SemanticNodeId, StructureDecision>();
407431
for (const node of nodes) {
408432
decisions.set(
409433
node.id,
410-
baseDecision(graph, node, recovered.get(node.id) as VomNode, frameOwnerIds, redundant),
434+
baseDecision(
435+
graph,
436+
node,
437+
recovered.get(node.id) as VomNode,
438+
frameOwnerIds,
439+
redundant,
440+
blockedFallbacks,
441+
),
411442
);
412443
}
413444

414-
const domChildren = buildChildren(nodes, "domParentId");
415445
const candidates = reliableGroupCandidates(graph, nodes, recovered, decisions, domChildren);
416446
for (const nodeId of leafCandidates(nodes, candidates, domChildren)) {
417447
decisions.set(nodeId, {

‎apps/extension/src/tools/vom/snapshot.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface SnapshotDocument {
6767
textValue?: SparseArray;
6868
inputChecked?: RareBooleanData;
6969
optionSelected?: RareBooleanData;
70+
isClickable?: RareBooleanData;
7071
};
7172
layout?: {
7273
nodeIndex?: number[];
@@ -150,6 +151,7 @@ export async function decodeDocument(
150151
const textValues = sparseIndexMap(dn.textValue);
151152
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
152153
const selectedOptions = new Set(dn.optionSelected?.index ?? []);
154+
const clickableNodes = new Set(dn.isClickable?.index ?? []);
153155

154156
// Collect visible text from #text child nodes so element CapturedNodes
155157
// carry a textContent value usable as a button/link label fallback.
@@ -241,6 +243,7 @@ export async function decodeDocument(
241243
position: styles.position || "static",
242244
pointerEvents: styles["pointer-events"] || "auto",
243245
cursor: styles.cursor || "auto",
246+
...(clickableNodes.has(n) ? { clickable: true } : {}),
244247

245248
textContent,
246249
...(formValue !== undefined ? { formValue } : {}),

0 commit comments

Comments
 (0)