From 3e38ee6130b9e35d2730830eb2072911f408e1a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 May 2025 23:50:56 +0000 Subject: [PATCH 1/3] feat(aria): capture active element state Fixes https://github.com/microsoft/playwright/issues/36041 --- packages/injected/src/ariaSnapshot.ts | 10 ++- packages/playwright-core/src/server/page.ts | 2 +- .../src/utils/isomorphic/ariaSnapshot.ts | 6 ++ tests/page/page-aria-snapshot-ai.spec.ts | 79 +++++++++++++++++-- .../to-match-aria-snapshot-active.spec.ts | 67 ++++++++++++++++ .../aria-snapshot-file.spec.ts | 23 ++++++ 6 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 tests/page/to-match-aria-snapshot-active.spec.ts diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index c1f2ab4bdc411..3bcbce434fe2f 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,9 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r key += ` [disabled]`; if (ariaNode.expanded) key += ` [expanded]`; + // Do not include active in the generated code. + if (ariaNode.active && options?.mode !== 'regex') + 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/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 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); +}); From 289ba4a4be6b697748108014f6f680debb426dde Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 30 Jun 2025 16:39:27 -0700 Subject: [PATCH 2/3] Revert tests/page/to-match-aria-snapshot-active.spec.ts --- .../to-match-aria-snapshot-active.spec.ts | 67 ------------------- 1 file changed, 67 deletions(-) delete mode 100644 tests/page/to-match-aria-snapshot-active.spec.ts diff --git a/tests/page/to-match-aria-snapshot-active.spec.ts b/tests/page/to-match-aria-snapshot-active.spec.ts deleted file mode 100644 index bfde62b51c605..0000000000000 --- a/tests/page/to-match-aria-snapshot-active.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * 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 From 1c291d94a2ec9def04bc69c6e95f0fd38ee6dd2c Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 30 Jun 2025 17:02:12 -0700 Subject: [PATCH 3/3] generate active only for AI --- packages/injected/src/ariaSnapshot.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 3bcbce434fe2f..c2344910dabd6 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -434,8 +434,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r key += ` [disabled]`; if (ariaNode.expanded) key += ` [expanded]`; - // Do not include active in the generated code. - if (ariaNode.active && options?.mode !== 'regex') + if (ariaNode.active && options?.forAI) key += ` [active]`; if (ariaNode.level) key += ` [level=${ariaNode.level}]`;