diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index c1f2ab4bdc411..c2344910dabd6 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -163,6 +163,7 @@ function ariaRef(element: Element, role: string, name: string, options?: { forAI } function toAriaNode(element: Element, options?: { forAI?: boolean, refPrefix?: string }): AriaNode | null { + const active = element.ownerDocument.activeElement === element; if (element.nodeName === 'IFRAME') { return { role: 'iframe', @@ -172,7 +173,8 @@ function toAriaNode(element: Element, options?: { forAI?: boolean, refPrefix?: s props: {}, element, box: box(element), - receivesPointerEvents: true + receivesPointerEvents: true, + active }; } @@ -192,7 +194,8 @@ function toAriaNode(element: Element, options?: { forAI?: boolean, refPrefix?: s props: {}, element, box: box(element), - receivesPointerEvents + receivesPointerEvents, + active }; if (roleUtils.kAriaCheckedRoles.includes(role)) @@ -431,6 +434,8 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r key += ` [disabled]`; if (ariaNode.expanded) key += ` [expanded]`; + if (ariaNode.active && options?.forAI) + key += ` [active]`; if (ariaNode.level) key += ` [level=${ariaNode.level}]`; if (ariaNode.pressed === 'mixed') diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index c8c9b9ec293c1..9945d1afaa1ff 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -1027,7 +1027,7 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frame const lines = snapshot.split('\n'); const result = []; for (const line of lines) { - const match = line.match(/^(\s*)- iframe \[ref=(.*)\]/); + const match = line.match(/^(\s*)- iframe (?:\[active\] )?\[ref=(.*)\]/); if (!match) { result.push(line); continue; diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts index 48d1a627d05d4..24c908b100af2 100644 --- a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts +++ b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts @@ -28,6 +28,7 @@ export type AriaProps = { checked?: boolean | 'mixed'; disabled?: boolean; expanded?: boolean; + active?: boolean; level?: number; pressed?: boolean | 'mixed'; selected?: boolean; @@ -444,6 +445,11 @@ export class KeyParser { node.expanded = value === 'true'; return; } + if (key === 'active') { + this._assert(value === 'true' || value === 'false', 'Value of "active" attribute must be a boolean', errorPos); + node.active = value === 'true'; + return; + } if (key === 'level') { this._assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number', errorPos); node.level = Number(value); diff --git a/tests/page/page-aria-snapshot-ai.spec.ts b/tests/page/page-aria-snapshot-ai.spec.ts index 3d12cd086fee7..12bc6186326ca 100644 --- a/tests/page/page-aria-snapshot-ai.spec.ts +++ b/tests/page/page-aria-snapshot-ai.spec.ts @@ -29,7 +29,7 @@ it('should generate refs', async ({ page }) => { const snapshot1 = await snapshotForAI(page); expect(snapshot1).toContainYaml(` - - generic [ref=e1]: + - generic [active] [ref=e1]: - button "One" [ref=e2] - button "Two" [ref=e3] - button "Three" [ref=e4] @@ -44,7 +44,7 @@ it('should generate refs', async ({ page }) => { const snapshot2 = await snapshotForAI(page); expect(snapshot2).toContainYaml(` - - generic [ref=e1]: + - generic [active] [ref=e1]: - button "One" [ref=e2] - button "Not Two" [ref=e5] - button "Three" [ref=e4] @@ -68,9 +68,9 @@ it('should stitch all frame snapshots', async ({ page, server }) => { await page.goto(server.PREFIX + '/frames/nested-frames.html'); const snapshot = await snapshotForAI(page); expect(snapshot).toContainYaml(` - - generic [ref=e1]: + - generic [active] [ref=e1]: - iframe [ref=e2]: - - generic [ref=f1e1]: + - generic [active] [ref=f1e1]: - iframe [ref=f1e2]: - generic [ref=f2e2]: Hi, I'm frame - iframe [ref=f1e3]: @@ -132,7 +132,7 @@ it('should not generate refs for elements with pointer-events:none', async ({ pa const snapshot = await snapshotForAI(page); expect(snapshot).toContainYaml(` - - generic [ref=e1]: + - generic [active] [ref=e1]: - button "no-ref" - button "with-ref" [ref=e4] - button "with-ref" [ref=e7] @@ -228,7 +228,7 @@ it('should gracefully fallback when child frame cant be captured', async ({ page `, { waitUntil: 'domcontentloaded' }); const snapshot = await snapshotForAI(page); expect(snapshot).toContainYaml(` - - generic [ref=e1]: + - generic [active] [ref=e1]: - paragraph [ref=e2]: Test - iframe [ref=e3] `); @@ -256,3 +256,70 @@ it('should auto-wait for blocking CSS', async ({ page, server }) => { `, { waitUntil: 'commit' }); expect(await snapshotForAI(page)).toContainYaml('Hello World'); }); + +it('should include active element information', async ({ page }) => { + await page.setContent(` + + +
Not focusable
+ `); + + // Wait for autofocus to take effect + await page.waitForFunction(() => document.activeElement?.id === 'btn2'); + + const snapshot = await snapshotForAI(page); + + expect(snapshot).toContainYaml(` + - generic [ref=e1]: + - button "Button 1" [ref=e2] + - button "Button 2" [active] [ref=e3] + - generic [ref=e4]: Not focusable + `); +}); + +it('should update active element on focus', async ({ page }) => { + await page.setContent(` + + + `); + + // Initially there shouldn't be an active element on the inputs + const initialSnapshot = await snapshotForAI(page); + expect(initialSnapshot).toContainYaml(` + - generic [active] [ref=e1]: + - textbox "First input" [ref=e2] + - textbox "Second input" [ref=e3] + `); + + // Focus the second input + await page.locator('#input2').focus(); + + // After focus, the second input should be active + const afterFocusSnapshot = await snapshotForAI(page); + + expect(afterFocusSnapshot).toContainYaml(` + - generic [ref=e1]: + - textbox "First input" [ref=e2] + - textbox "Second input" [active] [ref=e3] + `); +}); + +it('should mark iframe as active when it contains focused element', async ({ page }) => { + // Create a simple HTML file for the iframe + await page.setContent(` + + + `); + + // Test 1: Focus the input inside the iframe + await page.frameLocator('iframe').locator('#iframe-input').focus(); + const inputInIframeFocusedSnapshot = await snapshotForAI(page); + + // The iframe should be marked as active when it contains a focused element + expect(inputInIframeFocusedSnapshot).toContainYaml(` + - generic [ref=e1]: + - textbox "Regular input" [ref=e2] + - iframe [active] [ref=e3]: + - textbox "Input in iframe" [active] [ref=f1e2] + `); +}); diff --git a/tests/playwright-test/aria-snapshot-file.spec.ts b/tests/playwright-test/aria-snapshot-file.spec.ts index 768b8d0e72d2d..0f031fc113c56 100644 --- a/tests/playwright-test/aria-snapshot-file.spec.ts +++ b/tests/playwright-test/aria-snapshot-file.spec.ts @@ -250,3 +250,26 @@ test('should respect config.expect.toMatchAriaSnapshot.pathTemplate', async ({ r expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('should match active element after focus', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'a.spec.ts-snapshots/test.aria.yml': ` + - textbox "First input" + - textbox "Second input" [active] + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page }) => { + await page.setContent(\` + + + \`); + // Focus the second input + await page.locator('#input2').focus(); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.aria.yml' }); + }); + ` + }); + + expect(result.exitCode).toBe(0); +});