Skip to content

Commit 740c897

Browse files
committed
snapshot tests, cursor fix
1 parent 6848ab8 commit 740c897

File tree

4 files changed

+200
-55
lines changed

4 files changed

+200
-55
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 43 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -495,23 +495,28 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
495495
return results;
496496
}
497497

498+
function buildByRefMap(root: AriaNode | undefined, map: Map<string, AriaNode> = new Map()): Map<string, AriaNode> {
499+
if (root?.ref)
500+
map.set(root.ref, root);
501+
for (const child of root?.children || []) {
502+
if (typeof child !== 'string')
503+
buildByRefMap(child, map);
504+
}
505+
return map;
506+
}
507+
508+
function arePropsEqual(a: AriaNode, b: AriaNode): boolean {
509+
const aKeys = Object.keys(a.props);
510+
const bKeys = Object.keys(b.props);
511+
return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]);
512+
}
513+
498514
export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previous?: AriaSnapshot): string {
499515
const options = toInternalOptions(publicOptions);
500516
const lines: string[] = [];
501517
const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true;
502518
const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str;
503-
504-
const previousByRef = new Map<string, AriaNode>();
505-
const visitPrevious = (ariaNode: AriaNode) => {
506-
if (ariaNode.ref)
507-
previousByRef.set(ariaNode.ref, ariaNode);
508-
for (const child of ariaNode.children) {
509-
if (typeof child !== 'string')
510-
visitPrevious(child);
511-
}
512-
};
513-
if (previous)
514-
visitPrevious(previous.root);
519+
const previousByRef = buildByRefMap(previous?.root);
515520

516521
const visitText = (text: string, indent: string) => {
517522
const escaped = yamlEscapeValueIfNeeded(renderString(text));
@@ -556,40 +561,36 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
556561
return key;
557562
};
558563

559-
const arePropsEqual = (a: AriaNode, b: AriaNode): boolean => {
560-
const aKeys = Object.keys(a.props);
561-
const bKeys = Object.keys(b.props);
562-
return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]);
564+
const getSingleInlinedTextChild = (ariaNode: AriaNode | undefined): string | undefined => {
565+
return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined;
563566
};
564567

565568
const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean, previousNode: AriaNode | undefined): { unchanged: boolean } => {
566569
if (ariaNode.ref)
567570
previousNode = previousByRef.get(ariaNode.ref);
568-
const linesBefore = lines.length;
569571

572+
const linesBefore = lines.length;
570573
const key = createKey(ariaNode, renderCursorPointer);
571-
let unchanged = !!previousNode && key === createKey(previousNode, renderCursorPointer) && arePropsEqual(ariaNode, previousNode);
572-
573574
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);
574-
const hasProps = !!Object.keys(ariaNode.props).length;
575575
const inCursorPointer = renderCursorPointer && !!ariaNode.ref && hasPointerCursor(ariaNode);
576+
const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode);
577+
578+
// Whether ariaNode's subtree is the same as previousNode's, and can be replaced with just a ref.
579+
let unchanged = !!previousNode && key === createKey(previousNode, renderCursorPointer) && arePropsEqual(ariaNode, previousNode);
576580

