Skip to content

Commit 263453a

Browse files
authored
chore: relax the visibility test for ai snapshots (#35898)
1 parent 1d181ae commit 263453a

File tree

4 files changed

+267
-223
lines changed

4 files changed

+267
-223
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
1818

19-
import { box, getElementComputedStyle, getGlobalOptions } from './domUtils';
19+
import { box, getElementComputedStyle, getGlobalOptions, isElementVisible } from './domUtils';
2020
import * as roleUtils from './roleUtils';
2121
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
2222

@@ -75,7 +75,10 @@ export function generateAriaTree(rootElement: Element, generation: number, optio
7575
return;
7676

7777
const element = node as Element;
78-
if (roleUtils.isElementHiddenForAria(element))
78+
let isVisible = !roleUtils.isElementHiddenForAria(element);
79+
if (options?.forAI)
80+
isVisible = isVisible || isElementVisible(element);
81+
if (!isVisible)
7982
return;
8083

8184
const ariaChildren: Element[] = [];
@@ -197,7 +200,9 @@ function normalizeGenericRoles(node: AriaNode) {
197200
const normalized = normalizeChildren(child);
198201
result.push(...normalized);
199202
}
200-
const removeSelf = node.role === 'generic' && result.every(c => typeof c !== 'string' && canRef(c));
203+
204+
// Only remove generic that encloses one element, logical grouping still makes sense, even if it is not ref-able.
205+
const removeSelf = node.role === 'generic' && result.length <= 1 && result.every(c => typeof c !== 'string' && receivesPointerEvents(c));
201206
if (removeSelf)
202207
return result;
203208
node.children = result;
@@ -402,10 +407,11 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
402407
key += ` [pressed]`;
403408
if (ariaNode.selected === true)
404409
key += ` [selected]`;
405-
if (options?.forAI && canRef(ariaNode)) {
410+
if (options?.forAI && receivesPointerEvents(ariaNode)) {
406411
const id = ariaSnapshot.ids.get(ariaNode.element);
412+
const cursor = hasPointerCursor(ariaNode) ? ' [cursor=pointer]' : '';
407413
if (id)
408-
key += ` [ref=s${ariaSnapshot.generation}e${id}]`;
414+
key += ` [ref=s${ariaSnapshot.generation}e${id}]${cursor}`;
409415
}
410416

411417
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);
@@ -496,6 +502,10 @@ function textContributesInfo(node: AriaNode, text: string): boolean {
496502
return filtered.trim().length / text.length > 0.1;
497503
}
498504

499-
function canRef(ariaNode: AriaNode): boolean {
505+
function receivesPointerEvents(ariaNode: AriaNode): boolean {
500506
return ariaNode.box.visible && ariaNode.receivesPointerEvents;
501507
}
508+
509+
function hasPointerCursor(ariaNode: AriaNode): boolean {
510+
return ariaNode.box.style?.cursor === 'pointer';
511+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { FrameLocator, Page } from '@playwright/test';
18+
import { test as it, expect } from './pageTest';
19+
20+
const forAI = { _forAI: true } as any;
21+
22+
it('should generate refs', async ({ page }) => {
23+
await page.setContent(`
24+
<button>One</button>
25+
<button>Two</button>
26+
<button>Three</button>
27+
`);
28+
29+
const snapshot1 = await page.locator('body').ariaSnapshot(forAI);
30+
expect(snapshot1).toContain('- button "One" [ref=s1e3]');
31+
expect(snapshot1).toContain('- button "Two" [ref=s1e4]');
32+
expect(snapshot1).toContain('- button "Three" [ref=s1e5]');
33+
34+
await expect(page.locator('aria-ref=s1e3')).toHaveText('One');
35+
await expect(page.locator('aria-ref=s1e4')).toHaveText('Two');
36+
await expect(page.locator('aria-ref=s1e5')).toHaveText('Three');
37+
38+
const snapshot2 = await page.locator('body').ariaSnapshot(forAI);
39+
expect(snapshot2).toContain('- button "One" [ref=s2e3]');
40+
await expect(page.locator('aria-ref=s2e3')).toHaveText('One');
41+
42+
const e = await expect(page.locator('aria-ref=s1e3')).toHaveText('One').catch(e => e);
43+
expect(e.message).toContain('Error: Stale aria-ref, expected s2e{number}, got s1e3');
44+
});
45+
46+
it('should list iframes', async ({ page }) => {
47+
await page.setContent(`
48+
<h1>Hello</h1>
49+
<iframe name="foo" src="data:text/html,<h1>World</h1>">
50+
`);
51+
52+
const snapshot1 = await page.locator('body').ariaSnapshot(forAI);
53+
expect(snapshot1).toContain('- iframe');
54+
55+
const frameSnapshot = await page.frameLocator(`iframe`).locator('body').ariaSnapshot();
56+
expect(frameSnapshot).toEqual('- heading "World" [level=1]');
57+
});
58+
59+
it('ref mode can be used to stitch all frame snapshots', async ({ page, server }) => {
60+
await page.goto(server.PREFIX + '/frames/nested-frames.html');
61+
62+
async function allFrameSnapshot(frame: Page | FrameLocator): Promise<string> {
63+
const snapshot = await frame.locator('body').ariaSnapshot(forAI);
64+
const lines = snapshot.split('\n');
65+
const result = [];
66+
for (const line of lines) {
67+
const match = line.match(/^(\s*)- iframe \[ref=(.*)\]/);
68+
if (!match) {
69+
result.push(line);
70+
continue;
71+
}
72+
73+
const leadingSpace = match[1];
74+
const ref = match[2];
75+
const childFrame = frame.frameLocator(`aria-ref=${ref}`);
76+
const childSnapshot = await allFrameSnapshot(childFrame);
77+
result.push(line + ':', childSnapshot.split('\n').map(l => leadingSpace + ' ' + l).join('\n'));
78+
}
79+
return result.join('\n');
80+
}
81+
82+
expect(await allFrameSnapshot(page)).toContainYaml(`
83+
- generic [ref=s1e2]:
84+
- iframe [ref=s1e3]:
85+
- generic [ref=s1e2]:
86+
- iframe [ref=s1e3]:
87+
- generic [ref=s1e3]: Hi, I'm frame
88+
- iframe [ref=s1e4]:
89+
- generic [ref=s1e3]: Hi, I'm frame
90+
- iframe [ref=s1e4]:
91+
- generic [ref=s1e3]: Hi, I'm frame
92+
`);
93+
});
94+
95+
it('should not generate refs for hidden elements', async ({ page }) => {
96+
await page.setContent(`
97+
<button>One</button>
98+
<button style="width: 0; height: 0; appearance: none; border: 0; padding: 0;">Two</button>
99+
<button>Three</button>
100+
`);
101+
102+
const snapshot = await page.locator('body').ariaSnapshot(forAI);
103+
expect(snapshot).toContainYaml(`
104+
- generic [ref=s1e2]:
105+
- button "One" [ref=s1e3]
106+
- button "Two"
107+
- button "Three" [ref=s1e5]
108+
`);
109+
});
110+
111+
it('should not generate refs for elements with pointer-events:none', async ({ page }) => {
112+
await page.setContent(`
113+
<button style="pointer-events: none">no-ref</button>
114+
<div style="pointer-events: none">
115+
<button style="pointer-events: auto">with-ref</button>
116+
</div>
117+
<div style="pointer-events: none">
118+
<div style="pointer-events: initial">
119+
<button>with-ref</button>
120+
</div>
121+
</div>
122+
<div style="pointer-events: none">
123+
<div style="pointer-events: auto">
124+
<button>with-ref</button>
125+
</div>
126+
</div>
127+
<div style="pointer-events: auto">
128+
<div style="pointer-events: none">
129+
<button>no-ref</button>
130+
</div>
131+
</div>
132+
`);
133+
134+
const snapshot = await page.locator('body').ariaSnapshot(forAI);
135+
expect(snapshot).toContainYaml(`
136+
- generic [ref=s1e2]:
137+
- button "no-ref"
138+
- button "with-ref" [ref=s1e5]
139+
- button "with-ref" [ref=s1e8]
140+
- button "with-ref" [ref=s1e11]
141+
- generic [ref=s1e12]:
142+
- generic:
143+
- button "no-ref"
144+
`);
145+
});
146+
147+
it('emit generic roles for nodes w/o roles', async ({ page }) => {
148+
await page.setContent(`
149+
<style>
150+
input {
151+
width: 0;
152+
height: 0;
153+
opacity: 0;
154+
}
155+
</style>
156+
<div>
157+
<label>
158+
<span>
159+
<input type="radio" value="Apple" checked="">
160+
</span>
161+
<span>Apple</span>
162+
</label>
163+
<label>
164+
<span>
165+
<input type="radio" value="Pear">
166+
</span>
167+
<span>Pear</span>
168+
</label>
169+
<label>
170+
<span>
171+
<input type="radio" value="Orange">
172+
</span>
173+
<span>Orange</span>
174+
</label>
175+
</div>
176+
`);
177+
178+
const snapshot = await page.locator('body').ariaSnapshot(forAI);
179+
180+
expect(snapshot).toContainYaml(`
181+
- generic [ref=s1e3]:
182+
- generic [ref=s1e4]:
183+
- generic [ref=s1e5]:
184+
- radio "Apple" [checked]
185+
- generic [ref=s1e7]: Apple
186+
- generic [ref=s1e8]:
187+
- generic [ref=s1e9]:
188+
- radio "Pear"
189+
- generic [ref=s1e11]: Pear
190+
- generic [ref=s1e12]:
191+
- generic [ref=s1e13]:
192+
- radio "Orange"
193+
- generic [ref=s1e15]: Orange
194+
`);
195+
});
196+
197+
it('should collapse generic nodes', async ({ page }) => {
198+
await page.setContent(`
199+
<div>
200+
<div>
201+
<div>
202+
<button>Button</button>
203+
</div>
204+
</div>
205+
</div>
206+
`);
207+
208+
const snapshot = await page.locator('body').ariaSnapshot(forAI);
209+
expect(snapshot).toContainYaml(`
210+
- button \"Button\" [ref=s1e6]
211+
`);
212+
});
213+
214+
it('should include cursor pointer hint', async ({ page }) => {
215+
await page.setContent(`
216+
<button style="cursor: pointer">Button</button>
217+
`);
218+
219+
const snapshot = await page.locator('body').ariaSnapshot(forAI);
220+
expect(snapshot).toContainYaml(`
221+
- button \"Button\" [ref=s1e3] [cursor=pointer]
222+
`);
223+
});

0 commit comments

Comments
 (0)
close