Skip to content

Improve browser selector stability - #123

Open
8b86gww4t6-ops wants to merge 1 commit into
webdriverio:mainfrom
8b86gww4t6-ops:selector-stability-ladder
Open

Improve browser selector stability#123
8b86gww4t6-ops wants to merge 1 commit into
webdriverio:mainfrom
8b86gww4t6-ops:selector-stability-ladder

Conversation

@8b86gww4t6-ops

Copy link
Copy Markdown

Summary

  • Prefer explicit browser test hooks (data-testid, data-test, data-qa) before accessible-name selectors
  • Skip generated-looking IDs before falling back to stable attributes and scoped CSS
  • Cover selector priority in interactable element and accessibility tree tests

Verification

  • pnpm install --frozen-lockfile
  • pnpm exec tsc --noEmit
  • pnpm test

Notes

  • pnpm audit reports existing dependency advisories unrelated to this selector change

@linux-foundation-easycla

linux-foundation-easycla Bot commented Jun 30, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: 8b86gww4t6-ops / name: Brandon James (544e044)

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

Greptile Summary

This PR rewrites the selector-generation logic in both browser scripts to prefer explicit test hooks (data-testid, data-test, data-qa) and ARIA-based selectors over the previous text-content heuristic, and introduces isLikelyGeneratedId to avoid anchoring selectors on machine-generated UUIDs and framework IDs. The CSS path fallback is also updated to skip generated IDs on both the target element and its ancestors.

  • New selector priority (both scripts): test hooks → unique aria/name → stable #id[name][placeholder] → class → structural CSS path.
  • isLikelyGeneratedId guards UUID, long-hex, and common framework ID patterns in both the early #id return and the CSS path loop.
  • Tests cover the new priority order, data-test/data-qa variants, generated-ID skipping (early return and CSS-path ancestor), and placeholder selectors.

Confidence Score: 4/5

Safe to merge; the new selector logic is well-tested and the generated-ID guards work correctly in all covered paths.

The selector priority rewrite is correct and well-covered by new tests. The only notable gap is that getSelectorAccessibleName omits the title fallback that getAccessibleName uses for display, so elements named only via title show a name in the accessibility tree but get a CSS selector rather than aria/title-text. No data is lost and no selector breaks — it is a consistency issue between the displayed name and the generated selector, not a functional failure.

Both src/scripts/get-browser-accessibility-tree.ts and src/scripts/get-interactable-browser-elements.ts — specifically the getSelectorAccessibleName function in each, which diverges from getAccessibleName in its handling of the title attribute.

Important Files Changed

