diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 4397d144db63d..15943f1a428b8 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -206,6 +206,12 @@ Below is the HTML markup and the respective ARIA snapshot: - link "About" ``` +### option: Locator.ariaSnapshot.emitGeneric +* since: v1.53 +- `emitGeneric` <[boolean]> + +Generate `generic` aria nodes for elements w/o roles (similar to Chrome DevTools). + ### option: Locator.ariaSnapshot.ref * since: v1.52 - `ref` <[boolean]> diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 7f7b72c3566c4..17de28ecbfe6c 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -38,7 +38,7 @@ export type AriaSnapshot = { ids: Map; }; -export function generateAriaTree(rootElement: Element, generation: number): AriaSnapshot { +export function generateAriaTree(rootElement: Element, generation: number, options?: { emitGeneric?: boolean }): AriaSnapshot { const visited = new Set(); const snapshot: AriaSnapshot = { @@ -91,7 +91,7 @@ export function generateAriaTree(rootElement: Element, generation: number): Aria return; addElement(element); - const childAriaNode = toAriaNode(element); + const childAriaNode = toAriaNode(element, options); if (childAriaNode) ariaNode.children.push(childAriaNode); processElement(childAriaNode || ariaNode, element, ariaChildren); @@ -145,14 +145,16 @@ export function generateAriaTree(rootElement: Element, generation: number): Aria } normalizeStringChildren(snapshot.root); + normalizeGenericRoles(snapshot.root); return snapshot; } -function toAriaNode(element: Element): AriaNode | null { +function toAriaNode(element: Element, options?: { emitGeneric?: boolean }): AriaNode | null { if (element.nodeName === 'IFRAME') return { role: 'iframe', name: '', children: [], props: {}, element }; - const role = roleUtils.getAriaRole(element); + const defaultRole = options?.emitGeneric ? 'generic' : null; + const role = roleUtils.getAriaRole(element) ?? defaultRole; if (!role || role === 'presentation' || role === 'none') return null; @@ -185,6 +187,33 @@ function toAriaNode(element: Element): AriaNode | null { return result; } +function normalizeGenericRoles(rootA11yNode: AriaNode) { + const visit = (ariaNode: AriaNode) => { + const newChildren: (AriaNode | string)[] = []; + for (const child of ariaNode.children) { + if (typeof child === 'string') { + newChildren.push(child); + continue; + } + const isEmptyGeneric = child.role === 'generic' && child.children.length === 0; + const isSingleGenericChild = child.role === 'generic' && child.children.length === 1; + if (isSingleGenericChild) { + // Inline single child chains. + const newChild = child.children[0]; + newChildren.push(newChild); + if (typeof newChild !== 'string') + visit(newChild); + } else if (!isEmptyGeneric) { + // Empty div + newChildren.push(child); + visit(child); + } + } + ariaNode.children = newChildren; + }; + visit(rootA11yNode); +} + function normalizeStringChildren(rootA11yNode: AriaNode) { const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => { if (!buffer.length) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 5a1ba8bc8949e..d333e1460d633 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -281,11 +281,11 @@ export class InjectedScript { return new Set(result.map(r => r.element)); } - ariaSnapshot(node: Node, options?: { mode?: 'raw' | 'regex', ref?: boolean }): string { + ariaSnapshot(node: Node, options?: { mode?: 'raw' | 'regex', ref?: boolean, emitGeneric?: boolean }): string { if (node.nodeType !== Node.ELEMENT_NODE) throw this.createStacklessError('Can only capture aria snapshot of Element nodes.'); const generation = (this._lastAriaSnapshot?.generation || 0) + 1; - this._lastAriaSnapshot = generateAriaTree(node as Element, generation); + this._lastAriaSnapshot = generateAriaTree(node as Element, generation, options); return renderAriaTree(this._lastAriaSnapshot, options); } diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index de167296585ac..c018ad0623b5a 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -12500,6 +12500,11 @@ export interface Locator { * @param options */ ariaSnapshot(options?: { + /** + * Generate `generic` aria nodes for elements w/o roles (similar to Chrome DevTools). + */ + emitGeneric?: boolean; + /** * Generate symbolic reference for each element. One can use `aria-ref=` locator immediately after capturing the * snapshot to perform actions on the element. diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 46825a99a9dd9..b431d7a317e18 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -303,7 +303,7 @@ export class Locator implements api.Locator { return await this._withElement((h, timeout) => h.screenshot({ ...options, mask, timeout }), options.timeout); } - async ariaSnapshot(options?: { ref?: boolean, _mode?: 'raw' | 'regex' } & TimeoutOptions): Promise { + async ariaSnapshot(options?: { ref?: boolean, emitGeneric?: boolean, _mode?: 'raw' | 'regex' } & TimeoutOptions): Promise { const result = await this._frame._channel.ariaSnapshot({ ...options, mode: options?._mode, selector: this._selector }); return result.snapshot; } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 0484fd1c11d6b..8ef6aa850f4a1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1481,6 +1481,7 @@ scheme.FrameAddStyleTagResult = tObject({ scheme.FrameAriaSnapshotParams = tObject({ selector: tString, ref: tOptional(tBoolean), + emitGeneric: tOptional(tBoolean), mode: tOptional(tEnum(['raw', 'regex'])), timeout: tOptional(tNumber), }); diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index fc4bd34255fb3..0886ad9195477 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -813,7 +813,7 @@ export class ElementHandle extends js.JSHandle { return this._page._delegate.getBoundingBox(this); } - async ariaSnapshot(options: { id?: boolean, mode?: 'raw' | 'regex' }): Promise { + async ariaSnapshot(options: { ref?: boolean, emitGeneric?: boolean, mode?: 'raw' | 'regex' }): Promise { return await this.evaluateInUtility(([injected, element, options]) => injected.ariaSnapshot(element, options), options); } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 179a046ed41a5..bf5be6e09d0eb 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1411,7 +1411,7 @@ export class Frame extends SdkObject { }); } - async ariaSnapshot(metadata: CallMetadata, selector: string, options: { ref?: boolean, mode?: 'raw' | 'regex' } & types.TimeoutOptions = {}): Promise { + async ariaSnapshot(metadata: CallMetadata, selector: string, options: { ref?: boolean, emitGeneric?: boolean, mode?: 'raw' | 'regex' } & types.TimeoutOptions = {}): Promise { const controller = new ProgressController(metadata, this); return controller.run(async progress => { return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => handle.ariaSnapshot(options)); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index de167296585ac..c018ad0623b5a 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -12500,6 +12500,11 @@ export interface Locator { * @param options */ ariaSnapshot(options?: { + /** + * Generate `generic` aria nodes for elements w/o roles (similar to Chrome DevTools). + */ + emitGeneric?: boolean; + /** * Generate symbolic reference for each element. One can use `aria-ref=` locator immediately after capturing the * snapshot to perform actions on the element. diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 2e1c447b88648..170e76410f2d2 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2688,11 +2688,13 @@ export type FrameAddStyleTagResult = { export type FrameAriaSnapshotParams = { selector: string, ref?: boolean, + emitGeneric?: boolean, mode?: 'raw' | 'regex', timeout?: number, }; export type FrameAriaSnapshotOptions = { ref?: boolean, + emitGeneric?: boolean, mode?: 'raw' | 'regex', timeout?: number, }; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index c146e995da8b9..481570f6e3952 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1975,6 +1975,7 @@ Frame: parameters: selector: string ref: boolean? + emitGeneric: boolean? mode: type: enum? literals: diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 5e3f0cf93a51f..c52dd68df178b 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -742,3 +742,42 @@ it('should not include hidden input elements', async ({ page }) => { expect(snapshot).toContain(`- button \"One\" - button \"Three\"`); }); + +it('emit generic roles for nodes w/o roles', async ({ page }) => { + await page.setContent(` + +
+ + + +
+ `); + + const snapshot = await page.locator('body').ariaSnapshot({ emitGeneric: true }); + + expect(snapshot).toContain(`- generic: + - generic: Apple + - generic: Pear + - generic: Orange`); +});