Skip to content

feat: add focused and focusable information to _snapshotForAI #36059

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/injected/src/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -172,7 +174,8 @@ function toAriaNode(element: Element, options?: { forAI?: boolean, refPrefix?: s
props: {},
element,
box: box(element),
receivesPointerEvents: true
receivesPointerEvents: true,
active: isActive
};
}

Expand All @@ -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))
Expand Down Expand Up @@ -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')
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/server/codegen/csharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)});`;
}
}

Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/server/codegen/java.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)});`;
}
}

Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/server/codegen/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} `)});`;
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/server/codegen/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)})`;
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type AriaProps = {
checked?: boolean | 'mixed';
disabled?: boolean;
expanded?: boolean;
active?: boolean;
level?: number;
pressed?: boolean | 'mixed';
selected?: boolean;
Expand Down Expand Up @@ -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);
Expand Down
78 changes: 72 additions & 6 deletions tests/page/page-aria-snapshot-ai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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]:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 }) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toMatchSnapshot tests are missing in a separate file.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to-match-aria-snapshot-active.spec.ts with comprehensive toMatchAriaSnapshot tests for active functionality in commit a7f952f.

await page.setContent(`
<button id="btn1">Button 1</button>
<button id="btn2" autofocus>Button 2</button>
<div>Not focusable</div>
`);

// 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(`
<input id="input1" placeholder="First input">
<input id="input2" placeholder="Second input">
`);

// 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(`
<input id="regular-input" placeholder="Regular input">
<iframe src="data:text/html,<input id='iframe-input' placeholder='Input in iframe'>" tabindex="0"></iframe>
`);

// 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]');
});
67 changes: 67 additions & 0 deletions tests/page/to-match-aria-snapshot-active.spec.ts
Original file line number Diff line number Diff line change
@@ -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(`
<button id="btn1">Button 1</button>
<button id="btn2" autofocus>Button 2</button>
`);

// 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(`
<input id="input1" placeholder="First input">
<input id="input2" placeholder="Second input">
`);

// 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(`
<input id="regular-input" placeholder="Regular input">
<iframe src="data:text/html,<input id='iframe-input' placeholder='Input in iframe'>" tabindex="0"></iframe>
`);

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