From 43f94174b55d979d2f8b648caaef18e674adf4f9 Mon Sep 17 00:00:00 2001
From: Brandon James <8b86gww4t6-ops@users.noreply.github.com>
Date: Tue, 30 Jun 2026 22:39:02 +0000
Subject: [PATCH] Improve browser selector stability
---
src/scripts/get-browser-accessibility-tree.ts | 97 ++++++++++---
.../get-interactable-browser-elements.ts | 136 ++++++++++++++----
tests/scripts/accessibility-tree.test.ts | 49 ++++++-
tests/scripts/interactable-elements.test.ts | 45 +++++-
4 files changed, 276 insertions(+), 51 deletions(-)
diff --git a/src/scripts/get-browser-accessibility-tree.ts b/src/scripts/get-browser-accessibility-tree.ts
index a4c6e74..54b4ae5 100644
--- a/src/scripts/get-browser-accessibility-tree.ts
+++ b/src/scripts/get-browser-accessibility-tree.ts
@@ -124,43 +124,104 @@ const accessibilityTreeScript = () => (function () {
return (el.textContent?.trim().replace(/\s+/g, ' ') || '').slice(0, 100);
}
+ function getSelectorAccessibleName(el: HTMLElement, role: string | null): string {
+ const ariaLabel = el.getAttribute('aria-label');
+ if (ariaLabel) return ariaLabel.trim();
+
+ const labelledBy = el.getAttribute('aria-labelledby');
+ if (labelledBy) {
+ const texts = labelledBy.split(/\s+/)
+ .map(id => document.getElementById(id)?.textContent?.trim() || '')
+ .filter(Boolean);
+ if (texts.length > 0) return texts.join(' ').slice(0, 100);
+ }
+
+ const tag = el.tagName.toLowerCase();
+ if (tag === 'img' || (tag === 'input' && el.getAttribute('type') === 'image')) {
+ const alt = el.getAttribute('alt');
+ if (alt !== null) return alt.trim();
+ }
+
+ if (['input', 'select', 'textarea'].includes(tag)) {
+ const id = el.getAttribute('id');
+ if (id) {
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
+ if (label) return label.textContent?.trim() || '';
+ }
+ const parentLabel = el.closest('label');
+ if (parentLabel) {
+ const clone = parentLabel.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll('input,select,textarea').forEach(n => n.remove());
+ const lt = clone.textContent?.trim();
+ if (lt) return lt;
+ }
+ return '';
+ }
+
+ if (role && CONTAINER_ROLES.has(role)) return '';
+ return (el.textContent?.trim().replace(/\s+/g, ' ') || '').slice(0, 100);
+ }
+
+ function isLikelyGeneratedId(id: string): boolean {
+ return /^[0-9a-f]{8,}$/i.test(id) ||
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) ||
+ /(?:^|[-_:])\d{8,}(?:$|[-_:])/.test(id) ||
+ /^(?:ember|react-select|radix|headlessui|mui|mantine|chakra|auto|generated)[-_:]?\d+/i.test(id);
+ }
+
+ function uniqueCssSelector(selector: string): string | null {
+ return document.querySelectorAll(selector).length === 1 ? selector : null;
+ }
+
function getSelector(element: HTMLElement): string {
const tag = element.tagName.toLowerCase();
- const text = element.textContent?.trim().replace(/\s+/g, ' ');
- if (text && text.length > 0 && text.length <= 50) {
- const sameTagElements = document.querySelectorAll(tag);
- let matchCount = 0;
- sameTagElements.forEach(el => { if (el.textContent?.includes(text)) matchCount++; });
- if (matchCount === 1) return `${tag}*=${text}`;
+ for (const attr of ['data-testid', 'data-test', 'data-qa']) {
+ const value = element.getAttribute(attr);
+ if (!value) continue;
+ const selector = uniqueCssSelector(`[${attr}="${CSS.escape(value)}"]`);
+ if (selector) return selector;
}
- const ariaLabel = element.getAttribute('aria-label');
- if (ariaLabel && ariaLabel.length <= 80) return `aria/${ariaLabel}`;
-
- const testId = element.getAttribute('data-testid');
- if (testId) {
- const sel = `[data-testid="${CSS.escape(testId)}"]`;
- if (document.querySelectorAll(sel).length === 1) return sel;
+ const role = getRole(element);
+ const accessibleName = getSelectorAccessibleName(element, role);
+ 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}`;
}
- if (element.id) return `#${CSS.escape(element.id)}`;
+ if (element.id && !isLikelyGeneratedId(element.id)) return `#${CSS.escape(element.id)}`;
const nameAttr = element.getAttribute('name');
if (nameAttr) {
const sel = `${tag}[name="${CSS.escape(nameAttr)}"]`;
- if (document.querySelectorAll(sel).length === 1) return sel;
+ const selector = uniqueCssSelector(sel);
+ if (selector) return selector;
+ }
+
+ const placeholder = element.getAttribute('placeholder');
+ if (placeholder) {
+ const sel = `${tag}[placeholder="${CSS.escape(placeholder)}"]`;
+ const selector = uniqueCssSelector(sel);
+ if (selector) return selector;
}
if (element.className && typeof element.className === 'string') {
const classes = element.className.trim().split(/\s+/).filter(Boolean);
for (const cls of classes) {
const sel = `${tag}.${CSS.escape(cls)}`;
- if (document.querySelectorAll(sel).length === 1) return sel;
+ const selector = uniqueCssSelector(sel);
+ if (selector) return selector;
}
if (classes.length >= 2) {
const sel = `${tag}${classes.slice(0, 2).map(c => `.${CSS.escape(c)}`).join('')}`;
- if (document.querySelectorAll(sel).length === 1) return sel;
+ const selector = uniqueCssSelector(sel);
+ if (selector) return selector;
}
}
@@ -168,7 +229,7 @@ const accessibilityTreeScript = () => (function () {
const path: string[] = [];
while (current && current !== document.documentElement) {
let seg = current.tagName.toLowerCase();
- if (current.id) { path.unshift(`#${CSS.escape(current.id)}`); break; }
+ if (current.id && !isLikelyGeneratedId(current.id)) { path.unshift(`#${CSS.escape(current.id)}`); break; }
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(c => c.tagName === current!.tagName);
diff --git a/src/scripts/get-interactable-browser-elements.ts b/src/scripts/get-interactable-browser-elements.ts
index e311328..e92464c 100644
--- a/src/scripts/get-interactable-browser-elements.ts
+++ b/src/scripts/get-interactable-browser-elements.ts
@@ -108,60 +108,142 @@ const elementsScript = (includeBounds: boolean) => (function () {
return (el.textContent?.trim().replace(/\s+/g, ' ') || '').slice(0, 100);
}
+ function getRole(el: HTMLElement): string | null {
+ const explicit = el.getAttribute('role');
+ if (explicit) return explicit.split(' ')[0];
+
+ const tag = el.tagName.toLowerCase();
+ switch (tag) {
+ case 'button': return 'button';
+ case 'a': return el.hasAttribute('href') ? 'link' : null;
+ case 'input': {
+ const type = (el.getAttribute('type') || 'text').toLowerCase();
+ if (type === 'hidden') return null;
+ if (type === 'checkbox' || type === 'radio') return type;
+ if (type === 'range') return 'slider';
+ if (type === 'search') return 'searchbox';
+ if (type === 'number') return 'spinbutton';
+ if (['submit', 'reset', 'button', 'image'].includes(type)) return 'button';
+ return 'textbox';
+ }
+ case 'select': return 'combobox';
+ case 'textarea': return 'textbox';
+ }
+
+ if ((el as HTMLElement & { contentEditable: string }).contentEditable === 'true') return 'textbox';
+ return null;
+ }
+
+ function getSelectorAccessibleName(el: HTMLElement): string {
+ const ariaLabel = el.getAttribute('aria-label');
+ if (ariaLabel) return ariaLabel.trim();
+
+ const labelledBy = el.getAttribute('aria-labelledby');
+ if (labelledBy) {
+ const texts = labelledBy.split(/\s+/)
+ .map(id => document.getElementById(id)?.textContent?.trim() || '')
+ .filter(Boolean);
+ if (texts.length > 0) return texts.join(' ').slice(0, 100);
+ }
+
+ const tag = el.tagName.toLowerCase();
+ if (tag === 'img' || (tag === 'input' && el.getAttribute('type') === 'image')) {
+ const alt = el.getAttribute('alt');
+ if (alt !== null) return alt.trim();
+ }
+
+ if (['input', 'select', 'textarea'].includes(tag)) {
+ const id = el.getAttribute('id');
+ if (id) {
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
+ if (label) return label.textContent?.trim() || '';
+ }
+ const parentLabel = el.closest('label');
+ if (parentLabel) {
+ const clone = parentLabel.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll('input,select,textarea').forEach(n => n.remove());
+ const lt = clone.textContent?.trim();
+ if (lt) return lt;
+ }
+ return '';
+ }
+
+ return (el.textContent?.trim().replace(/\s+/g, ' ') || '').slice(0, 100);
+ }
+
+ function isLikelyGeneratedId(id: string): boolean {
+ return /^[0-9a-f]{8,}$/i.test(id) ||
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) ||
+ /(?:^|[-_:])\d{8,}(?:$|[-_:])/.test(id) ||
+ /^(?:ember|react-select|radix|headlessui|mui|mantine|chakra|auto|generated)[-_:]?\d+/i.test(id);
+ }
+
+ function uniqueCssSelector(selector: string): string | null {
+ return document.querySelectorAll(selector).length === 1 ? selector : null;
+ }
+
function getSelector(element: HTMLElement): string {
const tag = element.tagName.toLowerCase();
- // 1. tag*=Text — best per WebdriverIO docs
- const text = element.textContent?.trim().replace(/\s+/g, ' ');
- if (text && text.length > 0 && text.length <= 50) {
- const sameTagElements = document.querySelectorAll(tag);
- let matchCount = 0;
- sameTagElements.forEach(el => {
- if (el.textContent?.includes(text)) matchCount++;
- });
- if (matchCount === 1) return `${tag}*=${text}`;
+ // 1. Explicit test hooks
+ for (const attr of ['data-testid', 'data-test', 'data-qa']) {
+ const value = element.getAttribute(attr);
+ if (!value) continue;
+ const selector = uniqueCssSelector(`[${attr}="${CSS.escape(value)}"]`);
+ if (selector) return selector;
}
- // 2. aria/label
- const ariaLabel = element.getAttribute('aria-label');
- if (ariaLabel && ariaLabel.length <= 80) return `aria/${ariaLabel}`;
-
- // 3. data-testid
- const testId = element.getAttribute('data-testid');
- if (testId) {
- const sel = `[data-testid="${CSS.escape(testId)}"]`;
- if (document.querySelectorAll(sel).length === 1) return sel;
+ // 2. Accessible name. WebdriverIO's aria/ selector is name-based, so only
+ // emit it when the accessible-name candidate is unique among interactables.
+ const role = getRole(element);
+ const accessibleName = getSelectorAccessibleName(element);
+ if (role && accessibleName && accessibleName.length <= 80) {
+ let matchCount = 0;
+ document.querySelectorAll(interactableSelectors).forEach(el => {
+ const htmlEl = el as HTMLElement;
+ if (isVisible(htmlEl) && getRole(htmlEl) && getSelectorAccessibleName(htmlEl) === accessibleName) matchCount++;
+ });
+ if (matchCount === 1) return `aria/${accessibleName}`;
}
- // 4. #id
- if (element.id) return `#${CSS.escape(element.id)}`;
+ // 3. Stable #id
+ if (element.id && !isLikelyGeneratedId(element.id)) return `#${CSS.escape(element.id)}`;
- // 5. [name] — form elements
+ // 4. Stable input attributes
const nameAttr = element.getAttribute('name');
if (nameAttr) {
const sel = `${tag}[name="${CSS.escape(nameAttr)}"]`;
- if (document.querySelectorAll(sel).length === 1) return sel;
+ const selector = uniqueCssSelector(sel);
+ if (selector) return selector;
+ }
+
+ const placeholder = element.getAttribute('placeholder');
+ if (placeholder) {
+ const sel = `${tag}[placeholder="${CSS.escape(placeholder)}"]`;
+ const selector = uniqueCssSelector(sel);
+ if (selector) return selector;
}
- // 6. tag.class — try each class individually, then first-two combination
+ // 5. Scoped CSS fallback — try classes before structural paths
if (element.className && typeof element.className === 'string') {
const classes = element.className.trim().split(/\s+/).filter(Boolean);
for (const cls of classes) {
const sel = `${tag}.${CSS.escape(cls)}`;
- if (document.querySelectorAll(sel).length === 1) return sel;
+ const selector = uniqueCssSelector(sel);
+ if (selector) return selector;
}
if (classes.length >= 2) {
const sel = `${tag}${classes.slice(0, 2).map(c => `.${CSS.escape(c)}`).join('')}`;
- if (document.querySelectorAll(sel).length === 1) return sel;
+ const selector = uniqueCssSelector(sel);
+ if (selector) return selector;
}
}
- // 7. CSS path fallback
let current: HTMLElement | null = element;
const path: string[] = [];
while (current && current !== document.documentElement) {
let seg = current.tagName.toLowerCase();
- if (current.id) {
+ if (current.id && !isLikelyGeneratedId(current.id)) {
path.unshift(`#${CSS.escape(current.id)}`);
break;
}
diff --git a/tests/scripts/accessibility-tree.test.ts b/tests/scripts/accessibility-tree.test.ts
index 0481ac9..957c432 100644
--- a/tests/scripts/accessibility-tree.test.ts
+++ b/tests/scripts/accessibility-tree.test.ts
@@ -157,10 +157,26 @@ describe('state fields', () => {
});
describe('selector generation', () => {
- it('uses unique text content selector', async () => {
+ it('uses explicit test hooks before accessible names', async () => {
+ document.body.innerHTML = '';
+ const nodes = await getBrowserAccessibilityTree(mockBrowser);
+ expect(nodes[0].selector).toBe('[data-testid="add-button"]');
+ });
+
+ it('supports data-test and data-qa hooks', async () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const nodes = await getBrowserAccessibilityTree(mockBrowser);
+ expect(nodes[0].selector).toBe('[data-test="save"]');
+ expect(nodes[1].selector).toBe('[data-qa="cancel"]');
+ });
+
+ it('uses accessible name selectors', async () => {
document.body.innerHTML = '';
const nodes = await getBrowserAccessibilityTree(mockBrowser);
- expect(nodes[0].selector).toBe('button*=Add to Basket');
+ expect(nodes[0].selector).toBe('aria/Add to Basket');
});
it('uses aria label selector when text is not unique', async () => {
@@ -182,6 +198,33 @@ describe('selector generation', () => {
expect(nodes[0].selector).toBe('#submit-btn');
});
+ it('skips generated ids', async () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const nodes = await getBrowserAccessibilityTree(mockBrowser);
+ expect(nodes[0].selector).toBe('input[name="email"]');
+ });
+
+ it('does not use generated ids in CSS path fallback', async () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const nodes = await getBrowserAccessibilityTree(mockBrowser);
+ expect(nodes[0].selector).not.toContain('550e8400-e29b-41d4-a716-446655440000');
+ expect(nodes[0].selector).toBe('body > section > button');
+ });
+
+ it('uses placeholder after name', async () => {
+ document.body.innerHTML = '';
+ const nodes = await getBrowserAccessibilityTree(mockBrowser);
+ expect(nodes[0].selector).toBe('input[placeholder="Search\\ products"]');
+ });
+
it('uses unique class selector before CSS path', async () => {
document.body.innerHTML = `
@@ -204,6 +247,6 @@ describe('selector generation', () => {
const nodes = await getBrowserAccessibilityTree(mockBrowser);
// class "btn" is shared, text is shared — must fall back to structural path
expect(nodes[0].selector).not.toBe('button.btn');
- expect(nodes[0].selector).not.toBe('button*=Add to basket');
+ expect(nodes[0].selector).not.toBe('aria/Add to basket');
});
});
diff --git a/tests/scripts/interactable-elements.test.ts b/tests/scripts/interactable-elements.test.ts
index 5641211..4fcb632 100644
--- a/tests/scripts/interactable-elements.test.ts
+++ b/tests/scripts/interactable-elements.test.ts
@@ -119,10 +119,26 @@ describe('accessible name', () => {
});
describe('selector generation', () => {
- it('uses unique text content', async () => {
+ it('uses explicit test hooks before accessible names', async () => {
+ document.body.innerHTML = '';
+ const elements = await getInteractableBrowserElements(mockBrowser);
+ expect(elements[0].selector).toBe('[data-testid="submit-button"]');
+ });
+
+ it('supports data-test and data-qa hooks', async () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const elements = await getInteractableBrowserElements(mockBrowser);
+ expect(elements[0].selector).toBe('[data-test="save"]');
+ expect(elements[1].selector).toBe('[data-qa="cancel"]');
+ });
+
+ it('uses accessible name selectors', async () => {
document.body.innerHTML = '';
const elements = await getInteractableBrowserElements(mockBrowser);
- expect(elements[0].selector).toBe('button*=Submit form');
+ expect(elements[0].selector).toBe('aria/Submit form');
});
it('uses aria-label selector when text is not unique', async () => {
@@ -145,6 +161,29 @@ describe('selector generation', () => {
expect(elements[1].selector).toBe('#btn-b');
});
+ it('skips generated ids', async () => {
+ document.body.innerHTML = '';
+ const elements = await getInteractableBrowserElements(mockBrowser);
+ expect(elements[0].selector).toBe('input[name="email"]');
+ });
+
+ it('does not use generated ids in CSS path fallback', async () => {
+ document.body.innerHTML = `
+
+ `;
+ const elements = await getInteractableBrowserElements(mockBrowser);
+ expect(elements[0].selector).not.toContain('550e8400-e29b-41d4-a716-446655440000');
+ expect(elements[0].selector).toBe('body > section > button');
+ });
+
+ it('uses placeholder after name', async () => {
+ document.body.innerHTML = '';
+ const elements = await getInteractableBrowserElements(mockBrowser);
+ expect(elements[0].selector).toBe('input[placeholder="Search\\ products"]');
+ });
+
it('uses unique class selector before CSS path', async () => {
document.body.innerHTML = `
@@ -162,6 +201,6 @@ describe('selector generation', () => {
`;
const elements = await getInteractableBrowserElements(mockBrowser);
expect(elements[0].selector).not.toBe('button.btn');
- expect(elements[0].selector).not.toBe('button*=Add to basket');
+ expect(elements[0].selector).not.toBe('aria/Add to basket');
});
});