Skip to content
This repository was archived by the owner on Apr 16, 2026. It is now read-only.

Commit a2fb48b

Browse files
committed
Stabilize iframe selectors via frame-tree navigation
For violations inside iframes, navigate to the inner frame and run generateSelectorSimple within that context instead of falling back to positional tag-path selectors.
1 parent a2d930f commit a2fb48b

3 files changed

Lines changed: 167 additions & 49 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@accesslint/playwright",
3-
"version": "0.3.1",
3+
"version": "0.3.2",
44
"description": "Accessibility assertions for Playwright — toBeAccessible() matcher powered by AccessLint",
55
"license": "MIT",
66
"publishConfig": {

src/snapshot.ts

Lines changed: 164 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { createRequire } from "node:module";
1010
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1111
import { dirname, resolve } from "node:path";
12-
import type { Page, Locator } from "@playwright/test";
12+
import type { Page, Locator, Frame } from "@playwright/test";
1313
import type { AuditViolation } from "./audit";
1414

1515
const require = createRequire(import.meta.url);
@@ -278,16 +278,16 @@ function loadAsLocator(): (lang: string, selector: string) => string {
278278
let _injectedSource: string | undefined;
279279
let _asLocator: ((lang: string, selector: string) => string) | undefined;
280280

281-
/** Inject Playwright's InjectedScript into the page (once per page). */
282-
async function ensureInjectedScript(page: Page): Promise<void> {
283-
const done = await page.evaluate(
281+
/** Inject Playwright's InjectedScript (once per context). Works with both Page and Frame. */
282+
async function ensureInjectedScript(target: Page | Frame): Promise<void> {
283+
const done = await target.evaluate(
284284
() => typeof (window as any).__accesslintInjected !== "undefined",
285285
);
286286
if (done) return;
287287

288288
if (!_injectedSource) _injectedSource = getInjectedScriptSource();
289289

290-
await page.addScriptTag({
290+
await target.addScriptTag({
291291
content: `(() => {
292292
const module = {};
293293
${_injectedSource}
@@ -305,41 +305,25 @@ async function ensureInjectedScript(page: Page): Promise<void> {
305305
}
306306

307307
/**
308-
* Convert audit violations into snapshot violations with **stable selectors**.
309-
*
310-
* Uses Playwright's `InjectedScript.generateSelectorSimple()` — the same
311-
* engine behind codegen — to produce selectors that favor ARIA roles,
312-
* accessible names, text content, and test IDs over CSS classes and random
313-
* IDs. The internal selector format is then converted to the human-readable
314-
* Locator API form via `asLocator()`.
315-
*
316-
* Example: `main > img` → `getByRole('img')`
317-
*
318-
* Falls back to a tag-path selector if injection fails.
308+
* Generate stable selectors for CSS selectors within a Page or Frame.
309+
* Injects InjectedScript, runs generateSelectorSimple, then converts
310+
* via asLocator. Falls back to tag-path selectors on failure.
319311
*/
320-
export async function toStableViolations(
321-
target: Page | Locator,
322-
violations: AuditViolation[],
323-
): Promise<SnapshotViolation[]> {
324-
if (violations.length === 0) return [];
325-
326-
const page = getPage(target);
327-
const selectors = violations.map((v) => v.selector);
312+
async function stabilizeSelectors(
313+
target: Page | Frame,
314+
cssSelectors: string[],
315+
): Promise<string[]> {
316+
if (cssSelectors.length === 0) return [];
328317

329-
let stableSelectors: string[];
330318
try {
331-
await ensureInjectedScript(page);
319+
await ensureInjectedScript(target);
332320
if (!_asLocator) _asLocator = loadAsLocator();
333321
const asLocator = _asLocator;
334322

335-
const internalSelectors: string[] = await page.evaluate(
336-
(cssSelectors: string[]) => {
323+
const internalSelectors: string[] = await target.evaluate(
324+
(selectors: string[]) => {
337325
const injected = (window as any).__accesslintInjected;
338-
339-
return cssSelectors.map((selector) => {
340-
// Shadow-DOM / iframe boundary selectors — keep as-is
341-
if (selector.includes(">>>")) return selector;
342-
326+
return selectors.map((selector) => {
343327
try {
344328
const el = selector
345329
? document.querySelector(selector)
@@ -353,34 +337,169 @@ export async function toStableViolations(
353337
}
354338
});
355339
},
356-
selectors,
340+
cssSelectors,
357341
);
358342

359-
// Convert internal:role=button[name="X"i] → getByRole('button', { name: 'X' })
360-
stableSelectors = internalSelectors.map((sel) => {
361-
if (sel.includes(">>>")) return sel;
343+
return internalSelectors.map((sel) => {
362344
try {
363345
return asLocator("javascript", sel);
364346
} catch {
365347
return sel;
366348
}
367349
});
368350
} catch {
369-
// Fallback: tag-path selectors if InjectedScript fails
370-
stableSelectors = await tagPathFallback(page, selectors);
351+
return tagPathFallback(target, cssSelectors);
352+
}
353+
}
354+
355+
// ---------------------------------------------------------------------------
356+
// Frame navigation for >>>iframe> selectors
357+
// ---------------------------------------------------------------------------
358+
359+
const IFRAME_BOUNDARY = " >>>iframe> ";
360+
361+
/**
362+
* Navigate the frame tree to find the innermost frame described by a
363+
* `>>>iframe>` prefix like `#iframe1 >>>iframe> #iframe2 >>>iframe>`.
364+
*/
365+
async function findFrameByPrefix(
366+
page: Page,
367+
prefix: string,
368+
): Promise<Frame | null> {
369+
const segments = prefix
370+
.split(" >>>iframe>")
371+
.map((s) => s.trim())
372+
.filter(Boolean);
373+
374+
let currentFrame: Frame = page.mainFrame();
375+
for (const iframeSelector of segments) {
376+
let found = false;
377+
for (const child of currentFrame.childFrames()) {
378+
try {
379+
const frameEl = await child.frameElement();
380+
const matches = await currentFrame.evaluate(
381+
([el, sel]: [Element, string]) => {
382+
try {
383+
return el.matches(sel);
384+
} catch {
385+
return false;
386+
}
387+
},
388+
[frameEl, iframeSelector] as [any, string],
389+
);
390+
if (matches) {
391+
currentFrame = child;
392+
found = true;
393+
break;
394+
}
395+
} catch {
396+
continue;
397+
}
398+
}
399+
if (!found) return null;
400+
}
401+
return currentFrame;
402+
}
403+
404+
/**
405+
* Convert audit violations into snapshot violations with **stable selectors**.
406+
*
407+
* Uses Playwright's `InjectedScript.generateSelectorSimple()` — the same
408+
* engine behind codegen — to produce selectors that favor ARIA roles,
409+
* accessible names, text content, and test IDs over CSS classes and random
410+
* IDs. The internal selector format is then converted to the human-readable
411+
* Locator API form via `asLocator()`.
412+
*
413+
* For iframe violations (`>>>iframe>`), navigates to the inner frame and
414+
* stabilizes the element selector within that frame's context.
415+
*
416+
* Falls back to a tag-path selector if injection fails.
417+
*/
418+
export async function toStableViolations(
419+
target: Page | Locator,
420+
violations: AuditViolation[],
421+
): Promise<SnapshotViolation[]> {
422+
if (violations.length === 0) return [];
423+
424+
const page = getPage(target);
425+
const result: SnapshotViolation[] = new Array(violations.length);
426+
427+
// Categorize violations by context
428+
const mainIndices: number[] = [];
429+
const mainSelectors: string[] = [];
430+
const iframeGroups = new Map<
431+
string,
432+
{ indices: number[]; suffixes: string[] }
433+
>();
434+
435+
for (let i = 0; i < violations.length; i++) {
436+
const selector = violations[i].selector;
437+
const lastIframe = selector.lastIndexOf(IFRAME_BOUNDARY);
438+
439+
if (lastIframe >= 0) {
440+
const prefix = selector.substring(
441+
0,
442+
lastIframe + IFRAME_BOUNDARY.length - 1, // trim trailing space
443+
);
444+
const suffix = selector.substring(lastIframe + IFRAME_BOUNDARY.length);
445+
446+
if (suffix.includes(">>>")) {
447+
// Shadow DOM within iframe — keep as-is
448+
result[i] = { ruleId: violations[i].ruleId, selector };
449+
} else {
450+
const group = iframeGroups.get(prefix) ?? {
451+
indices: [],
452+
suffixes: [],
453+
};
454+
group.indices.push(i);
455+
group.suffixes.push(suffix);
456+
iframeGroups.set(prefix, group);
457+
}
458+
} else if (selector.includes(">>>")) {
459+
// Pure shadow DOM — keep as-is
460+
result[i] = { ruleId: violations[i].ruleId, selector };
461+
} else {
462+
mainIndices.push(i);
463+
mainSelectors.push(selector);
464+
}
371465
}
372466

373-
return violations.map((v, i) => ({
374-
ruleId: v.ruleId,
375-
selector: stableSelectors[i],
376-
}));
467+
// Stabilize main-frame selectors
468+
const stableMain = await stabilizeSelectors(page, mainSelectors);
469+
for (let i = 0; i < mainIndices.length; i++) {
470+
result[mainIndices[i]] = {
471+
ruleId: violations[mainIndices[i]].ruleId,
472+
selector: stableMain[i],
473+
};
474+
}
475+
476+
// Stabilize iframe selectors within their frame context
477+
for (const [prefix, { indices, suffixes }] of iframeGroups) {
478+
let stableSuffixes: string[];
479+
try {
480+
const frame = await findFrameByPrefix(page, prefix);
481+
stableSuffixes = frame
482+
? await stabilizeSelectors(frame, suffixes)
483+
: suffixes;
484+
} catch {
485+
stableSuffixes = suffixes;
486+
}
487+
for (let i = 0; i < indices.length; i++) {
488+
result[indices[i]] = {
489+
ruleId: violations[indices[i]].ruleId,
490+
selector: prefix + " " + stableSuffixes[i],
491+
};
492+
}
493+
}
494+
495+
return result;
377496
}
378497

379498
async function tagPathFallback(
380-
page: Page,
499+
target: Page | Frame,
381500
selectors: string[],
382501
): Promise<string[]> {
383-
return page.evaluate((cssSelectors: string[]) => {
502+
return target.evaluate((cssSelectors: string[]) => {
384503
function tagPath(el: Element): string {
385504
const parts: string[] = [];
386505
let current: Element | null = el;
@@ -402,7 +521,6 @@ async function tagPathFallback(
402521
}
403522

404523
return cssSelectors.map((selector) => {
405-
if (selector.includes(">>>")) return selector;
406524
try {
407525
const el = selector
408526
? document.querySelector(selector)

0 commit comments

Comments
 (0)