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
114 changes: 114 additions & 0 deletions packages/playwright-core/src/tools/backend/find.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* 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 * as z from 'zod';

import { defineTabTool } from './tool';

// Number of context lines to show around each match, like `grep -C`.
const contextLines = 3;

const find = defineTabTool({
capability: 'core',
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.',
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.'),
}),
type: 'readOnly',
},

handle: async (tab, params, response) => {
if (!params.text && !params.regex) {
response.addError('Provide either "text" or "regex" to search for.');
return;
}
if (params.text && params.regex) {
response.addError('Provide only one of "text" or "regex", not both.');
return;
}

let query: string;
let matches: (line: string) => boolean;
if (params.regex) {
const re = compileRegex(params.regex);
query = String(re);
matches = line => {
re.lastIndex = 0;
return re.test(line);
};
} else {
query = `"${params.text}"`;
const needle = params.text!.toLowerCase();
matches = line => line.toLowerCase().includes(needle);
}

const snapshot = await tab.page.ariaSnapshot({ mode: 'ai' });
const lines = snapshot.split('\n');
const matchedLines: number[] = [];
for (let i = 0; i < lines.length; i++) {
if (matches(lines[i]))
matchedLines.push(i);
}

if (!matchedLines.length) {
response.addTextResult(`No matches found for ${query}.`);
return;
}

// Merge matched lines into windows of context, coalescing overlapping ones.
const windows: { start: number, end: number }[] = [];
for (const line of matchedLines) {
const start = Math.max(0, line - contextLines);
const end = Math.min(lines.length - 1, line + contextLines);
const last = windows[windows.length - 1];
if (last && start <= last.end + 1)
last.end = Math.max(last.end, end);
else
windows.push({ start, end });
}

const snippets = windows.map(window => lines.slice(window.start, window.end + 1).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;
const flags = literal ? literal[2].replace(/g/g, '') : '';
return new RegExp(pattern, flags);
}

function isValidRegex(source: string): boolean {
try {
compileRegex(source);
return true;
} catch {
return false;
}
}

export default [
find,
];
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/backend/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import devtools from './devtools';
import dialogs from './dialogs';
import evaluate from './evaluate';
import files from './files';
import find from './find';
import form from './form';
import keyboard from './keyboard';
import mouse from './mouse';
Expand Down Expand Up @@ -53,6 +54,7 @@ export const browserTools: Tool<any>[] = [
...dialogs,
...evaluate,
...files,
...find,
...form,
...keyboard,
...mouse,
Expand Down
10 changes: 10 additions & 0 deletions packages/playwright-core/src/tools/cli-client/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ playwright-cli upload ./document.pdf
playwright-cli check e12
playwright-cli uncheck e12
playwright-cli snapshot
# search the snapshot for text or a regexp, returns matching nodes with surrounding context
playwright-cli find "Sign in"
playwright-cli find --regex "Sign (in|up)"
# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive
playwright-cli find --regex "/sign (in|up)/i"
playwright-cli eval "document.title"
playwright-cli eval "el => el.textContent" e5
# get element id, class, or any attribute not visible in the snapshot
Expand Down Expand Up @@ -279,6 +284,11 @@ playwright-cli snapshot e34

# include each element's bounding box as [box=x,y,width,height]
playwright-cli snapshot --boxes

# search a large snapshot instead of capturing it all — returns matching nodes
# with 3 lines of context around each match (like grep -C)
playwright-cli find "Add to cart"
playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}"
```

## Targeting elements
Expand Down
15 changes: 15 additions & 0 deletions packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,20 @@ const snapshot = declareCommand({
toolParams: ({ filename, target, depth, boxes }) => ({ filename, target, depth, boxes }),
});

const find = declareCommand({
name: 'find',
description: 'Search the page snapshot for text or a regexp, returning matching nodes with surrounding context (like search snippets)',
category: 'core',
args: z.object({
text: z.string().optional().describe('Plain text to search for in the page snapshot (case-insensitive substring match)'),
}),
options: z.object({
regex: z.string().optional().describe('Regular expression to search for in the page snapshot. Provide either a text argument or --regex, not both.'),
}),
toolName: 'browser_find',
toolParams: ({ text, regex }) => ({ text, regex }),
});

const generateLocator = declareCommand({
name: 'generate-locator',
description: 'Generate a Playwright locator for the given element',
Expand Down Expand Up @@ -1149,6 +1163,7 @@ const commandsArray: AnyCommandSchema[] = [
check,
uncheck,
snapshot,
find,
evaluate,
consoleList,
dialogAccept,
Expand Down
1 change: 1 addition & 0 deletions tests/mcp/capabilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_evaluate',
'browser_file_upload',
'browser_fill_form',
'browser_find',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
Expand Down
60 changes: 60 additions & 0 deletions tests/mcp/cli-find.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* 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 './cli-fixtures';

const listPage = `
<h1>Groceries</h1>
<ul>
<li>Apples</li>
<li>Bananas</li>
<li>Cherries</li>
</ul>
`;

test('find by text', async ({ cli, server }) => {
server.setContent('/', listPage, 'text/html');
await cli('open', server.PREFIX);

const { output } = await cli('find', 'Bananas');
expect(output).toContain('Found 1 match for "Bananas":');
expect(output).toContain('Apples');
expect(output).toContain('Cherries');
});

test('find by regex', async ({ cli, server }) => {
server.setContent('/', listPage, 'text/html');
await cli('open', server.PREFIX);

const { output } = await cli('find', '--regex=Bananas|Cherries');
expect(output).toContain('Found 2 matches for /Bananas|Cherries/:');
});

test('find by regex with /i flag', async ({ cli, server }) => {
server.setContent('/', listPage, 'text/html');
await cli('open', server.PREFIX);

const { output } = await cli('find', '--regex=/apples/i');
expect(output).toContain('Found 1 match for /apples/i:');
});

test('find reports no matches', async ({ cli, server }) => {
server.setContent('/', listPage, 'text/html');
await cli('open', server.PREFIX);

const { output } = await cli('find', 'Pineapples');
expect(output).toContain('No matches found for "Pineapples".');
});
145 changes: 145 additions & 0 deletions tests/mcp/find.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* 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';

const listPage = `
<h1>Groceries</h1>
<ul>
<li>Apples</li>
<li>Bananas</li>
<li>Cherries</li>
</ul>
<button>Add to cart</button>
`;

test('browser_find by text', async ({ client, server }) => {
server.setContent('/', listPage, 'text/html');
await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });

const response = await client.callTool({
name: 'browser_find',
arguments: { text: 'Bananas' },
});
expect(response).toHaveResponse({
result: expect.stringContaining(`Found 1 match for "Bananas":`),
});
// The 3-line context window includes the neighbouring list items.
expect(response).toHaveResponse({
result: expect.stringContaining('Apples'),
});
expect(response).toHaveResponse({
result: expect.stringContaining('Cherries'),
});
});

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 } });

expect(await client.callTool({
name: 'browser_find',
arguments: { text: 'apples' },
})).toHaveResponse({
result: expect.stringContaining('Apples'),
});
});

test('browser_find by regex', async ({ client, server }) => {
server.setContent('/', listPage, 'text/html');
await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });

expect(await client.callTool({
name: 'browser_find',
arguments: { regex: 'Bananas|Cherries' },
})).toHaveResponse({
result: expect.stringContaining(`Found 2 matches for /Bananas|Cherries/:`),
});
});

test('browser_find regex is case-sensitive by default', async ({ client, server }) => {
server.setContent('/', listPage, 'text/html');
await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });

expect(await client.callTool({
name: 'browser_find',
arguments: { regex: 'apples' },
})).toHaveResponse({
result: `No matches found for /apples/.`,
});
});

test('browser_find regex honors /i flag', async ({ client, server }) => {
server.setContent('/', listPage, 'text/html');
await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });

expect(await client.callTool({
name: 'browser_find',
arguments: { regex: '/apples/i' },
})).toHaveResponse({
result: expect.stringContaining(`Found 1 match for /apples/i:`),
});
});

test('browser_find reports no matches', async ({ client, server }) => {
server.setContent('/', listPage, 'text/html');
await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });

expect(await client.callTool({
name: 'browser_find',
arguments: { text: 'Pineapples' },
})).toHaveResponse({
result: `No matches found for "Pineapples".`,
});
});

test('browser_find requires text or regex', async ({ client, server }) => {
server.setContent('/', listPage, 'text/html');
await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });

expect(await client.callTool({
name: 'browser_find',
arguments: {},
})).toHaveResponse({
error: expect.stringContaining('Provide either "text" or "regex" to search for.'),
isError: true,
});
});

test('browser_find rejects both text and regex', async ({ client, server }) => {
server.setContent('/', listPage, 'text/html');
await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });

expect(await client.callTool({
name: 'browser_find',
arguments: { text: 'Apples', regex: 'Apples' },
})).toHaveResponse({
error: expect.stringContaining('Provide only one of "text" or "regex", not both.'),
isError: true,
});
});

test('browser_find rejects invalid regex', async ({ client, server }) => {
server.setContent('/', listPage, 'text/html');
await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });

expect(await client.callTool({
name: 'browser_find',
arguments: { regex: '(' },
})).toHaveResponse({
isError: true,
});
});
Loading