diff --git a/packages/playwright-core/src/tools/backend/find.ts b/packages/playwright-core/src/tools/backend/find.ts index 703dd0e3a9e36..c29b01dfbf9f6 100644 --- a/packages/playwright-core/src/tools/backend/find.ts +++ b/packages/playwright-core/src/tools/backend/find.ts @@ -26,7 +26,7 @@ const find = defineTabTool({ schema: { name: 'browser_find', title: 'Find in page snapshot', - description: 'Search the accessibility snapshot of the current page for text or a regular expression. Returns matching snapshot nodes with a few lines of surrounding context (like search snippets), which is cheaper than capturing the whole snapshot when you only need to locate an element and its ref.', + description: 'Search the accessibility snapshot of the current page for text or a regular expression. Returns matching snapshot nodes with a few lines of surrounding context (like search snippets), each shown under its path from the root of the tree, which is cheaper than capturing the whole snapshot when you only need to locate an element and its ref.', inputSchema: z.object({ text: z.string().optional().describe('Plain text to search for in the page snapshot (case-insensitive substring match). Provide either text or regex, not both.'), regex: z.string().optional().refine(v => !v || isValidRegex(v), { message: 'Invalid regular expression' }).describe('Regular expression to search for in the page snapshot. Matching is case-sensitive by default; wrap the pattern in slashes to add flags, e.g. "/error/i" for case-insensitive. Provide either text or regex, not both.'), @@ -61,6 +61,7 @@ const find = defineTabTool({ const snapshot = await tab.page.ariaSnapshot({ mode: 'ai' }); const lines = snapshot.split('\n'); + const indents = lines.map(indentOf); const matchedLines: number[] = []; for (let i = 0; i < lines.length; i++) { if (matches(lines[i])) @@ -84,15 +85,31 @@ const find = defineTabTool({ windows.push({ start, end }); } - const snippets = windows.map(window => lines.slice(window.start, window.end + 1).join('\n')); + const path = new Set(); + for (const match of matchedLines) { + path.add(match); + for (const ancestor of ancestorIndices(lines, indents, match)) + path.add(ancestor); + } + + const snippets = windows.map(window => { + const indices = ancestorIndices(lines, indents, window.start); + for (let i = window.start; i <= window.end; i++) + indices.push(i); + const out: string[] = []; + for (let i = 0; i < indices.length; i++) { + const index = indices[i]; + if (i > 0 && index > indices[i - 1] + 1 && !path.has(index) && !path.has(indices[i - 1])) + out.push(' '.repeat(indents[index]) + '...'); + out.push(lines[index]); + } + return out.join('\n'); + }); const matchWord = matchedLines.length === 1 ? 'match' : 'matches'; response.addTextResult(`Found ${matchedLines.length} ${matchWord} for ${query}:\n\n${snippets.join('\n\n----\n\n')}`); }, }); -// Accept either a bare pattern or a `/pattern/flags` literal, mirroring the -// test runner's forceRegExp. Matching is line-oriented, so the global flag is -// dropped: it only makes `.test()` stateful without changing which lines match. function compileRegex(source: string): RegExp { const literal = /^\/(.*)\/([a-z]*)$/.exec(source); const pattern = literal ? literal[1] : source; @@ -109,6 +126,24 @@ function isValidRegex(source: string): boolean { } } +function indentOf(line: string): number { + return line.length - line.trimStart().length; +} + +function ancestorIndices(lines: string[], indents: number[], index: number): number[] { + const result: number[] = []; + let indent = indents[index]; + for (let i = index - 1; i >= 0 && indent > 0; i--) { + if (!lines[i].trim()) + continue; + if (indents[i] < indent) { + result.push(i); + indent = indents[i]; + } + } + return result.reverse(); +} + export default [ find, ]; diff --git a/tests/mcp/find.spec.ts b/tests/mcp/find.spec.ts index 4455cdbcd20f0..1b4ee9f93bc50 100644 --- a/tests/mcp/find.spec.ts +++ b/tests/mcp/find.spec.ts @@ -46,6 +46,92 @@ test('browser_find by text', async ({ client, server }) => { }); }); +const nestedPage = ` +
+
+ +
+
+`; + +test('browser_find shows the path from the root to the match', async ({ client, server }) => { + server.setContent('/', nestedPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + const response = await client.callTool({ + name: 'browser_find', + arguments: { text: 'Deep Target Link' }, + }); + + // The ancestor path is prepended even though it is far above the 3-line + // context window, so the matched node is shown in its place in the tree. The + // jump from the path down to the context window is implied, so it is not + // marked with an ellipsis. + expect(response).toHaveResponse({ + result: expect.stringContaining(`Found 1 match for "Deep Target Link": + +- main [ref=e2]: + - region "Sidebar" [ref=e3]: + - navigation "Primary" [ref=e4]: + - list [ref=e5]: + - listitem [ref=e14]:`), + }); + expect(response).toHaveResponse({ + result: expect.stringContaining(` - listitem [ref=e16]: + - link "Deep Target Link" [ref=e17]`), + }); + // The preceding sibling still appears as surrounding context. + expect(response).toHaveResponse({ + result: expect.stringContaining('Careers'), + }); +}); + +const toolbarPage = ` +
+
+ + + + +
+
+ +
+
+`; + +test('browser_find marks gaps within off-path context with an ellipsis', async ({ client, server }) => { + server.setContent('/', toolbarPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + // The match sits under "Content"; the "Toolbar" group is off-path context + // whose truncated buttons are marked with an ellipsis, while the path down to + // the match stays unmarked. + expect(await client.callTool({ + name: 'browser_find', + arguments: { text: 'Target Button' }, + })).toHaveResponse({ + result: expect.stringContaining(`Found 1 match for "Target Button": + +- main [ref=e2]: + - group "Toolbar" [ref=e3]: + ... + - button "Three" [ref=e6] + - button "Four" [ref=e7] + - group "Content" [ref=e8]: + - button "Target Button" [ref=e9]`), + }); +}); + test('browser_find is case-insensitive for text', async ({ client, server }) => { server.setContent('/', listPage, 'text/html'); await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });