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
45 changes: 40 additions & 5 deletions packages/playwright-core/src/tools/backend/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
Expand Down Expand Up @@ -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]))
Expand All @@ -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<number>();
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;
Expand All @@ -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,
];
86 changes: 86 additions & 0 deletions tests/mcp/find.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,92 @@ test('browser_find by text', async ({ client, server }) => {
});
});

const nestedPage = `
<main>
<section aria-label="Sidebar">
<nav aria-label="Primary">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
<li><a href="/careers">Careers</a></li>
<li><a href="/target">Deep Target Link</a></li>
</ul>
</nav>
</section>
</main>
`;

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 = `
<main>
<div role="group" aria-label="Toolbar">
<button>One</button>
<button>Two</button>
<button>Three</button>
<button>Four</button>
</div>
<div role="group" aria-label="Content">
<button>Target Button</button>
</div>
</main>
`;

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 } });
Expand Down
Loading