Skip to content

Commit 8f2ec1d

Browse files
committed
feat(dom-snapshot): implement cursor: pointer detection for interactive elements and enhance test coverage
1 parent f3e04d3 commit 8f2ec1d

File tree

2 files changed

+245
-0
lines changed

2 files changed

+245
-0
lines changed

packages/dom-snapshot/src/__tests__/dom-automation.test.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,35 @@ describe("DOM snapshot collector", () => {
315315
expect(labelledDiv?.name).toBe("Description Label");
316316
});
317317

318+
it("captures span with aria-label inside nested structure (real-world icon button)", () => {
319+
setHtml(`
320+
<div class="ant-space-item">
321+
<span aria-describedby="rh">
322+
<span class="anticon zcp-icon" aria-label="Show Deploy Detail" data-testid="action-detail" style="font-size: 16px;">
323+
<svg class="icon" viewBox="0 0 1024 1024" width="200" height="200">
324+
<path d="M833.013155 249.550056L468.049052"></path>
325+
</svg>
326+
</span>
327+
</span>
328+
</div>
329+
`);
330+
331+
const snapshot = collectDomSnapshot(document);
332+
const nodes = Object.values(snapshot.idToNode);
333+
334+
// The span with aria-label="Show Deploy Detail" should be captured
335+
const iconSpan = nodes.find((n) => n.name === "Show Deploy Detail");
336+
expect(iconSpan).toBeTruthy();
337+
expect(iconSpan?.tagName).toBe("span");
338+
expect(iconSpan?.name).toBe("Show Deploy Detail");
339+
340+
// Search should find it
341+
const searchResult = searchAndFormat(snapshot, "Show Deploy Detail");
342+
expect(searchResult).not.toBeNull();
343+
expect(searchResult).toContain("Show Deploy Detail");
344+
expect(searchResult).not.toContain("No matches found");
345+
});
346+
318347
it("captures nested text content through multiple skipped generic elements", () => {
319348
setHtml(`
320349
<div>
@@ -1944,3 +1973,197 @@ describe("searchAndFormat", () => {
19441973
expect(result).toContain("Cancel");
19451974
});
19461975
});
1976+
1977+
describe("cursor: pointer detection", () => {
1978+
beforeEach(() => {
1979+
document.body.innerHTML = "";
1980+
});
1981+
1982+
it("includes elements with cursor: pointer style as interactive", () => {
1983+
// Add a style element with cursor: pointer
1984+
document.body.innerHTML = `
1985+
<style>
1986+
.clickable-card { cursor: pointer; }
1987+
</style>
1988+
<div class="clickable-card">
1989+
<span>Card Title</span>
1990+
<span>Card Description</span>
1991+
</div>
1992+
`;
1993+
1994+
const snapshot = collectDomSnapshot(document);
1995+
const nodes = Object.values(snapshot.idToNode);
1996+
1997+
// The clickable-card div should be captured as a node (not just its text children)
1998+
const cardNode = nodes.find(
1999+
(n) =>
2000+
n.tagName === "div" &&
2001+
n.children &&
2002+
n.children.some(
2003+
(c) => c.role === "StaticText" && c.name === "Card Title",
2004+
),
2005+
);
2006+
2007+
expect(cardNode).toBeDefined();
2008+
expect(cardNode?.id).toBeTruthy();
2009+
});
2010+
2011+
it("includes inline cursor: pointer elements", () => {
2012+
document.body.innerHTML = `
2013+
<div style="cursor: pointer;">Clickable Inline</div>
2014+
`;
2015+
2016+
const snapshot = collectDomSnapshot(document);
2017+
const nodes = Object.values(snapshot.idToNode);
2018+
2019+
// Should capture the div with cursor: pointer
2020+
const clickableDiv = nodes.find(
2021+
(n) =>
2022+
n.tagName === "div" &&
2023+
n.children?.some(
2024+
(c) => c.role === "StaticText" && c.name === "Clickable Inline",
2025+
),
2026+
);
2027+
2028+
expect(clickableDiv).toBeDefined();
2029+
});
2030+
2031+
it("assigns node IDs to cursor: pointer elements for automation", () => {
2032+
document.body.innerHTML = `
2033+
<style>.clickable { cursor: pointer; }</style>
2034+
<div class="clickable">Click Me</div>
2035+
`;
2036+
2037+
const snapshot = collectDomSnapshot(document);
2038+
const clickableEl = document.querySelector(".clickable");
2039+
const nodeId = clickableEl?.getAttribute("data-aipex-nodeid");
2040+
2041+
expect(nodeId).toBeTruthy();
2042+
expect(snapshot.idToNode[nodeId!]).toBeDefined();
2043+
});
2044+
2045+
it("captures card component with cursor-pointer (simulating shadcn/ui card)", () => {
2046+
document.body.innerHTML = `
2047+
<style>.cursor-pointer { cursor: pointer; }</style>
2048+
<div data-slot="card" class="cursor-pointer bg-card rounded-xl border shadow-sm">
2049+
<div data-slot="card-header" class="flex flex-row items-center">
2050+
<div class="p-2 rounded-md bg-gray-100">
2051+
<svg class="size-4"></svg>
2052+
</div>
2053+
<div>
2054+
<div data-slot="card-title" class="text-base font-semibold">
2055+
deploy-k8s-workloads
2056+
</div>
2057+
<div class="text-sm text-gray-600 mt-1">
2058+
Usage zam and kapp deploy k8s workloads
2059+
</div>
2060+
</div>
2061+
</div>
2062+
</div>
2063+
`;
2064+
2065+
const snapshot = collectDomSnapshot(document);
2066+
const cardEl = document.querySelector('[data-slot="card"]');
2067+
const nodeId = cardEl?.getAttribute("data-aipex-nodeid");
2068+
2069+
expect(nodeId).toBeTruthy();
2070+
expect(snapshot.idToNode[nodeId!]).toBeDefined();
2071+
expect(snapshot.idToNode[nodeId!]?.tagName).toBe("div");
2072+
});
2073+
2074+
it("searchAndFormat finds text within cursor-pointer card", () => {
2075+
document.body.innerHTML = `
2076+
<style>.cursor-pointer { cursor: pointer; }</style>
2077+
<div class="cursor-pointer">
2078+
<span>deploy-k8s-workloads</span>
2079+
<span>Usage zam and kapp deploy</span>
2080+
</div>
2081+
`;
2082+
2083+
const snapshot = collectDomSnapshot(document);
2084+
const result = searchAndFormat(snapshot, "deploy-k8s-workloads");
2085+
2086+
expect(result).not.toBeNull();
2087+
expect(result).toContain("deploy-k8s-workloads");
2088+
expect(result).not.toContain("No matches found");
2089+
});
2090+
2091+
it("captures nested cursor-pointer elements with separate IDs", () => {
2092+
document.body.innerHTML = `
2093+
<style>
2094+
.outer-card { cursor: pointer; }
2095+
.inner-tag { cursor: pointer; }
2096+
</style>
2097+
<div class="outer-card">
2098+
<h3>Card Title</h3>
2099+
<span class="inner-tag">Clickable Tag</span>
2100+
</div>
2101+
`;
2102+
2103+
collectDomSnapshot(document);
2104+
const outerEl = document.querySelector(".outer-card");
2105+
const innerEl = document.querySelector(".inner-tag");
2106+
2107+
expect(outerEl?.getAttribute("data-aipex-nodeid")).toBeTruthy();
2108+
expect(innerEl?.getAttribute("data-aipex-nodeid")).toBeTruthy();
2109+
expect(outerEl?.getAttribute("data-aipex-nodeid")).not.toBe(
2110+
innerEl?.getAttribute("data-aipex-nodeid"),
2111+
);
2112+
});
2113+
2114+
it("captures ant-tag with cursor-pointer as clickable element", () => {
2115+
document.body.innerHTML = `
2116+
<style>.cursor-pointer { cursor: pointer; }</style>
2117+
<span class="ant-tag cursor-pointer text-blue-500">
2118+
dev/main/va1/meta
2119+
</span>
2120+
`;
2121+
2122+
const snapshot = collectDomSnapshot(document);
2123+
const tagEl = document.querySelector(".ant-tag");
2124+
const nodeId = tagEl?.getAttribute("data-aipex-nodeid");
2125+
2126+
expect(nodeId).toBeTruthy();
2127+
expect(snapshot.idToNode[nodeId!]).toBeDefined();
2128+
expect(snapshot.idToNode[nodeId!]?.tagName).toBe("span");
2129+
});
2130+
2131+
it("does not treat cursor: default elements as interactive", () => {
2132+
document.body.innerHTML = `
2133+
<style>.not-clickable { cursor: default; }</style>
2134+
<div class="not-clickable">Not Clickable</div>
2135+
`;
2136+
2137+
const snapshot = collectDomSnapshot(document);
2138+
const nodes = Object.values(snapshot.idToNode);
2139+
2140+
// Should not include the div as a separate node since it has cursor: default
2141+
// The text should still be captured as StaticText
2142+
const staticTextNode = nodes.find(
2143+
(n) => n.role === "StaticText" && n.name === "Not Clickable",
2144+
);
2145+
expect(staticTextNode).toBeDefined();
2146+
});
2147+
2148+
it("captures table row with cursor-pointer for row click actions", () => {
2149+
document.body.innerHTML = `
2150+
<style>.clickable-row { cursor: pointer; }</style>
2151+
<table>
2152+
<tbody>
2153+
<tr class="clickable-row">
2154+
<td>Row Data 1</td>
2155+
<td>Row Data 2</td>
2156+
</tr>
2157+
</tbody>
2158+
</table>
2159+
`;
2160+
2161+
const snapshot = collectDomSnapshot(document);
2162+
const rowEl = document.querySelector(".clickable-row");
2163+
const nodeId = rowEl?.getAttribute("data-aipex-nodeid");
2164+
2165+
expect(nodeId).toBeTruthy();
2166+
expect(snapshot.idToNode[nodeId!]).toBeDefined();
2167+
expect(snapshot.idToNode[nodeId!]?.tagName).toBe("tr");
2168+
});
2169+
});

packages/dom-snapshot/src/collector.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,23 @@ function hasExplicitAccessibleLabel(
352352
return false;
353353
}
354354

355+
/**
356+
* Check if an element has cursor: pointer style, indicating it's clickable via CSS/JS.
357+
* This helps identify interactive elements that don't use semantic HTML.
358+
*/
359+
function hasCursorPointer(element: Element, rootDocument: Document): boolean {
360+
if (!(element instanceof HTMLElement)) {
361+
return false;
362+
}
363+
364+
const style = rootDocument.defaultView?.getComputedStyle(element);
365+
if (!style) {
366+
return false;
367+
}
368+
369+
return style.cursor === "pointer";
370+
}
371+
355372
function shouldIncludeElement(
356373
element: Element,
357374
options: CollectorOptions,
@@ -377,6 +394,11 @@ function shouldIncludeElement(
377394
return true;
378395
}
379396

397+
// Include elements with cursor: pointer style (clickable via CSS/JS)
398+
if (hasCursorPointer(element, rootDocument)) {
399+
return true;
400+
}
401+
380402
if (role === "image") {
381403
const img = element as HTMLImageElement;
382404
return Boolean(img.alt && img.alt.trim().length > 0);

0 commit comments

Comments
 (0)