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'); }); });