diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 142fd7517edc6..3215c9d070fb6 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -392,8 +392,8 @@ export function matchesExpectAriaTemplate(rootElement: Element, template: aria.A return { matches, received: { - raw: renderAriaTree(snapshot, { mode: 'default' }), - regex: renderAriaTree(snapshot, { mode: 'codegen' }), + raw: renderAriaTree(snapshot, { mode: 'default' }).text, + regex: renderAriaTree(snapshot, { mode: 'codegen' }).text, } }; } @@ -568,9 +568,10 @@ function indent(depth: number): string { return ' '.repeat(depth); } -export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previousSnapshot?: AriaSnapshot): string { +export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previousSnapshot?: AriaSnapshot): { text: string, iframeDepths: Record } { const options = toInternalOptions(publicOptions); const lines: string[] = []; + const iframeDepths: Record = {}; const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true; const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str; @@ -634,6 +635,9 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr if (publicOptions.depth && depth > publicOptions.depth) return; + if (ariaNode.role === 'iframe' && ariaNode.ref) + iframeDepths[ariaNode.ref] = depth; + // Replace the whole subtree with a single reference when possible. if (statusMap.get(ariaNode) === 'same' && ariaNode.ref) { lines.push(indent(depth) + `- ref=${ariaNode.ref} [unchanged]`); @@ -679,7 +683,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr else visit(nodeToRender, 0, !!options.renderCursorPointer); } - return lines.join('\n'); + return { text: lines.join('\n'), iframeDepths }; } function convertToBestGuessRegex(text: string): string { diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 416ef12ea697b..45dc8a46f6685 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -306,25 +306,25 @@ export class InjectedScript { return this.incrementalAriaSnapshot(node, options).full; } - incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string, depth?: number }): { full: string, incremental?: string, iframeRefs: string[] } { + incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string, depth?: number }): { full: string, incremental?: string, iframeRefs: string[], iframeDepths: Record } { if (node.nodeType !== Node.ELEMENT_NODE) throw this.createStacklessError('Can only capture aria snapshot of Element nodes.'); const ariaSnapshot = generateAriaTree(node as Element, options); - const full = renderAriaTree(ariaSnapshot, options); + const rendered = renderAriaTree(ariaSnapshot, options); let incremental: string | undefined; if (options.track) { const previousSnapshot = this._lastAriaSnapshotForTrack.get(options.track); if (previousSnapshot) - incremental = renderAriaTree(ariaSnapshot, options, previousSnapshot); + incremental = renderAriaTree(ariaSnapshot, options, previousSnapshot).text; this._lastAriaSnapshotForTrack.set(options.track, ariaSnapshot); } this._lastAriaSnapshotForQuery = ariaSnapshot; - return { full, incremental, iframeRefs: ariaSnapshot.iframeRefs }; + return { full: rendered.text, incremental, iframeRefs: ariaSnapshot.iframeRefs, iframeDepths: rendered.iframeDepths }; } ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map } { const tree = generateAriaTree(this.document.body, { mode: 'ai' }); - const ariaSnapshot = renderAriaTree(tree, { mode: 'ai' }); + const { text: ariaSnapshot } = renderAriaTree(tree, { mode: 'ai' }); return { ariaSnapshot, refs: tree.refs }; } diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 83d2ec449cd48..5aca23e54020c 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -313,7 +313,7 @@ export class Locator implements api.Locator { } async ariaSnapshot(options: TimeoutOptions & { mode?: 'ai' | 'default', depth?: number } = {}): Promise { - const result = await this._frame._page!._channel.ariaSnapshot({ timeout: this._frame._timeout(options), mode: options.mode, selector: this._selector, depth: options.depth }); + const result = await this._frame._channel.ariaSnapshot({ timeout: this._frame._timeout(options), mode: options.mode, selector: this._selector, depth: options.depth }); return result.snapshot; } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 7c56fd27b6075..86991356d3cbb 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -852,7 +852,7 @@ export class Page extends ChannelOwner implements api.Page } async ariaSnapshot(options: TimeoutOptions & { mode?: 'ai' | 'default', depth?: number, _track?: string } = {}): Promise { - const result = await this._channel.ariaSnapshot({ timeout: this._timeoutSettings.timeout(options), track: options._track, mode: options.mode, depth: options.depth }); + const result = await this.mainFrame()._channel.ariaSnapshot({ timeout: this._timeoutSettings.timeout(options), track: options._track, mode: options.mode, depth: options.depth }); return result.snapshot; } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 0303364ac2c0d..0cd953c5af032 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1494,16 +1494,6 @@ scheme.PageRequestsParams = tOptional(tObject({})); scheme.PageRequestsResult = tObject({ requests: tArray(tChannel(['Request'])), }); -scheme.PageAriaSnapshotParams = tObject({ - mode: tOptional(tEnum(['ai', 'default'])), - track: tOptional(tString), - selector: tOptional(tString), - depth: tOptional(tInt), - timeout: tFloat, -}); -scheme.PageAriaSnapshotResult = tObject({ - snapshot: tString, -}); scheme.PageStartJSCoverageParams = tObject({ resetOnNavigation: tOptional(tBoolean), reportAnonymousScripts: tOptional(tBoolean), @@ -1630,6 +1620,16 @@ scheme.FrameAddStyleTagParams = tObject({ scheme.FrameAddStyleTagResult = tObject({ element: tChannel(['ElementHandle']), }); +scheme.FrameAriaSnapshotParams = tObject({ + mode: tOptional(tEnum(['ai', 'default'])), + track: tOptional(tString), + selector: tOptional(tString), + depth: tOptional(tInt), + timeout: tFloat, +}); +scheme.FrameAriaSnapshotResult = tObject({ + snapshot: tString, +}); scheme.FrameBlurParams = tObject({ selector: tString, strict: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index d0b93af295d6d..2e6a1d77f3bc0 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -136,6 +136,10 @@ export class FrameDispatcher extends Dispatcher { + return await this._frame.ariaSnapshot(progress, params); + } + async click(params: channels.FrameClickParams, progress: Progress): Promise { progress.metadata.potentiallyClosesScope = true; return await this._frame.click(progress, params.selector, params); diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index a0e8651366ddc..d824892cce7dc 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -344,10 +344,6 @@ export class PageDispatcher extends Dispatcher RequestDispatcher.from(this.parentScope(), request)) }; } - async ariaSnapshot(params: channels.PageAriaSnapshotParams, progress: Progress): Promise { - return await this._page.ariaSnapshot(progress, params); - } - async bringToFront(params: channels.PageBringToFrontParams, progress: Progress): Promise { await progress.race(this._page.bringToFront()); } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index ddf782b282843..344463e328d3e 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -24,7 +24,7 @@ import { helper } from './helper'; import { SdkObject } from './instrumentation'; import * as js from './javascript'; import * as network from './network'; -import { Page } from './page'; +import { Page, ariaSnapshotForFrame } from './page'; import { isAbortError, ProgressController } from './progress'; import * as types from './types'; import { LongStandingScope, asLocator, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, renderTitleForCall } from '../utils'; @@ -36,6 +36,7 @@ import { ManualPromise } from '../utils/isomorphic/manualPromise'; import { compressCallLog } from './callLog'; import type { ConsoleMessage } from './console'; +import type { SelectorInfo } from './frameSelectors'; import type { ElementStateWithoutStable, FrameExpectParams, InjectedScript } from '@injected/injectedScript'; import type { Progress } from './progress'; import type { ScreenshotOptions } from './screenshotter'; @@ -1690,6 +1691,27 @@ export class Frame extends SdkObject { }, { source, arg }); } + async ariaSnapshot(progress: Progress, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean, selector?: string, depth?: number } = {}): Promise<{ snapshot: string }> { + if (options.selector && options.track) + throw new Error('Cannot specify both selector and track options'); + + let targetFrame: Frame; + let info: SelectorInfo | undefined; + if (options.selector) { + const resolved = await this.selectors.resolveInjectedForSelector(options.selector, { strict: true }); + if (!resolved) + throw new Error(`Selector "${options.selector}" did not resolve to any element`); + targetFrame = resolved.frame; + info = resolved.info; + } else { + targetFrame = this; + } + + const result = await ariaSnapshotForFrame(progress, targetFrame, { ...options, info }); + const snapshot = options.track && result.incremental ? result.incremental.join('\n') : result.full.join('\n'); + return { snapshot }; + } + private _asLocator(selector: string) { return asLocator(this._page.browserContext._browser.sdkLanguage(), selector); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 8e07c11104a85..f1670e2b7c7bc 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -886,27 +886,6 @@ export class Page extends SdkObject { await Promise.all(this.frames().map(frame => frame.hideHighlight().catch(() => {}))); } - async ariaSnapshot(progress: Progress, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean, selector?: string, depth?: number } = {}): Promise<{ snapshot: string }> { - if (options.selector && options.track) - throw new Error('Cannot specify both selector and track options'); - - let frame: frames.Frame; - let info: SelectorInfo | undefined; - if (options.selector) { - const resolved = await this.mainFrame().selectors.resolveInjectedForSelector(options.selector, { strict: true }); - if (!resolved) - throw new Error(`Selector "${options.selector}" did not resolve to any element`); - frame = resolved.frame; - info = resolved.info; - } else { - frame = this.mainFrame(); - } - - const result = await ariaSnapshotForFrame(progress, frame, { ...options, info }); - const snapshot = options.track && result.incremental ? result.incremental.join('\n') : result.full.join('\n'); - return { snapshot }; - } - async setDockTile(image: Buffer) { await this.delegate.setDockTile(image); } @@ -1048,7 +1027,7 @@ export class InitScript extends DisposableObject { } } -async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean, info?: SelectorInfo, depth?: number } = {}): Promise<{ full: string[], incremental?: string[] }> { +export async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean, info?: SelectorInfo, depth?: number } = {}): Promise<{ full: string[], incremental?: string[] }> { // Only await the topmost navigations, inner frames will be empty when racing. const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => { try { @@ -1085,7 +1064,13 @@ async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, opt } }); - const childSnapshotPromises = snapshot.iframeRefs.map(ref => ariaSnapshotFrameRef(progress, frame, ref, options)); + // Only fetch child snapshots for iframes that were actually rendered (not filtered by depth). + const renderedIframeRefs = snapshot.iframeRefs.filter(ref => ref in snapshot.iframeDepths); + const childSnapshotPromises = renderedIframeRefs.map(ref => { + const iframeDepth = snapshot.iframeDepths[ref]; + const childDepth = options.depth ? options.depth - iframeDepth - 1 : undefined; + return ariaSnapshotFrameRef(progress, frame, ref, { ...options, depth: childDepth }); + }); const childSnapshots = await Promise.all(childSnapshotPromises); const full = []; @@ -1093,12 +1078,12 @@ async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, opt if (snapshot.incremental !== undefined) { incremental = snapshot.incremental.split('\n'); - for (let i = 0; i < snapshot.iframeRefs.length; i++) { + for (let i = 0; i < renderedIframeRefs.length; i++) { const childSnapshot = childSnapshots[i]; if (childSnapshot.incremental) incremental.push(...childSnapshot.incremental); else if (childSnapshot.full.length) - incremental.push('- iframe [ref=' + snapshot.iframeRefs[i] + ']:', ...childSnapshot.full.map(l => ' ' + l)); + incremental.push('- iframe [ref=' + renderedIframeRefs[i] + ']:', ...childSnapshot.full.map(l => ' ' + l)); } } @@ -1111,7 +1096,7 @@ async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, opt const leadingSpace = match[1]; const ref = match[2]; - const childSnapshot = childSnapshots[snapshot.iframeRefs.indexOf(ref)] ?? { full: [] }; + const childSnapshot = childSnapshots[renderedIframeRefs.indexOf(ref)] ?? { full: [] }; full.push(childSnapshot.full.length ? line + ':' : line); full.push(...childSnapshot.full.map(l => leadingSpace + ' ' + l)); } @@ -1119,7 +1104,7 @@ async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, opt return { full, incremental }; } -async function ariaSnapshotFrameRef(progress: Progress, parentFrame: frames.Frame, frameRef: string, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean }): Promise<{ full: string[], incremental?: string[] }> { +async function ariaSnapshotFrameRef(progress: Progress, parentFrame: frames.Frame, frameRef: string, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean, depth?: number }): Promise<{ full: string[], incremental?: string[] }> { const frameSelector = `aria-ref=${frameRef} >> internal:control=enter-frame`; const frameBodySelector = `${frameSelector} >> body`; const child = await progress.race(parentFrame.selectors.resolveFrameForSelector(frameBodySelector, { strict: true })); diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index a09db6bd60a0a..49f3c0c63f7e5 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -139,7 +139,6 @@ export const methodMetainfo = new Map; pdf(params: PagePdfParams, progress?: Progress): Promise; requests(params?: PageRequestsParams, progress?: Progress): Promise; - ariaSnapshot(params: PageAriaSnapshotParams, progress?: Progress): Promise; startJSCoverage(params: PageStartJSCoverageParams, progress?: Progress): Promise; stopJSCoverage(params?: PageStopJSCoverageParams, progress?: Progress): Promise; startCSSCoverage(params: PageStartCSSCoverageParams, progress?: Progress): Promise; @@ -2610,22 +2609,6 @@ export type PageRequestsOptions = {}; export type PageRequestsResult = { requests: RequestChannel[], }; -export type PageAriaSnapshotParams = { - mode?: 'ai' | 'default', - track?: string, - selector?: string, - depth?: number, - timeout: number, -}; -export type PageAriaSnapshotOptions = { - mode?: 'ai' | 'default', - track?: string, - selector?: string, - depth?: number, -}; -export type PageAriaSnapshotResult = { - snapshot: string, -}; export type PageStartJSCoverageParams = { resetOnNavigation?: boolean, reportAnonymousScripts?: boolean, @@ -2767,6 +2750,7 @@ export interface FrameChannel extends FrameEventTarget, Channel { evalOnSelectorAll(params: FrameEvalOnSelectorAllParams, progress?: Progress): Promise; addScriptTag(params: FrameAddScriptTagParams, progress?: Progress): Promise; addStyleTag(params: FrameAddStyleTagParams, progress?: Progress): Promise; + ariaSnapshot(params: FrameAriaSnapshotParams, progress?: Progress): Promise; blur(params: FrameBlurParams, progress?: Progress): Promise; check(params: FrameCheckParams, progress?: Progress): Promise; click(params: FrameClickParams, progress?: Progress): Promise; @@ -2872,6 +2856,22 @@ export type FrameAddStyleTagOptions = { export type FrameAddStyleTagResult = { element: ElementHandleChannel, }; +export type FrameAriaSnapshotParams = { + mode?: 'ai' | 'default', + track?: string, + selector?: string, + depth?: number, + timeout: number, +}; +export type FrameAriaSnapshotOptions = { + mode?: 'ai' | 'default', + track?: string, + selector?: string, + depth?: number, +}; +export type FrameAriaSnapshotResult = { + snapshot: string, +}; export type FrameBlurParams = { selector: string, strict?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 063ab3e0d9c87..a5d62aef2ef79 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2008,23 +2008,6 @@ Page: type: array items: Request - ariaSnapshot: - title: Aria snapshot - group: getter - parameters: - mode: - type: enum? - literals: - - ai - - default - # When track is present, an incremental snapshot is returned when possible. - track: string? - selector: string? - depth: int? - timeout: float - returns: - snapshot: string - startJSCoverage: title: Start JS coverage group: configuration @@ -2274,6 +2257,23 @@ Frame: snapshot: true pausesBeforeAction: true + ariaSnapshot: + title: Aria snapshot + group: getter + parameters: + mode: + type: enum? + literals: + - ai + - default + # When track is present, an incremental snapshot is returned when possible. + track: string? + selector: string? + depth: int? + timeout: float + returns: + snapshot: string + blur: title: Blur parameters: diff --git a/tests/page/page-aria-snapshot-ai.spec.ts b/tests/page/page-aria-snapshot-ai.spec.ts index 49d094ec8e59e..927a3dad967a5 100644 --- a/tests/page/page-aria-snapshot-ai.spec.ts +++ b/tests/page/page-aria-snapshot-ai.spec.ts @@ -67,6 +67,38 @@ it('should list iframes', async ({ page }) => { expect(frameSnapshot).toEqual('- heading "World" [level=1]'); }); +it('should snapshot a locator inside an iframe', async ({ page }) => { + await page.setContent(` +

Main Page

+ + `); + + const list = page.frames()[1].locator('ul'); + const snapshot = await list.ariaSnapshot({ mode: 'ai' }); + expect(snapshot).toContainYaml(` + - list [ref=f1e1]: + - listitem [ref=f1e2]: Item 1 + - listitem [ref=f1e3]: Item 2 + `); +}); + +it('should limit depth across iframe boundary', async ({ page }) => { + await page.setContent(` + + `); + + const snapshot = await snapshotForAI(page, { depth: 3 }); + expect(snapshot).toContainYaml(` + - navigation [ref=e2]: + - iframe [ref=e3]: + - list [ref=f1e2]: + - listitem [ref=f1e3] + `); + expect(snapshot).not.toContain('button'); +}); + it('should stitch all frame snapshots', async ({ page, server }) => { await page.goto(server.PREFIX + '/frames/nested-frames.html'); const snapshot = await snapshotForAI(page);