diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index a75a5cd185d6c..a12ff0b896f7e 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -164,6 +164,8 @@ function ariaRef(element: Element, role: string, name: string, options?: { forAI function toAriaNode(element: Element, options?: { forAI?: boolean, refPrefix?: string }): AriaNode | null { if (element.nodeName === 'IFRAME') { + const isActive = element.ownerDocument.activeElement === element; + return { role: 'iframe', name: '', @@ -172,7 +174,8 @@ function toAriaNode(element: Element, options?: { forAI?: boolean, refPrefix?: s props: {}, element, box: box(element), - receivesPointerEvents: true + receivesPointerEvents: true, + active: isActive }; } @@ -192,7 +195,8 @@ function toAriaNode(element: Element, options?: { forAI?: boolean, refPrefix?: s props: {}, element, box: box(element), - receivesPointerEvents + receivesPointerEvents, + active: element.ownerDocument.activeElement === element }; if (roleUtils.kAriaCheckedRoles.includes(role)) @@ -431,6 +435,8 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r key += ` [disabled]`; if (ariaNode.expanded) key += ` [expanded]`; + if (ariaNode.active) + key += ` [active]`; if (ariaNode.level) key += ` [level=${ariaNode.level}]`; if (ariaNode.pressed === 'mixed') diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index 9fd67fa96c06c..77ef62924db54 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -148,7 +148,9 @@ export class CSharpLanguageGenerator implements LanguageGenerator { return `await Expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } case 'assertSnapshot': - return `await Expect(${subject}.${this._asLocator(action.selector)}).ToMatchAriaSnapshotAsync(${quote(action.snapshot)});`; + // Remove [active] attribute from codegen snapshots as it's too transient for stable tests + const cleanedSnapshot = action.snapshot.replace(/\s\[active\]/g, ''); + return `await Expect(${subject}.${this._asLocator(action.selector)}).ToMatchAriaSnapshotAsync(${quote(cleanedSnapshot)});`; } } diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 42456eb4a9549..a2ddc3513828a 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -135,7 +135,9 @@ export class JavaLanguageGenerator implements LanguageGenerator { return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${assertion};`; } case 'assertSnapshot': - return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).matchesAriaSnapshot(${quote(action.snapshot)});`; + // Remove [active] attribute from codegen snapshots as it's too transient for stable tests + const cleanedSnapshot = action.snapshot.replace(/\s\[active\]/g, ''); + return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).matchesAriaSnapshot(${quote(cleanedSnapshot)});`; } } diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index 568f0a5113c35..d41219a107190 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -120,7 +120,9 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { } case 'assertSnapshot': { const commentIfNeeded = this._isTest ? '' : '// '; - return `${commentIfNeeded}await expect(${subject}.${this._asLocator(action.selector)}).toMatchAriaSnapshot(${quoteMultiline(action.snapshot, `${commentIfNeeded} `)});`; + // Remove [active] attribute from codegen snapshots as it's too transient for stable tests + const cleanedSnapshot = action.snapshot.replace(/\s\[active\]/g, ''); + return `${commentIfNeeded}await expect(${subject}.${this._asLocator(action.selector)}).toMatchAriaSnapshot(${quoteMultiline(cleanedSnapshot, `${commentIfNeeded} `)});`; } } } diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index d7b056f083175..365f02b99daa3 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -128,7 +128,9 @@ export class PythonLanguageGenerator implements LanguageGenerator { return `expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } case 'assertSnapshot': - return `expect(${subject}.${this._asLocator(action.selector)}).to_match_aria_snapshot(${quote(action.snapshot)})`; + // Remove [active] attribute from codegen snapshots as it's too transient for stable tests + const cleanedSnapshot = action.snapshot.replace(/\s\[active\]/g, ''); + return `expect(${subject}.${this._asLocator(action.selector)}).to_match_aria_snapshot(${quote(cleanedSnapshot)})`; } } diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts index ffbd50ddef416..2e92b67d1c994 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; @@ -443,6 +444,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 b9a77596495f6..0d8f90a2cc10e 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]: @@ -128,7 +128,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] @@ -224,8 +224,74 @@ 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] `); }); + +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).toContain('iframe [active]'); + + // Also check that the input element inside the iframe is active + const iframeSnapshot = await page.frameLocator('iframe').locator('body').ariaSnapshot(); + expect(iframeSnapshot).toContain('textbox "Input in iframe" [active]'); +}); diff --git a/tests/page/to-match-aria-snapshot-active.spec.ts b/tests/page/to-match-aria-snapshot-active.spec.ts new file mode 100644 index 0000000000000..bfde62b51c605 --- /dev/null +++ b/tests/page/to-match-aria-snapshot-active.spec.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './pageTest'; + +test('should match active element', async ({ page }) => { + await page.setContent(` + + + `); + + // Wait for autofocus to take effect + await page.waitForFunction(() => document.activeElement?.id === 'btn2'); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button "Button 1" + - button "Button 2" [active] + `); +}); + +test('should match active element after focus', async ({ page }) => { + await page.setContent(` + + + `); + + // Focus the second input + await page.locator('#input2').focus(); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - textbox "First input" + - textbox "Second input" [active] + `); +}); + +test('should match active iframe', async ({ page }) => { + await page.setContent(` + + + `); + + // Focus the input inside the iframe + await page.frameLocator('iframe').locator('#iframe-input').focus(); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - textbox "Regular input" + - iframe [active] + `); + + // Also check that the input element inside the iframe is active + await expect(page.frameLocator('iframe').locator('body')).toMatchAriaSnapshot(` + - textbox "Input in iframe" [active] + `); +}); \ No newline at end of file