577-
if (!ariaNode.children.length && !hasProps) {
581+
if (!ariaNode.children.length && !Object.keys(ariaNode.props).length) {
578582
// Leaf node without children.
579583
lines.push(escapedKey);
580-
} else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' && !hasProps) {
581-
// Leaf node with only some text inside.
582-
const shouldInclude = includeText(ariaNode, ariaNode.children[0]);
583-
const text = shouldInclude ? renderString(ariaNode.children[0]) : null;
584-
if (text)
585-
lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(text));
584+
} else if (singleInlinedTextChild !== undefined) {
585+
// Leaf node with just some text inside.
586+
// Unchanged when the previous node also had the same single text child.
587+
unchanged = unchanged && getSingleInlinedTextChild(previousNode) === singleInlinedTextChild;
588+
589+
const shouldInclude = includeText(ariaNode, singleInlinedTextChild);
590+
if (shouldInclude)
591+
lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild)));
586592
else
587593
lines.push(escapedKey);
588-
589-
// Node is unchanged only when previous node also had the same single text child.
590-
unchanged = unchanged && !!previousNode &&
591-
previousNode.children.length === 1 && typeof previousNode.children[0] === 'string' &&
592-
!Object.keys(previousNode.props).length && ariaNode.children[0] === previousNode.children[0];
593594
} else {
594595
// Node with (optional) props and some children.
595596
lines.push(escapedKey + ':');
@@ -603,10 +604,9 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
603604
for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) {
604605
const child = ariaNode.children[childIndex];
605606
if (typeof child === 'string') {
606-
const shouldInclude = includeText(ariaNode, child);
607-
if (shouldInclude)
607+
unchanged = unchanged && previousNode?.children[childIndex] === child;
608+
if (includeText(ariaNode, child))
608609
visitText(child, childIndent);
609-
unchanged = unchanged && previousNode?.children[childIndex] === child && shouldInclude === includeText(previousNode, child);
610610
} else {
611611
const previousChild = previousNode?.children[childIndex];
612612
const childResult = visit(child, childIndent, renderCursorPointer && !inCursorPointer, typeof previousChild !== 'string' ? previousChild : undefined);
@@ -624,17 +624,13 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
624624
return { unchanged };
625625
};
626626

627-
const ariaNode = ariaSnapshot.root;
628-
if (ariaNode.role === 'fragment') {
629-
// Render fragment.
630-
for (const child of ariaNode.children || []) {
631-
if (typeof child === 'string')
632-
visitText(child, '');
633-
else
634-
visit(child, '', !!options.renderCursorPointer, undefined);
635-
}
636-
} else {
637-
visit(ariaNode, '', !!options.renderCursorPointer, undefined);
627+
// Do not render the root fragment, just its children.
628+
const nodesToRender = ariaSnapshot.root.role === 'fragment' ? ariaSnapshot.root.children : [ariaSnapshot.root];
629+
for (const nodeToRender of nodesToRender) {
630+
if (typeof nodeToRender === 'string')
631+
visitText(nodeToRender, '');
632+
else
633+
visit(nodeToRender, '', !!options.renderCursorPointer, undefined);
638634
}
639635
return lines.join('\n');
640636
}
@@ -698,5 +694,5 @@ function textContributesInfo(node: AriaNode, text: string): boolean {
698694
}
699695

700696
function hasPointerCursor(ariaNode: AriaNode): boolean {
701-
return ariaNode.box.style?.cursor === 'pointer';
697+
return ariaNode.box.cursor === 'pointer';
702698
}

packages/injected/src/domUtils.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,28 +112,32 @@ export type Box = {
112112
visible: boolean;
113113
inline: boolean;
114114
rect?: DOMRect;
115-
style?: CSSStyleDeclaration;
115+
// Note: we do not store the CSSStyleDeclaration object, because it is a live object
116+
// and changes values over time. This does not work for caching or comparing to the
117+
// old values. Instead, store all the properties separately.
118+
cursor?: CSSStyleDeclaration['cursor'];
116119
};
117120

118121
export function computeBox(element: Element): Box {
119122
// Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises.
120123
const style = getElementComputedStyle(element);
121124
if (!style)
122125
return { visible: true, inline: false };
126+
const cursor = style.cursor;
123127
if (style.display === 'contents') {
124128
// display:contents is not rendered itself, but its child nodes are.
125129
for (let child = element.firstChild; child; child = child.nextSibling) {
126130
if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && isElementVisible(child as Element))
127-
return { visible: true, inline: false, style };
131+
return { visible: true, inline: false, cursor };
128132
if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text))
129-
return { visible: true, inline: true, style };
133+
return { visible: true, inline: true, cursor };
130134
}
131-
return { visible: false, inline: false, style };
135+
return { visible: false, inline: false, cursor };
132136
}
133137
if (!isElementStyleVisibilityVisible(element, style))
134-
return { style, visible: false, inline: false };
138+
return { cursor, visible: false, inline: false };
135139
const rect = element.getBoundingClientRect();
136-
return { rect, style, visible: rect.width > 0 && rect.height > 0, inline: style.display === 'inline' };
140+
return { rect, cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === 'inline' };
137141
}
138142

139143
export function isElementVisible(element: Element): boolean {

packages/playwright/src/mcp/browser/tab.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ export class Tab extends EventEmitter<TabEventsInterface> {
6262
this.context = context;
6363
this.page = page as Page;
6464
this._onPageClose = onPageClose;
65-
page.on('console', console.error);
6665
page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event)));
6766
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
6867
page.on('request', request => this._requests.add(request));

tests/page/page-aria-snapshot-ai.spec.ts

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { asLocator } from 'playwright-core/lib/utils';
1919

2020
import { test as it, expect, unshift } from './pageTest';
2121

