diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index a1e1afd4256af..57e4c689f585c 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -495,21 +495,36 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: return results; } -export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions): string { +function buildByRefMap(root: AriaNode | undefined, map: Map = new Map()): Map { + if (root?.ref) + map.set(root.ref, root); + for (const child of root?.children || []) { + if (typeof child !== 'string') + buildByRefMap(child, map); + } + return map; +} + +function arePropsEqual(a: AriaNode, b: AriaNode): boolean { + const aKeys = Object.keys(a.props); + const bKeys = Object.keys(b.props); + return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]); +} + +export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previous?: AriaSnapshot): string { const options = toInternalOptions(publicOptions); const lines: string[] = []; const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true; const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str; - const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string, renderCursorPointer: boolean) => { - if (typeof ariaNode === 'string') { - if (parentAriaNode && !includeText(parentAriaNode, ariaNode)) - return; - const text = yamlEscapeValueIfNeeded(renderString(ariaNode)); - if (text) - lines.push(indent + '- text: ' + text); - return; - } + const previousByRef = buildByRefMap(previous?.root); + const visitText = (text: string, indent: string) => { + const escaped = yamlEscapeValueIfNeeded(renderString(text)); + if (escaped) + lines.push(indent + '- text: ' + escaped); + }; + + const createKey = (ariaNode: AriaNode, renderCursorPointer: boolean): string => { let key = ariaNode.role; // Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes. if (ariaNode.name && ariaNode.name.length <= 900) { @@ -538,41 +553,84 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr if (ariaNode.selected === true) key += ` [selected]`; - let inCursorPointer = false; if (ariaNode.ref) { key += ` [ref=${ariaNode.ref}]`; - if (renderCursorPointer && hasPointerCursor(ariaNode)) { - inCursorPointer = true; + if (renderCursorPointer && hasPointerCursor(ariaNode)) key += ' [cursor=pointer]'; - } } + return key; + }; + + const getSingleInlinedTextChild = (ariaNode: AriaNode | undefined): string | undefined => { + return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined; + }; + const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean, previousNode: AriaNode | undefined): { unchanged: boolean } => { + if (ariaNode.ref) + previousNode = previousByRef.get(ariaNode.ref); + + const linesBefore = lines.length; + const key = createKey(ariaNode, renderCursorPointer); const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key); - const hasProps = !!Object.keys(ariaNode.props).length; - if (!ariaNode.children.length && !hasProps) { + const inCursorPointer = renderCursorPointer && !!ariaNode.ref && hasPointerCursor(ariaNode); + const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode); + + // Whether ariaNode's subtree is the same as previousNode's, and can be replaced with just a ref. + let unchanged = !!previousNode && key === createKey(previousNode, renderCursorPointer) && arePropsEqual(ariaNode, previousNode); + + if (!ariaNode.children.length && !Object.keys(ariaNode.props).length) { + // Leaf node without children. lines.push(escapedKey); - } else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' && !hasProps) { - const text = includeText(ariaNode, ariaNode.children[0]) ? renderString(ariaNode.children[0] as string) : null; - if (text) - lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(text)); + } else if (singleInlinedTextChild !== undefined) { + // Leaf node with just some text inside. + // Unchanged when the previous node also had the same single text child. + unchanged = unchanged && getSingleInlinedTextChild(previousNode) === singleInlinedTextChild; + + const shouldInclude = includeText(ariaNode, singleInlinedTextChild); + if (shouldInclude) + lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild))); else lines.push(escapedKey); } else { + // Node with (optional) props and some children. lines.push(escapedKey + ':'); for (const [name, value] of Object.entries(ariaNode.props)) lines.push(indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value)); - for (const child of ariaNode.children || []) - visit(child, ariaNode, indent + ' ', renderCursorPointer && !inCursorPointer); + + // All children must be the same. + unchanged = unchanged && previousNode?.children.length === ariaNode.children.length; + + const childIndent = indent + ' '; + for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) { + const child = ariaNode.children[childIndex]; + if (typeof child === 'string') { + unchanged = unchanged && previousNode?.children[childIndex] === child; + if (includeText(ariaNode, child)) + visitText(child, childIndent); + } else { + const previousChild = previousNode?.children[childIndex]; + const childResult = visit(child, childIndent, renderCursorPointer && !inCursorPointer, typeof previousChild !== 'string' ? previousChild : undefined); + unchanged = unchanged && childResult.unchanged; + } + } } + + if (unchanged && ariaNode.ref) { + // Replace the whole subtree with a single reference. + lines.splice(linesBefore); + lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`); + } + + return { unchanged }; }; - const ariaNode = ariaSnapshot.root; - if (ariaNode.role === 'fragment') { - // Render fragment. - for (const child of ariaNode.children || []) - visit(child, ariaNode, '', !!options.renderCursorPointer); - } else { - visit(ariaNode, null, '', !!options.renderCursorPointer); + // Do not render the root fragment, just its children. + const nodesToRender = ariaSnapshot.root.role === 'fragment' ? ariaSnapshot.root.children : [ariaSnapshot.root]; + for (const nodeToRender of nodesToRender) { + if (typeof nodeToRender === 'string') + visitText(nodeToRender, ''); + else + visit(nodeToRender, '', !!options.renderCursorPointer, undefined); } return lines.join('\n'); } @@ -636,5 +694,5 @@ function textContributesInfo(node: AriaNode, text: string): boolean { } function hasPointerCursor(ariaNode: AriaNode): boolean { - return ariaNode.box.style?.cursor === 'pointer'; + return ariaNode.box.cursor === 'pointer'; } diff --git a/packages/injected/src/domUtils.ts b/packages/injected/src/domUtils.ts index eb2b6a25f5a0f..78381bb409c93 100644 --- a/packages/injected/src/domUtils.ts +++ b/packages/injected/src/domUtils.ts @@ -112,7 +112,10 @@ export type Box = { visible: boolean; inline: boolean; rect?: DOMRect; - style?: CSSStyleDeclaration; + // Note: we do not store the CSSStyleDeclaration object, because it is a live object + // and changes values over time. This does not work for caching or comparing to the + // old values. Instead, store all the properties separately. + cursor?: CSSStyleDeclaration['cursor']; }; export function computeBox(element: Element): Box { @@ -120,20 +123,21 @@ export function computeBox(element: Element): Box { const style = getElementComputedStyle(element); if (!style) return { visible: true, inline: false }; + const cursor = style.cursor; if (style.display === 'contents') { // display:contents is not rendered itself, but its child nodes are. for (let child = element.firstChild; child; child = child.nextSibling) { if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && isElementVisible(child as Element)) - return { visible: true, inline: false, style }; + return { visible: true, inline: false, cursor }; if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text)) - return { visible: true, inline: true, style }; + return { visible: true, inline: true, cursor }; } - return { visible: false, inline: false, style }; + return { visible: false, inline: false, cursor }; } if (!isElementStyleVisibilityVisible(element, style)) - return { style, visible: false, inline: false }; + return { cursor, visible: false, inline: false }; const rect = element.getBoundingClientRect(); - return { rect, style, visible: rect.width > 0 && rect.height > 0, inline: style.display === 'inline' }; + return { rect, cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === 'inline' }; } export function isElementVisible(element: Element): boolean { diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 3ed79366a9aa5..a204af76c4458 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -92,7 +92,8 @@ export class InjectedScript { readonly window: Window & typeof globalThis; readonly document: Document; readonly consoleApi: ConsoleAPI; - private _lastAriaSnapshot: AriaSnapshot | undefined; + private _lastAriaSnapshotForTrack = new Map(); + private _lastAriaSnapshotForQuery: AriaSnapshot | undefined; // Recorder must use any external dependencies through InjectedScript. // Otherwise it will end up with a copy of all modules it uses, and any @@ -299,11 +300,18 @@ export class InjectedScript { return new Set(result.map(r => r.element)); } - ariaSnapshot(node: Node, options: AriaTreeOptions): string { + ariaSnapshot(node: Node, options: AriaTreeOptions & { track?: string, incremental?: boolean }): string { if (node.nodeType !== Node.ELEMENT_NODE) throw this.createStacklessError('Can only capture aria snapshot of Element nodes.'); - this._lastAriaSnapshot = generateAriaTree(node as Element, options); - return renderAriaTree(this._lastAriaSnapshot, options); + const ariaSnapshot = generateAriaTree(node as Element, options); + let previous: AriaSnapshot | undefined; + if (options.incremental) + previous = options.track ? this._lastAriaSnapshotForTrack.get(options.track) : this._lastAriaSnapshotForQuery; + const result = renderAriaTree(ariaSnapshot, options, previous); + if (options.track) + this._lastAriaSnapshotForTrack.set(options.track, ariaSnapshot); + this._lastAriaSnapshotForQuery = ariaSnapshot; + return result; } ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map } { @@ -692,7 +700,7 @@ export class InjectedScript { _createAriaRefEngine() { const queryAll = (root: SelectorRoot, selector: string): Element[] => { - const result = this._lastAriaSnapshot?.elements?.get(selector); + const result = this._lastAriaSnapshotForQuery?.elements?.get(selector); return result && result.isConnected ? [result] : []; }; return { queryAll }; diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 739231637fbff..c0edc605f0f39 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -847,8 +847,8 @@ export class Page extends ChannelOwner implements api.Page return result.pdf; } - async _snapshotForAI(options: TimeoutOptions = {}): Promise { - const result = await this._channel.snapshotForAI({ timeout: this._timeoutSettings.timeout(options) }); + async _snapshotForAI(options: TimeoutOptions & { track?: string, mode?: 'full' | 'incremental' } = {}): Promise { + const result = await this._channel.snapshotForAI({ timeout: this._timeoutSettings.timeout(options), track: options.track, mode: options.mode }); return result.snapshot; } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index b8e7c32993dc1..83c27272842d6 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1465,6 +1465,8 @@ scheme.PageRequestsResult = tObject({ requests: tArray(tChannel(['Request'])), }); scheme.PageSnapshotForAIParams = tObject({ + track: tOptional(tString), + mode: tOptional(tEnum(['full', 'incremental'])), timeout: tFloat, }); scheme.PageSnapshotForAIResult = tObject({ diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 97443690fac55..8ca3682e3df53 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -352,7 +352,7 @@ export class PageDispatcher extends Dispatcher { - return { snapshot: await this._page.snapshotForAI(progress) }; + return { snapshot: await this._page.snapshotForAI(progress, params) }; } async bringToFront(params: channels.PageBringToFrontParams, progress: Progress): Promise { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index fd5a9fa9a2a76..1ad565ab0bba5 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -859,9 +859,9 @@ export class Page extends SdkObject { await Promise.all(this.frames().map(frame => frame.hideHighlight().catch(() => {}))); } - async snapshotForAI(progress: Progress): Promise { + async snapshotForAI(progress: Progress, options: { track?: string, mode?: 'full' | 'incremental' }): Promise { this.lastSnapshotFrameIds = []; - const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), 0, this.lastSnapshotFrameIds); + const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), 0, this.lastSnapshotFrameIds, options); return snapshot.join('\n'); } } @@ -1037,18 +1037,18 @@ class FrameThrottler { } } -async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frameOrdinal: number, frameIds: string[]): Promise { +async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frameOrdinal: number, frameIds: string[], options: { track?: string, mode?: 'full' | 'incremental' }): Promise { // Only await the topmost navigations, inner frames will be empty when racing. const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => { try { const context = await progress.race(frame._utilityContext()); const injectedScript = await progress.race(context.injectedScript()); - const snapshotOrRetry = await progress.race(injectedScript.evaluate((injected, refPrefix) => { + const snapshotOrRetry = await progress.race(injectedScript.evaluate((injected, options) => { const node = injected.document.body; if (!node) return true; - return injected.ariaSnapshot(node, { mode: 'ai', refPrefix }); - }, frameOrdinal ? 'f' + frameOrdinal : '')); + return injected.ariaSnapshot(node, { mode: 'ai', ...options }); + }, { refPrefix: frameOrdinal ? 'f' + frameOrdinal : '', incremental: options.mode === 'incremental', track: options.track })); if (snapshotOrRetry === true) return continuePolling; return snapshotOrRetry; @@ -1080,7 +1080,7 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frame const frameOrdinal = frameIds.length + 1; frameIds.push(child.frame._id); try { - const childSnapshot = await snapshotFrameForAI(progress, child.frame, frameOrdinal, frameIds); + const childSnapshot = await snapshotFrameForAI(progress, child.frame, frameOrdinal, frameIds, options); result.push(line + ':', ...childSnapshot.map(l => leadingSpace + ' ' + l)); } catch { result.push(line); diff --git a/packages/playwright/src/mcp/browser/response.ts b/packages/playwright/src/mcp/browser/response.ts index bdf7d2a8821bb..a521291367397 100644 --- a/packages/playwright/src/mcp/browser/response.ts +++ b/packages/playwright/src/mcp/browser/response.ts @@ -28,7 +28,7 @@ export class Response { private _code: string[] = []; private _images: { contentType: string, data: Buffer }[] = []; private _context: Context; - private _includeSnapshot = false; + private _includeSnapshot: 'none' | 'full' | 'incremental' = 'none'; private _includeTabs = false; private _tabSnapshot: TabSnapshot | undefined; @@ -75,8 +75,8 @@ export class Response { return this._images; } - setIncludeSnapshot() { - this._includeSnapshot = true; + setIncludeSnapshot(full?: 'full') { + this._includeSnapshot = full ?? 'incremental'; } setIncludeTabs() { @@ -86,8 +86,8 @@ 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()) - this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot(); + if (this._includeSnapshot !== 'none' && this._context.currentTab()) + this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot(this._includeSnapshot); for (const tab of this._context.tabs()) await tab.updateTitle(); } @@ -126,7 +126,7 @@ ${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. diff --git a/packages/playwright/src/mcp/browser/tab.ts b/packages/playwright/src/mcp/browser/tab.ts index 2831c68491674..dbb56babd6ae5 100644 --- a/packages/playwright/src/mcp/browser/tab.ts +++ b/packages/playwright/src/mcp/browser/tab.ts @@ -217,10 +217,10 @@ export class Tab extends EventEmitter { return this._requests; } - async captureSnapshot(): Promise { + async captureSnapshot(mode: 'full' | 'incremental'): Promise { let tabSnapshot: TabSnapshot | undefined; const modalStates = await this._raceAgainstModalStates(async () => { - const snapshot = await this.page._snapshotForAI(); + const snapshot = await this.page._snapshotForAI({ mode, track: 'response' }); tabSnapshot = { url: this.page.url(), title: await this.page.title(), diff --git a/packages/playwright/src/mcp/browser/tools/snapshot.ts b/packages/playwright/src/mcp/browser/tools/snapshot.ts index e944bc403485a..33abe39621b3f 100644 --- a/packages/playwright/src/mcp/browser/tools/snapshot.ts +++ b/packages/playwright/src/mcp/browser/tools/snapshot.ts @@ -30,7 +30,7 @@ const snapshot = defineTool({ handle: async (context, params, response) => { await context.ensureTab(); - response.setIncludeSnapshot(); + response.setIncludeSnapshot('full'); }, }); diff --git a/packages/playwright/src/mcp/browser/tools/tabs.ts b/packages/playwright/src/mcp/browser/tools/tabs.ts index 768db50e50c39..6eec59d5acc13 100644 --- a/packages/playwright/src/mcp/browser/tools/tabs.ts +++ b/packages/playwright/src/mcp/browser/tools/tabs.ts @@ -45,14 +45,14 @@ const browserTabs = defineTool({ } case 'close': { await context.closeTab(params.index); - response.setIncludeSnapshot(); + response.setIncludeSnapshot('full'); return; } case 'select': { if (params.index === undefined) throw new Error('Tab index is required'); await context.selectTab(params.index); - response.setIncludeSnapshot(); + response.setIncludeSnapshot('full'); return; } } diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 66fef77a33bd1..867b77d13f213 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2548,10 +2548,13 @@ export type PageRequestsResult = { requests: RequestChannel[], }; export type PageSnapshotForAIParams = { + track?: string, + mode?: 'full' | 'incremental', timeout: number, }; export type PageSnapshotForAIOptions = { - + track?: string, + mode?: 'full' | 'incremental', }; export type PageSnapshotForAIResult = { snapshot: string, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 73aeba935bea2..cbb7d1060234a 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1977,6 +1977,13 @@ Page: snapshotForAI: internal: true parameters: + track: string? + mode: + # defaults to "full" + type: enum? + literals: + - full + - incremental timeout: float returns: snapshot: string diff --git a/tests/mcp/click.spec.ts b/tests/mcp/click.spec.ts index 0335ccf8c9691..8e64ad5c3cd70 100644 --- a/tests/mcp/click.spec.ts +++ b/tests/mcp/click.spec.ts @@ -16,10 +16,16 @@ import { test, expect } from './fixtures'; -test('browser_click', async ({ client, server, mcpBrowser }) => { +test('browser_click', async ({ client, server }) => { server.setContent('/', ` Title + `, 'text/html'); await client.callTool({ @@ -35,7 +41,7 @@ test('browser_click', async ({ client, server, mcpBrowser }) => { }, })).toHaveResponse({ code: `await page.getByRole('button', { name: 'Submit' }).click();`, - pageState: expect.stringContaining(`- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]`), + pageState: expect.stringContaining(`- button "Submit" [active] [ref=e2]`), }); }); diff --git a/tests/mcp/core.spec.ts b/tests/mcp/core.spec.ts index 033a212245d5d..13dde2ba8420c 100644 --- a/tests/mcp/core.spec.ts +++ b/tests/mcp/core.spec.ts @@ -90,8 +90,6 @@ test('browser_select_option (multiple)', async ({ client, server }) => { })).toHaveResponse({ code: `await page.getByRole('listbox').selectOption(['bar', 'baz']);`, pageState: expect.stringContaining(` -- listbox [ref=e2]: - - option "Foo" [ref=e3] - option "Bar" [selected] [ref=e4] - option "Baz" [selected] [ref=e5]`), }); diff --git a/tests/mcp/dialogs.spec.ts b/tests/mcp/dialogs.spec.ts index 4c2bf98eb5025..548a33d608617 100644 --- a/tests/mcp/dialogs.spec.ts +++ b/tests/mcp/dialogs.spec.ts @@ -17,7 +17,7 @@ import { test, expect } from './fixtures'; test('alert dialog', async ({ client, server }) => { - server.setContent('/', ``, 'text/html'); + server.setContent('/', `Title`, 'text/html'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, @@ -54,7 +54,7 @@ test('alert dialog', async ({ client, server }) => { }, })).toHaveResponse({ modalState: undefined, - pageState: expect.stringContaining(`- button "Button"`), + pageState: expect.stringContaining(`Page Title: Title`), }); }); @@ -218,7 +218,7 @@ test('prompt dialog', async ({ client, server }) => { }); test('alert dialog w/ race', async ({ client, server }) => { - server.setContent('/', ``, 'text/html'); + server.setContent('/', `Title`, 'text/html'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, @@ -247,9 +247,7 @@ test('alert dialog w/ race', async ({ client, server }) => { expect(result).toHaveResponse({ modalState: undefined, pageState: expect.stringContaining(`- Page URL: ${server.PREFIX}/ -- Page Title: -- Page Snapshot: -\`\`\`yaml -- button "Button"`), +- Page Title: Title +- Page Snapshot:`), }); }); diff --git a/tests/mcp/session-log.spec.ts b/tests/mcp/session-log.spec.ts index 6f9a98ee66cb6..22f17566a4408 100644 --- a/tests/mcp/session-log.spec.ts +++ b/tests/mcp/session-log.spec.ts @@ -42,7 +42,7 @@ test('session log should record tool calls', async ({ startClient, server }, tes }, })).toHaveResponse({ code: `await page.getByRole('button', { name: 'Submit' }).click();`, - pageState: expect.stringContaining(`- button "Submit"`), + pageState: expect.stringContaining(`Page Title: Title`), }); const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0]; diff --git a/tests/mcp/snapshot-diff.spec.ts b/tests/mcp/snapshot-diff.spec.ts new file mode 100644 index 0000000000000..4c4ffdae1ee64 --- /dev/null +++ b/tests/mcp/snapshot-diff.spec.ts @@ -0,0 +1,158 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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 './fixtures'; + +test('should return aria snapshot diff', async ({ client, server }) => { + server.setContent('/', ` + + +
    + + `, 'text/html'); + + const listitems = new Array(100).fill(0).map((_, i) => `\n - listitem [ref=e${5 + i}]: Filler ${i}`).join(''); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + })).toHaveResponse({ + pageState: expect.stringContaining(` + - button "Button 1" [ref=e2] + - button "Button 2" [active] [ref=e3] + - list [ref=e4]:${listitems}`), + }); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Button 2', + ref: 'e3', + }, + })).toHaveResponse({ + pageState: expect.stringContaining(`Page Snapshot: +\`\`\`yaml +- ref=e1 [unchanged] +\`\`\``), + }); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Button 1', + ref: 'e2', + }, + })).toHaveResponse({ + pageState: expect.stringContaining(`Page Snapshot: +\`\`\`yaml +- generic [ref=e1]: + - button "Button 1" [active] [ref=e2] + - button "Button 2new text" [ref=e105] + - ref=e4 [unchanged] +\`\`\``), + }); + + // browser_snapshot forces a full snapshot. + expect(await client.callTool({ + name: 'browser_snapshot', + })).toHaveResponse({ + pageState: expect.stringContaining(`Page Snapshot: +\`\`\`yaml +- generic [ref=e1]: + - button "Button 1" [active] [ref=e2] + - button "Button 2new text" [ref=e105] + - list [ref=e4]:${listitems} +\`\`\``), + }); +}); + +test('should reset aria snapshot diff upon navigation', async ({ client, server }) => { + server.setContent('/before', ` + + +
      + + `, 'text/html'); + + server.setContent('/after', ` + + +
        + + `, 'text/html'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX + '/before', + }, + })).toHaveResponse({ + pageState: expect.stringContaining(` + - button "Button 1" [ref=e2] + - button "Button 2" [active] [ref=e3]`), + }); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Button 1', + ref: 'e2', + }, + })).toHaveResponse({ + pageState: expect.stringContaining(` + - button "Button 1" [ref=e2] + - button "Button 2" [active] [ref=e3]`), + }); +}); diff --git a/tests/mcp/wait.spec.ts b/tests/mcp/wait.spec.ts index 0388faf259b57..e844eac4ea89d 100644 --- a/tests/mcp/wait.spec.ts +++ b/tests/mcp/wait.spec.ts @@ -31,9 +31,11 @@ test('browser_wait_for(text)', async ({ client, server }) => { `, 'text/html'); - await client.callTool({ + expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [ref=e3]: Text to disappear`), }); await client.callTool({ @@ -44,10 +46,14 @@ test('browser_wait_for(text)', async ({ client, server }) => { }, }); - expect(await client.callTool({ + await client.callTool({ name: 'browser_wait_for', arguments: { text: 'Text to appear' }, code: `await page.getByText("Text to appear").first().waitFor({ state: 'visible' });`, + }); + + expect(await client.callTool({ + name: 'browser_snapshot', })).toHaveResponse({ pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`), }); @@ -68,9 +74,11 @@ test('browser_wait_for(textGone)', async ({ client, server }) => { `, 'text/html'); - await client.callTool({ + expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [ref=e3]: Text to disappear`), }); await client.callTool({ @@ -81,10 +89,14 @@ test('browser_wait_for(textGone)', async ({ client, server }) => { }, }); - expect(await client.callTool({ + await client.callTool({ name: 'browser_wait_for', arguments: { textGone: 'Text to disappear' }, code: `await page.getByText("Text to disappear").first().waitFor({ state: 'hidden' });`, + }); + + expect(await client.callTool({ + name: 'browser_snapshot', })).toHaveResponse({ pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`), }); diff --git a/tests/page/page-aria-snapshot-ai.spec.ts b/tests/page/page-aria-snapshot-ai.spec.ts index 982cc120cb6cf..0e9b8e907c5ff 100644 --- a/tests/page/page-aria-snapshot-ai.spec.ts +++ b/tests/page/page-aria-snapshot-ai.spec.ts @@ -19,7 +19,7 @@ import { asLocator } from 'playwright-core/lib/utils'; import { test as it, expect, unshift } from './pageTest'; -function snapshotForAI(page: any, options?: { timeout?: number }): Promise { +function snapshotForAI(page: any, options?: { timeout?: number, mode?: 'full' | 'incremental', track?: string }): Promise { return page._snapshotForAI(options); } @@ -448,3 +448,149 @@ it('should not remove generic nodes with title', async ({ page }) => { - generic "Element title" [ref=e2] `); }); + +it('should create incremental snapshots on multiple tracks', async ({ page }) => { + await page.setContent(`
        • a span
        `); + + expect(await snapshotForAI(page, { track: 'first', mode: 'incremental' })).toContainYaml(` + - list [ref=e2]: + - listitem [ref=e3]: + - button "a button" [ref=e4] + - listitem [ref=e5]: a span + `); + expect(await snapshotForAI(page, { track: 'second', mode: 'incremental' })).toContainYaml(` + - list [ref=e2]: + - listitem [ref=e3]: + - button "a button" [ref=e4] + - listitem [ref=e5]: a span + `); + expect(await snapshotForAI(page, { track: 'first', mode: 'incremental' })).toContainYaml(` + - ref=e2 [unchanged] + `); + + await page.evaluate(() => { + document.querySelector('span').textContent = 'changed span'; + document.getElementById('hidden-li').style.display = 'inline'; + }); + expect(await snapshotForAI(page, { track: 'first', mode: 'incremental' })).toContainYaml(` + - list [ref=e2]: + - ref=e3 [unchanged] + - listitem [ref=e5]: changed span + - listitem [ref=e6]: some text + `); + + await page.evaluate(() => { + document.querySelector('span').textContent = 'a span'; + document.getElementById('hidden-li').style.display = 'none'; + }); + expect(await snapshotForAI(page, { track: 'first', mode: 'incremental' })).toContainYaml(` + - list [ref=e2]: + - ref=e3 [unchanged] + - listitem [ref=e5]: a span + `); + expect(await snapshotForAI(page, { track: 'second', mode: 'incremental' })).toContainYaml(` + - ref=e2 [unchanged] + `); + + expect(await snapshotForAI(page, { track: 'second', mode: 'full' })).toContainYaml(` + - list [ref=e2]: + - listitem [ref=e3]: + - button "a button" [ref=e4] + - listitem [ref=e5]: a span + `); +}); + +it('should create incremental snapshot for attribute change', async ({ page }) => { + await page.setContent(``); + await page.evaluate(() => document.querySelector('button').focus()); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - button "a button" [active] [ref=e2] + `); + + await page.evaluate(() => document.querySelector('button').blur()); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - button "a button" [ref=e2] + `); +}); + +it('should create incremental snapshot for child removal', async ({ page }) => { + await page.setContent(`
      • some text
      • `); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - listitem [ref=e2]: + - button "a button" [ref=e3] + - text: some text + `); + + await page.evaluate(() => document.querySelector('span').remove()); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - listitem [ref=e2]: + - ref=e3 [unchanged] + `); +}); + +it('should create incremental snapshot for child addition', async ({ page }) => { + await page.setContent(`
      • some text
      • `); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - listitem [ref=e2]: + - button "a button" [ref=e3] + `); + + await page.evaluate(() => document.querySelector('span').style.display = 'inline'); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - listitem [ref=e2]: + - ref=e3 [unchanged] + - text: some text + `); +}); + +it('should create incremental snapshot for prop change', async ({ page }) => { + await page.setContent(`a link`); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - link "a link" [ref=e2] [cursor=pointer]: + - /url: about:blank + `); + + await page.evaluate(() => document.querySelector('a').setAttribute('href', 'https://playwright.dev')); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - link "a link" [ref=e2] [cursor=pointer]: + - /url: https://playwright.dev + `); +}); + +it('should create incremental snapshot for cursor change', async ({ page }) => { + await page.setContent(`a link`); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - link "a link" [ref=e2] [cursor=pointer]: + - /url: about:blank + `); + + await page.evaluate(() => document.querySelector('a').style.cursor = 'default'); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - link "a link" [ref=e2]: + - /url: about:blank + `); +}); + +it('should create incremental snapshot for name change', async ({ page }) => { + await page.setContent(``); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - button "a button" [ref=e2] + `); + + await page.evaluate(() => document.querySelector('span').textContent = 'new button'); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - button "new button" [ref=e3] + `); +}); + +it('should create incremental snapshot for text change', async ({ page }) => { + await page.setContent(`
      • an item
      • `); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - listitem [ref=e2]: an item + `); + + await page.evaluate(() => document.querySelector('span').textContent = 'new text'); + expect(await snapshotForAI(page, { mode: 'incremental' })).toContainYaml(` + - listitem [ref=e2]: new text + `); +});