Skip to content
Merged
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
113 changes: 106 additions & 7 deletions packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,13 @@ export type AriaTextValue = {
normalized: string;
};

// Character offsets in the parsed source.
type SourceRange = { from: number, to: number };

export type AriaTemplateTextNode = {
kind: 'text';
text: AriaTextValue;
sourceRange?: SourceRange;
};

export type AriaTemplateRoleNode = AriaProps & {
Expand All @@ -55,6 +59,8 @@ export type AriaTemplateRoleNode = AriaProps & {
children?: AriaTemplateNode[];
props?: Record<string, AriaTextValue>;
containerMode?: 'contain' | 'equal' | 'deep-equal';
sourceRange?: SourceRange;
subtreeSourceRange?: SourceRange; // only present when different from sourceRange
};

export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
Expand All @@ -70,7 +76,7 @@ type YamlLibrary = {
};

type ParsedYamlPosition = { line: number; col: number; };
type ParsingOptions = yamlTypes.ParseOptions;
type ParsingOptions = yamlTypes.ParseOptions & { laxProps?: boolean };

export type ParsedYamlError = {
message: string;
Expand Down Expand Up @@ -98,6 +104,13 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: Pars
return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])];
};

type WithYamlRange = { range?: yamlTypes.Range | null };
const computeRange = (firstToken: WithYamlRange, lastToken: WithYamlRange): SourceRange | undefined => {
if (!firstToken.range)
return;
return { from: firstToken.range[0], to: lastToken.range ? lastToken.range[2] : firstToken.range[2] };
};

const addError = (error: yamlTypes.YAMLError) => {
errors.push({
message: error.message,
Expand All @@ -111,6 +124,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: Pars
if (itemIsString) {
const childNode = KeyParser.parse(item, parseOptions, errors);
if (childNode) {
childNode.sourceRange = computeRange(item, item);
container.children = container.children || [];
container.children.push(childNode);
}
Expand Down Expand Up @@ -156,7 +170,8 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: Pars
}
container.children.push({
kind: 'text',
text: textValue(value.value)
text: textValue(value.value),
sourceRange: computeRange(key, value),
});
continue;
}
Expand Down Expand Up @@ -211,8 +226,11 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: Pars
...childNode,
children: [{
kind: 'text',
text: textValue(String(value.value))
}]
text: textValue(String(value.value)),
sourceRange: computeRange(value, value),
}],
sourceRange: computeRange(key, key),
subtreeSourceRange: computeRange(key, value),
});
continue;
}
Expand All @@ -222,6 +240,8 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: Pars
const valueIsSequence = value instanceof yaml.YAMLSeq;
if (valueIsSequence) {
container.children.push(childNode);
childNode.sourceRange = computeRange(key, key);
childNode.subtreeSourceRange = computeRange(key, value);
convertSeq(childNode, value as yamlTypes.YAMLSeq);
continue;
}
Expand All @@ -233,7 +253,13 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: Pars
}
};

const fragment: AriaTemplateNode = { kind: 'role', role: 'fragment' };
const emptyRange: WithYamlRange = { range: [0, 0, 0] }; // Fragment has no "self" source, only subtree.
const fragment: AriaTemplateNode = {
kind: 'role',
role: 'fragment',
sourceRange: computeRange(emptyRange, emptyRange),
subtreeSourceRange: computeRange(emptyRange, yamlDoc),
};

yamlDoc.errors.forEach(addError);
if (errors.length)
Expand Down Expand Up @@ -272,13 +298,14 @@ export function textValue(value: string): AriaTextValue {
}

export class KeyParser {
private _options: ParsingOptions;
private _input: string;
private _pos: number;
private _length: number;

static parse(text: yamlTypes.Scalar<string>, options: ParsingOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null {
try {
return new KeyParser(text.value)._parse();
return new KeyParser(text.value, options)._parse();
} catch (e) {
if (e instanceof ParserError) {
const message = options.prettyErrors === false ? e.message : e.message + ':\n\n' + text.value + '\n' + ' '.repeat(e.pos) + '^\n';
Expand All @@ -292,7 +319,8 @@ export class KeyParser {
}
}

constructor(input: string) {
constructor(input: string, options: ParsingOptions) {
this._options = options;
this._input = input;
this._pos = 0;
this._length = input.length;
Expand Down Expand Up @@ -475,6 +503,11 @@ export class KeyParser {
node.selected = value === 'true';
return;
}
if (this._options.laxProps) {
node.props = node.props || {};
node.props[key] = textValue(value);
return;
}
this._assert(false, `Unsupported attribute [${key}]`, errorPos);
}

Expand All @@ -492,3 +525,69 @@ export class ParserError extends Error {
this.pos = pos;
}
}

type AriaSnapshotDiffResult = 'equal' | 'different' | { ref: string, newSource: string }[];

export function diffAriaSnapshots(yaml: YamlLibrary, oldSnapshot: string, newSnapshot: string): AriaSnapshotDiffResult {
const diffTree = (oldNode: AriaTemplateNode, newNode: AriaTemplateNode): AriaSnapshotDiffResult => {
if (!oldNode.sourceRange || !newNode.sourceRange)
return 'different';

const oldSelfSource = oldSnapshot.slice(oldNode.sourceRange.from, oldNode.sourceRange.to);
const newSelfSource = newSnapshot.slice(newNode.sourceRange.from, newNode.sourceRange.to);
if (oldNode.kind !== 'role' || newNode.kind !== 'role')
return (oldNode.kind === newNode.kind && oldSelfSource === newSelfSource) ? 'equal' : 'different';

const newNodeSubtreeSourceRange = newNode.subtreeSourceRange || newNode.sourceRange;
const newSubtreeSource = newSnapshot.slice(newNodeSubtreeSourceRange.from, newNodeSubtreeSourceRange.to);

const oldChildren = oldNode.children || [];
const newChildren = newNode.children || [];
const childrenDiffs = [];
// When "self" is the same, we can diff children and try to find a small diff there.
let useChildrenDiffs = oldSelfSource === newSelfSource && oldChildren.length === newChildren.length;
let childrenTotalLength = 0;

if (useChildrenDiffs) {
for (let i = 0; i < oldChildren.length; i++) {
const childDiff = diffTree(oldChildren[i], newChildren[i]);
if (childDiff === 'equal')
continue;
if (childDiff === 'different') {
useChildrenDiffs = false;
break;
}
for (const diff of childDiff) {
childrenTotalLength += diff.newSource.length;
childrenDiffs.push(diff);
}
}
}

if (childrenDiffs.length > 1 && childrenTotalLength * 2 >= newSubtreeSource.length) {
// Too many children diffs without too much of a benefit.
useChildrenDiffs = false;
}

if (useChildrenDiffs) {
if (!childrenDiffs.length)
return 'equal';
return childrenDiffs;
}

const oldRef = oldNode.props?.ref?.raw;
const newRef = newNode.props?.ref?.raw;
if (!oldRef || oldRef !== newRef)
return 'different';

return [{ ref: oldRef, newSource: newSubtreeSource }];
};

try {
const oldParsed = parseAriaSnapshotUnsafe(yaml, oldSnapshot, { laxProps: true });
const newParsed = parseAriaSnapshotUnsafe(yaml, newSnapshot, { laxProps: true });
return diffTree(oldParsed, newParsed);
} catch {
return 'different';
}
}
27 changes: 16 additions & 11 deletions packages/playwright/src/mcp/browser/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class Response {
private _code: string[] = [];
private _images: { contentType: string, data: Buffer }[] = [];
private _context: Context;
private _includeSnapshot = false;
private _includeSnapshot: 'full' | 'partial' | 'none' = 'none';
private _includeTabs = false;
private _tabSnapshot: TabSnapshot | undefined;

Expand Down Expand Up @@ -72,8 +72,8 @@ export class Response {
return this._images;
}

setIncludeSnapshot() {
this._includeSnapshot = true;
setIncludeSnapshot(full?: 'full') {
this._includeSnapshot = full ?? 'partial';
}

setIncludeTabs() {
Expand All @@ -83,7 +83,7 @@ export class Response {
async finish() {
// All the async snapshotting post-action is happening here.
// Everything below should race against modal states.
if (this._includeSnapshot && this._context.currentTab())
if (this._includeSnapshot !== 'none' && this._context.currentTab())
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
for (const tab of this._context.tabs())
await tab.updateTitle();
Expand Down Expand Up @@ -113,15 +113,15 @@ ${this._code.join('\n')}
}

// List browser tabs.
if (this._includeSnapshot || this._includeTabs)
if (this._includeSnapshot !== 'none' || this._includeTabs)
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));

// Add snapshot if provided.
if (this._tabSnapshot?.modalStates.length) {
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
response.push('');
} else if (this._tabSnapshot) {
response.push(renderTabSnapshot(this._tabSnapshot));
response.push(renderTabSnapshot(this._tabSnapshot, this._includeSnapshot === 'full'));
response.push('');
}

Expand Down Expand Up @@ -153,7 +153,7 @@ ${this._code.join('\n')}
}
}

function renderTabSnapshot(tabSnapshot: TabSnapshot): string {
function renderTabSnapshot(tabSnapshot: TabSnapshot, fullSnapshot: boolean): string {
const lines: string[] = [];

if (tabSnapshot.consoleMessages.length) {
Expand All @@ -177,10 +177,15 @@ function renderTabSnapshot(tabSnapshot: TabSnapshot): string {
lines.push(`### Page state`);
lines.push(`- Page URL: ${tabSnapshot.url}`);
lines.push(`- Page Title: ${tabSnapshot.title}`);
lines.push(`- Page Snapshot:`);
lines.push('```yaml');
lines.push(tabSnapshot.ariaSnapshot);
lines.push('```');
if (!fullSnapshot && tabSnapshot.formattedAriaSnapshotDiff) {
lines.push(`- Page Snapshot Diff:`);
lines.push(tabSnapshot.formattedAriaSnapshotDiff);
} else {
lines.push(`- Page Snapshot:`);
lines.push('```yaml');
lines.push(tabSnapshot.ariaSnapshot);
lines.push('```');
}

return lines.join('\n');
}
Expand Down
40 changes: 38 additions & 2 deletions packages/playwright/src/mcp/browser/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@

import { EventEmitter } from 'events';
import * as playwright from 'playwright-core';
import { ManualPromise } from 'playwright-core/lib/utils';
import { ManualPromise, diffAriaSnapshots } from 'playwright-core/lib/utils';
import { yaml } from 'playwright-core/lib/utilsBundle';

import { callOnPageNoTrace, waitForCompletion } from './tools/utils';
import { logUnhandledError } from '../log';
Expand All @@ -40,6 +41,7 @@ export type TabSnapshot = {
url: string;
title: string;
ariaSnapshot: string;
formattedAriaSnapshotDiff?: string;
modalStates: ModalState[];
consoleMessages: ConsoleMessage[];
downloads: { download: playwright.Download, finished: boolean, outputFile: string }[];
Expand All @@ -55,6 +57,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
private _onPageClose: (tab: Tab) => void;
private _modalStates: ModalState[] = [];
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
private _lastAriaSnapshot: string | undefined;

constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
super();
Expand All @@ -63,7 +66,14 @@ export class Tab extends EventEmitter<TabEventsInterface> {
this._onPageClose = onPageClose;
page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event)));
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
page.on('request', request => this._requests.set(request, null));
page.on('request', request => {
this._requests.set(request, null);
// Note: unfortunately, 'framenavigated' event does not differentiate between
// a same-document (#hash or pushState) navigation and a new document being loaded.
// Relying upon a navigation request heuristic.
if (request.frame() === page.mainFrame() && request.isNavigationRequest())
this._willNavigateMainFrameToNewDocument();
});
page.on('response', response => this._requests.set(response.request(), response));
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
Expand Down Expand Up @@ -140,6 +150,10 @@ export class Tab extends EventEmitter<TabEventsInterface> {
this._onPageClose(this);
}

private _willNavigateMainFrameToNewDocument() {
this._lastAriaSnapshot = undefined;
}

async updateTitle() {
await this._raceAgainstModalStates(async () => {
this._lastTitle = await callOnPageNoTrace(this.page, page => page.title());
Expand All @@ -160,6 +174,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {

async navigate(url: string) {
this._clearCollectedArtifacts();
this._willNavigateMainFrameToNewDocument();

const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError));
try {
Expand Down Expand Up @@ -203,6 +218,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
url: this.page.url(),
title: await this.page.title(),
ariaSnapshot: snapshot,
formattedAriaSnapshotDiff: this._lastAriaSnapshot ? generateAriaSnapshotDiff(this._lastAriaSnapshot, snapshot) : undefined,
modalStates: [],
consoleMessages: [],
downloads: this._downloads,
Expand All @@ -212,6 +228,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
// Assign console message late so that we did not lose any to modal state.
tabSnapshot.consoleMessages = this._recentConsoleMessages;
this._recentConsoleMessages = [];
this._lastAriaSnapshot = tabSnapshot.ariaSnapshot;
}
return tabSnapshot ?? {
url: this.page.url(),
Expand Down Expand Up @@ -312,3 +329,22 @@ export function renderModalStates(context: Context, modalStates: ModalState[]):
}

const tabSymbol = Symbol('tabSymbol');

function generateAriaSnapshotDiff(oldSnapshot: string, newSnapshot: string) {
const diffs = diffAriaSnapshots(yaml, oldSnapshot, newSnapshot);
if (diffs === 'equal')
return '<no changes>';
if (diffs === 'different')
return;
if (diffs.length > 3 || diffs.some(diff => diff.newSource.split('\n').length > 100)) {
// Being conservative - up to 3 small diff changes, otherwise include full snapshot.
return;
}
const lines = [`The following refs have changed`];
for (const diff of diffs)
lines.push('', '```yaml', diff.newSource.trimEnd(), '```');
const combined = lines.join('\n');
if (combined.length >= newSnapshot.length)
return;
return combined;
}
2 changes: 1 addition & 1 deletion packages/playwright/src/mcp/browser/tools/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const snapshot = defineTool({

handle: async (context, params, response) => {
await context.ensureTab();
response.setIncludeSnapshot();
response.setIncludeSnapshot('full');
},
});

Expand Down
Loading
Loading