Filename Overview
src/scripts/get-browser-accessibility-tree.ts Adds getSelectorAccessibleName, isLikelyGeneratedId, and uniqueCssSelector helpers; rewrites getSelector to prioritise test-hook attributes, then aria/ name, then stable id/name/placeholder/class, then CSS path. The CSS path loop now guards generated IDs correctly. Minor: getSelectorAccessibleName omits the title fallback that getAccessibleName uses, so title-only named elements get a CSS selector even though aria/title-text would work.
src/scripts/get-interactable-browser-elements.ts Adds getRole, getSelectorAccessibleName, isLikelyGeneratedId, and uniqueCssSelector helpers; rewrites getSelector with the same priority order as the accessibility-tree script. Also skips generated IDs in the CSS path loop. Contains the same title fallback omission as the accessibility-tree script.
tests/scripts/accessibility-tree.test.ts Adds tests for test-hook priority, data-test/data-qa hooks, generated-ID skipping (both early-return and CSS-path fallback), and placeholder selectors. Correctly updates assertions from tag*=text to aria/name.
tests/scripts/interactable-elements.test.ts Mirrors the accessibility-tree test additions: test-hook priority, aria/name fallback, generated-ID skipping, CSS-path fallback, and placeholder selector. All assertions look correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[getSelector called] --> B{data-testid / data-test / data-qa unique?}
    B -- yes --> Z1["return [attr=value]"]
    B -- no --> C{role + accessible name unique?}
    C -- yes --> Z2["return aria/name"]
    C -- no --> D{element.id and NOT generated?}
    D -- yes --> Z3["return #id"]
    D -- no --> E{"[name] unique?"}
    E -- yes --> Z4["return tag[name=val]"]
    E -- no --> F{"[placeholder] unique?"}
    F -- yes --> Z5["return tag[placeholder=val]"]
    F -- no --> G{unique class selector?}
    G -- yes --> Z6["return tag.class"]
    G -- no --> H[CSS path walk ≤ 4 levels]
    H --> I{ancestor.id and NOT generated?}
    I -- yes --> Z7["return #ancestor-id > … > tag"]
    I -- no --> Z8["return structural path"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[getSelector called] --> B{data-testid / data-test / data-qa unique?}
    B -- yes --> Z1["return [attr=value]"]
    B -- no --> C{role + accessible name unique?}
    C -- yes --> Z2["return aria/name"]
    C -- no --> D{element.id and NOT generated?}
    D -- yes --> Z3["return #id"]
    D -- no --> E{"[name] unique?"}
    E -- yes --> Z4["return tag[name=val]"]
    E -- no --> F{"[placeholder] unique?"}
    F -- yes --> Z5["return tag[placeholder=val]"]
    F -- no --> G{unique class selector?}
    G -- yes --> Z6["return tag.class"]
    G -- no --> H[CSS path walk ≤ 4 levels]
    H --> I{ancestor.id and NOT generated?}
    I -- yes --> Z7["return #ancestor-id > … > tag"]
    I -- no --> Z8["return structural path"]
Loading

Reviews (2): Last reviewed commit: "Improve browser selector stability" | Re-trigger Greptile

Comment on lines +188 to 196
if (role && accessibleName && accessibleName.length <= 80) {
let matchCount = 0;
document.querySelectorAll('*').forEach(el => {
const htmlEl = el as HTMLElement;
const candidateRole = getRole(htmlEl);
if (isVisible(htmlEl) && candidateRole && getSelectorAccessibleName(htmlEl, candidateRole) === accessibleName) matchCount++;
});
if (matchCount === 1) return `aria/${accessibleName}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 querySelectorAll('*') scans the entire DOM on every aria/ uniqueness check

The interactable-elements script narrows the scan to interactableSelectors, but the accessibility-tree script calls document.querySelectorAll('*').forEach(...) for every element that has a role and accessible name. On a real page with thousands of DOM nodes — and with getSelector being called once per accessibility node — this is an O(n²) walk that can noticeably stall browser.execute() on content-heavy pages. Consider narrowing to a role-bearing subset rather than scanning every element in the document.

@8b86gww4t6-ops
8b86gww4t6-ops force-pushed the selector-stability-ladder branch from 544e044 to 43f9417 Compare June 30, 2026 23:18
const accessibleName = getSelectorAccessibleName(element, role);
if (role && accessibleName && accessibleName.length <= 80) {
let matchCount = 0;
document.querySelectorAll('*').forEach(el => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: querySelectorAll('*') walks the entire DOM for every aria/ uniqueness check -> O(n²)

Each invocation of getSelector() for an element with a viable accessible name scans all DOM elements (line 190), calling getRole(), isVisible(), and getSelectorAccessibleName() on every one. On a 10K-element page with 100 elements hitting this branch, that's 1M iterations, each non-trivial.

The interactable-elements script (line 202) correctly scopes this to interactableSelectors. At minimum, this should scope to elements that the walker actually assigns roles to — e.g. '[role], [aria-label], [aria-labelledby], a[href], button, input, select, textarea, h1-h6, img, nav, main, header, footer, aside, dialog, form, section'.

Ideally, precompute role + accessibleName → count in a single pass during the tree walk and do O(1) lookups here instead.

return (el.textContent?.trim().replace(/\s+/g, ' ') || '').slice(0, 100);
}

function getSelectorAccessibleName(el: HTMLElement, role: string | null): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: getAccessibleName and getSelectorAccessibleName are near-duplicates with subtle behavioral differences

The only differences: getSelectorAccessibleName omits placeholder and title fallbacks and adds a CONTAINER_ROLES guard. These omissions are correct for selector generation — placeholder and title produce brittle selectors — but the intent is obscured by having two 30-line functions that are ~80% identical.

If someone patches getAccessibleName in the future without realizing getSelectorAccessibleName exists, selector names will silently diverge from the reported name field.

return null;
}

function getSelectorAccessibleName(el: HTMLElement): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Same getAccessibleName / getSelectorAccessibleName duplication as the accessibility-tree script

getAccessibleName (L60–109) covers 8 fallback layers. getSelectorAccessibleName (L137–172) stops at textContent, skipping placeholder and title. The behavioral split is intentional (placeholder/title selectors are unstable), but the structural duplication carries the same maintenance risk as in the sibling script.

Same suggestion: rename to getStableSelectorName() with a comment explaining the deliberate omission.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants