diff --git a/packages/playwright-core/src/tools/backend/find.ts b/packages/playwright-core/src/tools/backend/find.ts new file mode 100644 index 0000000000000..703dd0e3a9e36 --- /dev/null +++ b/packages/playwright-core/src/tools/backend/find.ts @@ -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, +]; diff --git a/packages/playwright-core/src/tools/backend/tools.ts b/packages/playwright-core/src/tools/backend/tools.ts index a5fdfdc664c42..d9b4e7d64f923 100644 --- a/packages/playwright-core/src/tools/backend/tools.ts +++ b/packages/playwright-core/src/tools/backend/tools.ts @@ -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'; @@ -53,6 +54,7 @@ export const browserTools: Tool[] = [ ...dialogs, ...evaluate, ...files, + ...find, ...form, ...keyboard, ...mouse, diff --git a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md index c49ed5f53021f..987cd3a69d3ec 100644 --- a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md +++ b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md @@ -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 @@ -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 diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 7505f6a3a466e..ec87f4101e027 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -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', @@ -1149,6 +1163,7 @@ const commandsArray: AnyCommandSchema[] = [ check, uncheck, snapshot, + find, evaluate, consoleList, dialogAccept, diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index eef996256f549..e3d0c9e45aa42 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -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', diff --git a/tests/mcp/cli-find.spec.ts b/tests/mcp/cli-find.spec.ts new file mode 100644 index 0000000000000..dafacf47aefb9 --- /dev/null +++ b/tests/mcp/cli-find.spec.ts @@ -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 = ` +

Groceries

+ +`; + +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".'); +}); diff --git a/tests/mcp/find.spec.ts b/tests/mcp/find.spec.ts new file mode 100644 index 0000000000000..4455cdbcd20f0 --- /dev/null +++ b/tests/mcp/find.spec.ts @@ -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 = ` +

Groceries

+ + +`; + +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, + }); +});