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}]`;