Skip to content

Commit 43f9417

Browse files
Improve browser selector stability
1 parent df55786 commit 43f9417

4 files changed

Lines changed: 276 additions & 51 deletions

File tree

src/scripts/get-browser-accessibility-tree.ts

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -124,51 +124,112 @@ const accessibilityTreeScript = () => (function () {
124124
return (el.textContent?.trim().replace(/\s+/g, ' ') || '').slice(0, 100);
125125
}
126126

127+
function getSelectorAccessibleName(el: HTMLElement, role: string | null): string {
128+
const ariaLabel = el.getAttribute('aria-label');
129+
if (ariaLabel) return ariaLabel.trim();
130+
131+
const labelledBy = el.getAttribute('aria-labelledby');
132+
if (labelledBy) {
133+
const texts = labelledBy.split(/\s+/)
134+
.map(id => document.getElementById(id)?.textContent?.trim() || '')
135+
.filter(Boolean);
136+
if (texts.length > 0) return texts.join(' ').slice(0, 100);
137+
}
138+
139+
const tag = el.tagName.toLowerCase();
140+
if (tag === 'img' || (tag === 'input' && el.getAttribute('type') === 'image')) {
141+
const alt = el.getAttribute('alt');
142+
if (alt !== null) return alt.trim();
143+
}
144+
145+
if (['input', 'select', 'textarea'].includes(tag)) {
146+
const id = el.getAttribute('id');
147+
if (id) {
148+
const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
149+
if (label) return label.textContent?.trim() || '';
150+
}
151+
const parentLabel = el.closest('label');
152+
if (parentLabel) {
153+
const clone = parentLabel.cloneNode(true) as HTMLElement;
154+
clone.querySelectorAll('input,select,textarea').forEach(n => n.remove());
155+
const lt = clone.textContent?.trim();
156+
if (lt) return lt;
157+
}
158+
return '';
159+
}
160+
161+
if (role && CONTAINER_ROLES.has(role)) return '';
162+
return (el.textContent?.trim().replace(/\s+/g, ' ') || '').slice(0, 100);
163+
}
164+
165+
function isLikelyGeneratedId(id: string): boolean {
166+
return /^[0-9a-f]{8,}$/i.test(id) ||
167+
/^[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) ||
168+
/(?:^|[-_:])\d{8,}(?:$|[-_:])/.test(id) ||
169+
/^(?:ember|react-select|radix|headlessui|mui|mantine|chakra|auto|generated)[-_:]?\d+/i.test(id);
170+
}
171+
172+
function uniqueCssSelector(selector: string): string | null {
173+
return document.querySelectorAll(selector).length === 1 ? selector : null;
174+
}
175+
127176
function getSelector(element: HTMLElement): string {
128177
const tag = element.tagName.toLowerCase();
129178

130-
const text = element.textContent?.trim().replace(/\s+/g, ' ');
131-
if (text && text.length > 0 && text.length <= 50) {
132-
const sameTagElements = document.querySelectorAll(tag);
133-
let matchCount = 0;
134-
sameTagElements.forEach(el => { if (el.textContent?.includes(text)) matchCount++; });
135-
if (matchCount === 1) return `${tag}*=${text}`;
179+
for (const attr of ['data-testid', 'data-test', 'data-qa']) {
180+
const value = element.getAttribute(attr);
181+
if (!value) continue;
182+
const selector = uniqueCssSelector(`[${attr}="${CSS.escape(value)}"]`);
183+
if (selector) return selector;
136184
}
137185

138-
const ariaLabel = element.getAttribute('aria-label');
139-
if (ariaLabel && ariaLabel.length <= 80) return `aria/${ariaLabel}`;
140-
141-
const testId = element.getAttribute('data-testid');
142-
if (testId) {
143-
const sel = `[data-testid="${CSS.escape(testId)}"]`;
144-
if (document.querySelectorAll(sel).length === 1) return sel;
186+
const role = getRole(element);
187+
const accessibleName = getSelectorAccessibleName(element, role);
188+
if (role && accessibleName && accessibleName.length <= 80) {
189+
let matchCount = 0;
190+
document.querySelectorAll('*').forEach(el => {
191+
const htmlEl = el as HTMLElement;
192+
const candidateRole = getRole(htmlEl);
193+
if (isVisible(htmlEl) && candidateRole && getSelectorAccessibleName(htmlEl, candidateRole) === accessibleName) matchCount++;
194+
});
195+
if (matchCount === 1) return `aria/${accessibleName}`;
145196
}
146197

147-
if (element.id) return `#${CSS.escape(element.id)}`;
198+
if (element.id && !isLikelyGeneratedId(element.id)) return `#${CSS.escape(element.id)}`;
148199

149200
const nameAttr = element.getAttribute('name');
150201
if (nameAttr) {
151202
const sel = `${tag}[name="${CSS.escape(nameAttr)}"]`;
152-
if (document.querySelectorAll(sel).length === 1) return sel;
203+
const selector = uniqueCssSelector(sel);
204+
if (selector) return selector;
205+
}
206+
207+
const placeholder = element.getAttribute('placeholder');
208+
if (placeholder) {
209+
const sel = `${tag}[placeholder="${CSS.escape(placeholder)}"]`;
210+
const selector = uniqueCssSelector(sel);
211+
if (selector) return selector;
153212
}
154213

155214
if (element.className && typeof element.className === 'string') {
156215
const classes = element.className.trim().split(/\s+/).filter(Boolean);
157216
for (const cls of classes) {
158217
const sel = `${tag}.${CSS.escape(cls)}`;
159-
if (document.querySelectorAll(sel).length === 1) return sel;
218+
const selector = uniqueCssSelector(sel);
219+
if (selector) return selector;
160220
}
161221
if (classes.length >= 2) {
162222
const sel = `${tag}${classes.slice(0, 2).map(c => `.${CSS.escape(c)}`).join('')}`;
163-
if (document.querySelectorAll(sel).length === 1) return sel;
223+
const selector = uniqueCssSelector(sel);
224+
if (selector) return selector;
164225
}
165226
}
166227

167228
let current: HTMLElement | null = element;
168229
const path: string[] = [];
169230
while (current && current !== document.documentElement) {
170231
let seg = current.tagName.toLowerCase();
171-
if (current.id) { path.unshift(`#${CSS.escape(current.id)}`); break; }
232+
if (current.id && !isLikelyGeneratedId(current.id)) { path.unshift(`#${CSS.escape(current.id)}`); break; }
172233
const parent = current.parentElement;
173234
if (parent) {
174235
const siblings = Array.from(parent.children).filter(c => c.tagName === current!.tagName);

src/scripts/get-interactable-browser-elements.ts

Lines changed: 109 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -108,60 +108,142 @@ const elementsScript = (includeBounds: boolean) => (function () {
108108
return (el.textContent?.trim().replace(/\s+/g, ' ') || '').slice(0, 100);
109109
}
110110

111+
function getRole(el: HTMLElement): string | null {
112+
const explicit = el.getAttribute('role');
113+
if (explicit) return explicit.split(' ')[0];
114+
115+
const tag = el.tagName.toLowerCase();
116+
switch (tag) {
117+
case 'button': return 'button';
118+
case 'a': return el.hasAttribute('href') ? 'link' : null;
119+
case 'input': {
120+
const type = (el.getAttribute('type') || 'text').toLowerCase();
121+
if (type === 'hidden') return null;
122+
if (type === 'checkbox' || type === 'radio') return type;
123+
if (type === 'range') return 'slider';
124+
if (type === 'search') return 'searchbox';
125+
if (type === 'number') return 'spinbutton';
126+
if (['submit', 'reset', 'button', 'image'].includes(type)) return 'button';
127+
return 'textbox';
128+
}
129+
case 'select': return 'combobox';
130+
case 'textarea': return 'textbox';
131+
}
132+
133+
if ((el as HTMLElement & { contentEditable: string }).contentEditable === 'true') return 'textbox';
134+
return null;
135+
}
136+
137+
function getSelectorAccessibleName(el: HTMLElement): string {
138+
const ariaLabel = el.getAttribute('aria-label');
139+
if (ariaLabel) return ariaLabel.trim();
140+
141+
const labelledBy = el.getAttribute('aria-labelledby');
142+
if (labelledBy) {
143+
const texts = labelledBy.split(/\s+/)
144+
.map(id => document.getElementById(id)?.textContent?.trim() || '')
145+
.filter(Boolean);
146+
if (texts.length > 0) return texts.join(' ').slice(0, 100);
147+
}
148+
149+
const tag = el.tagName.toLowerCase();
150+
if (tag === 'img' || (tag === 'input' && el.getAttribute('type') === 'image')) {
151+
const alt = el.getAttribute('alt');
152+
if (alt !== null) return alt.trim();
153+
}
154+
155+
if (['input', 'select', 'textarea'].includes(tag)) {
156+
const id = el.getAttribute('id');
157+
if (id) {
158+
const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
159+
if (label) return label.textContent?.trim() || '';
160+
}
161+
const parentLabel = el.closest('label');
162+
if (parentLabel) {
163+
const clone = parentLabel.cloneNode(true) as HTMLElement;
164+
clone.querySelectorAll('input,select,textarea').forEach(n => n.remove());
165+
const lt = clone.textContent?.trim();
166+
if (lt) return lt;
167+
}
168+
return '';
169+
}
170+
171+
return (el.textContent?.trim().replace(/\s+/g, ' ') || '').slice(0, 100);
172+
}
173+
174+
function isLikelyGeneratedId(id: string): boolean {
175+
return /^[0-9a-f]{8,}$/i.test(id) ||
176+
/^[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) ||
177+
/(?:^|[-_:])\d{8,}(?:$|[-_:])/.test(id) ||
178+
/^(?:ember|react-select|radix|headlessui|mui|mantine|chakra|auto|generated)[-_:]?\d+/i.test(id);
179+
}
180+
181+
function uniqueCssSelector(selector: string): string | null {
182+
return document.querySelectorAll(selector).length === 1 ? selector : null;
183+
}
184+
111185
function getSelector(element: HTMLElement): string {
112186
const tag = element.tagName.toLowerCase();
113187

114-
// 1. tag*=Text — best per WebdriverIO docs
115-
const text = element.textContent?.trim().replace(/\s+/g, ' ');
116-
if (text && text.length > 0 && text.length <= 50) {
117-
const sameTagElements = document.querySelectorAll(tag);
118-
let matchCount = 0;
119-
sameTagElements.forEach(el => {
120-
if (el.textContent?.includes(text)) matchCount++;
121-
});
122-
if (matchCount === 1) return `${tag}*=${text}`;
188+
// 1. Explicit test hooks
189+
for (const attr of ['data-testid', 'data-test', 'data-qa']) {
190+
const value = element.getAttribute(attr);
191+
if (!value) continue;
192+
const selector = uniqueCssSelector(`[${attr}="${CSS.escape(value)}"]`);
193+
if (selector) return selector;
123194
}
124195

125-
// 2. aria/label
126-
const ariaLabel = element.getAttribute('aria-label');
127-
if (ariaLabel && ariaLabel.length <= 80) return `aria/${ariaLabel}`;
128-
129-
// 3. data-testid
130-
const testId = element.getAttribute('data-testid');
131-
if (testId) {
132-
const sel = `[data-testid="${CSS.escape(testId)}"]`;
133-
if (document.querySelectorAll(sel).length === 1) return sel;
196+
// 2. Accessible name. WebdriverIO's aria/ selector is name-based, so only
197+
// emit it when the accessible-name candidate is unique among interactables.
198+
const role = getRole(element);
199+
const accessibleName = getSelectorAccessibleName(element);
200+
if (role && accessibleName && accessibleName.length <= 80) {
201+
let matchCount = 0;
202+
document.querySelectorAll(interactableSelectors).forEach(el => {
203+
const htmlEl = el as HTMLElement;
204+
if (isVisible(htmlEl) && getRole(htmlEl) && getSelectorAccessibleName(htmlEl) === accessibleName) matchCount++;
205+
});
206+
if (matchCount === 1) return `aria/${accessibleName}`;
134207
}
135208

136-
// 4. #id
137-
if (element.id) return `#${CSS.escape(element.id)}`;
209+
// 3. Stable #id
210+
if (element.id && !isLikelyGeneratedId(element.id)) return `#${CSS.escape(element.id)}`;
138211

139-
// 5. [name] — form elements
212+
// 4. Stable input attributes
140213
const nameAttr = element.getAttribute('name');
141214
if (nameAttr) {
142215
const sel = `${tag}[name="${CSS.escape(nameAttr)}"]`;
143-
if (document.querySelectorAll(sel).length === 1) return sel;
216+
const selector = uniqueCssSelector(sel);
217+
if (selector) return selector;
218+
}
219+
220+
const placeholder = element.getAttribute('placeholder');
221+
if (placeholder) {
222+
const sel = `${tag}[placeholder="${CSS.escape(placeholder)}"]`;
223+
const selector = uniqueCssSelector(sel);
224+
if (selector) return selector;
144225
}
145226

146-
// 6. tag.class — try each class individually, then first-two combination
227+
// 5. Scoped CSS fallback — try classes before structural paths
147228
if (element.className && typeof element.className === 'string') {
148229
const classes = element.className.trim().split(/\s+/).filter(Boolean);
149230
for (const cls of classes) {
150231
const sel = `${tag}.${CSS.escape(cls)}`;
151-
if (document.querySelectorAll(sel).length === 1) return sel;
232+
const selector = uniqueCssSelector(sel);
233+
if (selector) return selector;
152234
}
153235
if (classes.length >= 2) {
154236
const sel = `${tag}${classes.slice(0, 2).map(c => `.${CSS.escape(c)}`).join('')}`;
155-
if (document.querySelectorAll(sel).length === 1) return sel;
237+
const selector = uniqueCssSelector(sel);
238+
if (selector) return selector;
156239
}
157240
}
158241

159-
// 7. CSS path fallback
160242
let current: HTMLElement | null = element;
161243
const path: string[] = [];
162244
while (current && current !== document.documentElement) {
163245
let seg = current.tagName.toLowerCase();
164-
if (current.id) {
246+
if (current.id && !isLikelyGeneratedId(current.id)) {
165247
path.unshift(`#${CSS.escape(current.id)}`);
166248
break;
167249
}

tests/scripts/accessibility-tree.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,26 @@ describe('state fields', () => {
157157
});
158158

159159
describe('selector generation', () => {
160-
it('uses unique text content selector', async () => {
160+
it('uses explicit test hooks before accessible names', async () => {
161+
document.body.innerHTML = '<button data-testid="add-button">Add to Basket</button>';
162+
const nodes = await getBrowserAccessibilityTree(mockBrowser);
163+
expect(nodes[0].selector).toBe('[data-testid="add-button"]');
164+
});
165+
166+
it('supports data-test and data-qa hooks', async () => {
167+
document.body.innerHTML = `
168+
<button data-test="save">Save</button>
169+
<button data-qa="cancel">Cancel</button>
170+
`;
171+
const nodes = await getBrowserAccessibilityTree(mockBrowser);
172+
expect(nodes[0].selector).toBe('[data-test="save"]');
173+
expect(nodes[1].selector).toBe('[data-qa="cancel"]');
174+
});
175+
176+
it('uses accessible name selectors', async () => {
161177
document.body.innerHTML = '<button>Add to Basket</button>';
162178
const nodes = await getBrowserAccessibilityTree(mockBrowser);
163-
expect(nodes[0].selector).toBe('button*=Add to Basket');
179+
expect(nodes[0].selector).toBe('aria/Add to Basket');
164180
});
165181

166182
it('uses aria label selector when text is not unique', async () => {
@@ -182,6 +198,33 @@ describe('selector generation', () => {
182198
expect(nodes[0].selector).toBe('#submit-btn');
183199
});
184200

201+
it('skips generated ids', async () => {
202+
document.body.innerHTML = `
203+
<input id="550e8400-e29b-41d4-a716-446655440000" name="email" aria-label="Email">
204+
<input name="confirm-email" aria-label="Email">
205+
`;
206+
const nodes = await getBrowserAccessibilityTree(mockBrowser);
207+
expect(nodes[0].selector).toBe('input[name="email"]');
208+
});
209+
210+
it('does not use generated ids in CSS path fallback', async () => {
211+
document.body.innerHTML = `
212+
<section id="550e8400-e29b-41d4-a716-446655440000">
213+
<button aria-label="Save"></button>
214+
</section>
215+
<button aria-label="Save"></button>
216+
`;
217+
const nodes = await getBrowserAccessibilityTree(mockBrowser);
218+
expect(nodes[0].selector).not.toContain('550e8400-e29b-41d4-a716-446655440000');
219+
expect(nodes[0].selector).toBe('body > section > button');
220+
});
221+
222+
it('uses placeholder after name', async () => {
223+
document.body.innerHTML = '<input placeholder="Search products">';
224+
const nodes = await getBrowserAccessibilityTree(mockBrowser);
225+
expect(nodes[0].selector).toBe('input[placeholder="Search\\ products"]');
226+
});
227+
185228
it('uses unique class selector before CSS path', async () => {
186229
document.body.innerHTML = `
187230
<button class="product_123">Add to basket</button>
@@ -204,6 +247,6 @@ describe('selector generation', () => {
204247
const nodes = await getBrowserAccessibilityTree(mockBrowser);
205248
// class "btn" is shared, text is shared — must fall back to structural path
206249
expect(nodes[0].selector).not.toBe('button.btn');
207-
expect(nodes[0].selector).not.toBe('button*=Add to basket');
250+
expect(nodes[0].selector).not.toBe('aria/Add to basket');
208251
});
209252
});

0 commit comments

Comments
 (0)