fix: resolve unnamed element refs matching multiple elements (#573)

* fix: resolve unnamed element refs matching multiple elements (#500)

When a page has one unnamed button among several named buttons,
clicking its ref fails with "matched N elements" because the
locator `getByRole('button')` matches all buttons on the page.

Normalize unnamed interactive elements to `name: ""` so the
selector becomes `getByRole('button', { name: "", exact: true })`
which matches only buttons with empty accessible names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove dead code branch in buildSelector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: make RefMap.name required string, remove dead code branches

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jin.2
2026-03-01 09:42:05 -06:00
committed by GitHub
co-authored by Claude Opus 4.6 hyunjinee
parent 79d8dfe34c
commit 7238b7da4c
4 changed files with 45 additions and 23 deletions
+16 -16
View File
@@ -23,7 +23,7 @@ export interface RefMap {
[ref: string]: {
selector: string;
role: string;
name?: string;
name: string;
/** Index for disambiguation when multiple elements have same role+name */
nth?: number;
};
@@ -130,12 +130,9 @@ const STRUCTURAL_ROLES = new Set([
/**
* Build a selector string for storing in ref map
*/
function buildSelector(role: string, name?: string): string {
if (name) {
const escapedName = JSON.stringify(name);
return `getByRole('${role}', { name: ${escapedName}, exact: true })`;
}
return `getByRole('${role}')`;
function buildSelector(role: string, name: string): string {
const escapedName = JSON.stringify(name);
return `getByRole('${role}', { name: ${escapedName}, exact: true })`;
}
/**
@@ -293,7 +290,7 @@ export async function getEnhancedSnapshot(
const cursorElements = await findCursorInteractiveElements(page, options.selector);
// Filter out elements whose text is already captured in the snapshot
const existingTexts = new Set(Object.values(refs).map((r) => r.name?.toLowerCase()));
const existingTexts = new Set(Object.values(refs).map((r) => r.name.toLowerCase()));
// Also extract quoted strings from the ARIA tree for broader dedup
for (const m of enhancedTree.matchAll(/"([^"]+)"/g)) {
existingTexts.add(m[1].toLowerCase());
@@ -404,12 +401,13 @@ function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOption
if (INTERACTIVE_ROLES.has(roleLower)) {
const ref = nextRef();
const nth = tracker.getNextIndex(roleLower, name);
tracker.trackRef(roleLower, name, ref);
const resolvedName = name ?? '';
const nth = tracker.getNextIndex(roleLower, resolvedName);
tracker.trackRef(roleLower, resolvedName, ref);
refs[ref] = {
selector: buildSelector(roleLower, name),
selector: buildSelector(roleLower, resolvedName),
role: roleLower,
name,
name: resolvedName,
nth, // Always store nth, we'll use it for duplicates
};
@@ -531,13 +529,15 @@ function processLine(
if (shouldHaveRef) {
const ref = nextRef();
const nth = tracker.getNextIndex(roleLower, name);
tracker.trackRef(roleLower, name, ref);
// Normalize to "" so unnamed elements get exact-match selectors
const resolvedName = isInteractive ? (name ?? '') : name!;
const nth = tracker.getNextIndex(roleLower, resolvedName);
tracker.trackRef(roleLower, resolvedName, ref);
refs[ref] = {
selector: buildSelector(roleLower, name),
selector: buildSelector(roleLower, resolvedName),
role: roleLower,
name,
name: resolvedName,
nth, // Always store nth, we'll clean up non-duplicates later
};