99import { createRequire } from "node:module" ;
1010import { mkdirSync , readFileSync , writeFileSync } from "node:fs" ;
1111import { dirname , resolve } from "node:path" ;
12- import type { Page , Locator } from "@playwright/test" ;
12+ import type { Page , Locator , Frame } from "@playwright/test" ;
1313import type { AuditViolation } from "./audit" ;
1414
1515const require = createRequire ( import . meta. url ) ;
@@ -278,16 +278,16 @@ function loadAsLocator(): (lang: string, selector: string) => string {
278278let _injectedSource : string | undefined ;
279279let _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
379498async 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