22-
function snapshotForAI(page: any, options?: { timeout?: number }): Promise<string> {
22+
function snapshotForAI(page: any, options?: { timeout?: number, mode?: 'full' | 'incremental', track?: string }): Promise<string> {
2323
return page._snapshotForAI(options);
2424
}
2525

@@ -448,3 +448,149 @@ it('should not remove generic nodes with title', async ({ page }) => {
448448
- generic "Element title" [ref=e2]
449449
`);
450450
});
451+
452+
it('should create incremental snapshots on multiple tracks', async ({ page }) => {
453+
await page.setContent(`<ul><li><button>a button</button></li><li><span>a span</span></li><li id=hidden-li style="display:none">some text</li></ul>`);
454+
455+
expect(await snapshotForAI(page, { track: 'first', mode: 'incremental' })).toContainYaml(`
456+
- list [ref=e2]:
457+
- listitem [ref=e3]:
458+
- button "a button" [ref=e4]
459+
- listitem [ref=e5]: a span
460+
`);
461+
expect(await snapshotForAI(page, { track: 'second', mode: 'incremental' })).toContainYaml(`
462+
- list [ref=e2]:
463+
- listitem [ref=e3]:
464+
- button "a button" [ref=e4]
465+
- listitem [ref=e5]: a span
466+
`);
467+
expect(await snapshotForAI(page, { track: 'first', mode: 'incremental' })).toContainYaml(`
468+
- ref=e2 [unchanged]
469+
`);
470+
471+
await page.evaluate(() => {
472+
document.querySelector('span').textContent = 'changed span';
473+
document.getElementById('hidden-li').style.display = 'inline';
474+
});
475+
expect(await snapshotForAI(page, { track: 'first', mode: 'incremental' })).toContainYaml(`
476+
- list [ref=e2]:
477+
- ref=e3 [unchanged]
478+
- listitem [ref=e5]: changed span
479+
- listitem [ref=e6]: some text
480+
`);
481+
482+
await page.evaluate(() => {
483+
document.querySelector('span').textContent = 'a span';
484+
document.getElementById('hidden-li').style.display = 'none';
485+
});
486+
expect(await snapshotForAI(page, { track: 'first', mode: 'incremental' })).toContainYaml(`
487+
- list [ref=e2]:
488+
- ref=e3 [unchanged]
489+
- listitem [ref=e5]: a span
490+
`);
491+
expect(await snapshotForAI(page, { track: 'second', mode: 'incremental' })).toContainYaml(`
492+
- ref=e2 [unchanged]
493+
`);
494+
495+
expect(await snapshotForAI(page, { track: 'second', mode: 'full' })).toContainYaml(`
496+
- list [ref=e2]:
497+
- listitem [ref=e3]:
498+
- button "a button" [ref=e4]
499+
- listitem [ref=e5]: a span
500+
`);
501+
});
502+
503+
it('should create incremental snapshot for attribute change', async ({ page }) => {
504+
await page.setContent(`<button>a button</button>`);
505+
await page.evaluate(() => document.querySelector('button').focus());
506+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
507+
- button "a button" [active] [ref=e2]
508+
`);
509+
510+
await page.evaluate(() => document.querySelector('button').blur());
511+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
512+
- button "a button" [ref=e2]
513+
`);
514+
});
515+
516+
it('should create incremental snapshot for child removal', async ({ page }) => {
517+
await page.setContent(`<li><button>a button</button><span>some text</span></li>`);
518+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
519+
- listitem [ref=e2]:
520+
- button "a button" [ref=e3]
521+
- text: some text
522+
`);
523+
524+
await page.evaluate(() => document.querySelector('span').remove());
525+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
526+
- listitem [ref=e2]:
527+
- ref=e3 [unchanged]
528+
`);
529+
});
530+
531+
it('should create incremental snapshot for child addition', async ({ page }) => {
532+
await page.setContent(`<li><button>a button</button><span style="display:none">some text</span></li>`);
533+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
534+
- listitem [ref=e2]:
535+
- button "a button" [ref=e3]
536+
`);
537+
538+
await page.evaluate(() => document.querySelector('span').style.display = 'inline');
539+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
540+
- listitem [ref=e2]:
541+
- ref=e3 [unchanged]
542+
- text: some text
543+
`);
544+
});
545+
546+
it('should create incremental snapshot for prop change', async ({ page }) => {
547+
await page.setContent(`<a href="about:blank" style="cursor:pointer">a link</a>`);
548+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
549+
- link "a link" [ref=e2] [cursor=pointer]:
550+
- /url: about:blank
551+
`);
552+
553+
await page.evaluate(() => document.querySelector('a').setAttribute('href', 'https://playwright.dev'));
554+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
555+
- link "a link" [ref=e2] [cursor=pointer]:
556+
- /url: https://playwright.dev
557+
`);
558+
});
559+
560+
it('should create incremental snapshot for cursor change', async ({ page }) => {
561+
await page.setContent(`<a href="about:blank" style="cursor:pointer">a link</a>`);
562+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
563+
- link "a link" [ref=e2] [cursor=pointer]:
564+
- /url: about:blank
565+
`);
566+
567+
await page.evaluate(() => document.querySelector('a').style.cursor = 'default');
568+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
569+
- link "a link" [ref=e2]:
570+
- /url: about:blank
571+
`);
572+
});
573+
574+
it('should create incremental snapshot for name change', async ({ page }) => {
575+
await page.setContent(`<button><span>a button</span></button>`);
576+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
577+
- button "a button" [ref=e2]
578+
`);
579+
580+
await page.evaluate(() => document.querySelector('span').textContent = 'new button');
581+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
582+
- button "new button" [ref=e3]
583+
`);
584+
});
585+
586+
it('should create incremental snapshot for text change', async ({ page }) => {
587+
await page.setContent(`<li><span>an item</span></li>`);
588+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
589+
- listitem [ref=e2]: an item
590+
`);
591+
592+
await page.evaluate(() => document.querySelector('span').textContent = 'new text');
593+
expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(`
594+
- listitem [ref=e2]: new text
595+
`);
596+
});

0 commit comments

Comments
 (0)