diff --git a/examples/todomvc/.claude/agents/playwright-test-planner.md b/examples/todomvc/.claude/agents/playwright-test-planner.md index da10326eb98b8..78af43b534500 100644 --- a/examples/todomvc/.claude/agents/playwright-test-planner.md +++ b/examples/todomvc/.claude/agents/playwright-test-planner.md @@ -1,7 +1,7 @@ --- name: playwright-test-planner description: Use this agent when you need to create comprehensive test plan for a web application or website -tools: Glob, Grep, Read, LS, Edit, MultiEdit, Write, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page +tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page, mcp__playwright-test__planner_save_plan model: sonnet color: green --- @@ -41,52 +41,7 @@ You will: 5. **Create Documentation** - Save your test plan as requested: - - Executive summary of the tested page/application - - Individual scenarios as separate sections - - Each scenario formatted with numbered steps - - Each test case with proposed file name for implementation - - Clear expected results for verification - - -# TodoMVC Application - Comprehensive Test Plan - -## Application Overview - -The TodoMVC application is a React-based todo list manager that provides core task management functionality. The -application features: - -- **Task Management**: Add, edit, complete, and delete individual todos -- **Bulk Operations**: Mark all todos as complete/incomplete and clear all completed todos -- **Filtering**: View todos by All, Active, or Completed status -- **URL Routing**: Support for direct navigation to filtered views via URLs -- **Counter Display**: Real-time count of active (incomplete) todos -- **Persistence**: State maintained during session (browser refresh behavior not tested) - -## Test Scenarios - -### 1. Adding New Todos - -**Seed:** `tests/seed.spec.ts` - -#### 1.1 Add Valid Todo - -**File** `tests/adding-new-todos/add-valid-todo.spec.ts` - -**Steps:** -1. Click in the "What needs to be done?" input field -2. Type "Buy groceries" -3. Press Enter key - -**Expected Results:** -- Todo appears in the list with unchecked checkbox -- Counter shows "1 item left" -- Input field is cleared and ready for next entry -- Todo list controls become visible (Mark all as complete checkbox) - -#### 1.2 -... - + Submit your test plan using `planner_save_plan` tool. **Quality Standards**: - Write steps that are specific enough for any tester to follow diff --git a/package-lock.json b/package-lock.json index 94d7eff8a761f..e2c9a8bc4bf68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "ssim.js": "^3.5.0", + "tiny-loop": "^0.0.6", "typescript": "^5.9.2", "vite": "^6.4.1", "ws": "^8.17.1", @@ -7389,6 +7390,16 @@ "node": ">=18" } }, + "node_modules/tiny-loop": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tiny-loop/-/tiny-loop-0.0.6.tgz", + "integrity": "sha512-rzNqgjcNwp/MDh7XDPrgXxqjSpxcKUwDYDHRPDca7QP8FKwH8YSImhtzZkkbASps3JYW5WE1AfAhmaqNOVWrMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", diff --git a/package.json b/package.json index ef4154d72d70f..450b2d9daf9ae 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "ssim.js": "^3.5.0", + "tiny-loop": "^0.0.6", "typescript": "^5.9.2", "vite": "^6.4.1", "ws": "^8.17.1", diff --git a/packages/playwright/ThirdPartyNotices.txt b/packages/playwright/ThirdPartyNotices.txt index 8e00058b381e5..1c67672f8bc3e 100644 --- a/packages/playwright/ThirdPartyNotices.txt +++ b/packages/playwright/ThirdPartyNotices.txt @@ -207,6 +207,7 @@ This project incorporates components from the projects listed below. The origina - statuses@2.0.2 (https://github.com/jshttp/statuses) - stoppable@1.1.0 (https://github.com/hunterloftis/stoppable) - supports-color@7.2.0 (https://github.com/chalk/supports-color) +- tiny-loop@0.0.6 (https://github.com/pavelfeldman/tiny-loop) - to-regex-range@5.0.1 (https://github.com/micromatch/to-regex-range) - toidentifier@1.0.1 (https://github.com/component/toidentifier) - type-is@2.0.1 (https://github.com/jshttp/type-is) @@ -5883,6 +5884,212 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ========================================= END OF supports-color@7.2.0 AND INFORMATION +%% tiny-loop@0.0.6 NOTICES AND INFORMATION BEGIN HERE +========================================= +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + 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. +========================================= +END OF tiny-loop@0.0.6 AND INFORMATION + %% to-regex-range@5.0.1 NOTICES AND INFORMATION BEGIN HERE ========================================= The MIT License (MIT) @@ -6192,6 +6399,6 @@ END OF zod@3.25.76 AND INFORMATION SUMMARY BEGIN HERE ========================================= -Total Packages: 216 +Total Packages: 217 ========================================= END OF SUMMARY \ No newline at end of file diff --git a/packages/playwright/bundles/mcp/package-lock.json b/packages/playwright/bundles/mcp/package-lock.json index fa26c67851736..4efc9cc345216 100644 --- a/packages/playwright/bundles/mcp/package-lock.json +++ b/packages/playwright/bundles/mcp/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.5", + "tiny-loop": "^0.0.6", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6" } @@ -958,6 +959,15 @@ "node": ">= 0.8" } }, + "node_modules/tiny-loop": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tiny-loop/-/tiny-loop-0.0.6.tgz", + "integrity": "sha512-rzNqgjcNwp/MDh7XDPrgXxqjSpxcKUwDYDHRPDca7QP8FKwH8YSImhtzZkkbASps3JYW5WE1AfAhmaqNOVWrMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/packages/playwright/bundles/mcp/package.json b/packages/playwright/bundles/mcp/package.json index 9c39c6db792a7..785e006301d2b 100644 --- a/packages/playwright/bundles/mcp/package.json +++ b/packages/playwright/bundles/mcp/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@modelcontextprotocol/sdk": "^1.17.5", + "tiny-loop": "^0.0.6", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6" } diff --git a/packages/playwright/bundles/mcp/src/mcpBundleImpl.ts b/packages/playwright/bundles/mcp/src/mcpBundleImpl.ts index 613a648e15ee2..8e4e4ea8c9707 100644 --- a/packages/playwright/bundles/mcp/src/mcpBundleImpl.ts +++ b/packages/playwright/bundles/mcp/src/mcpBundleImpl.ts @@ -23,5 +23,6 @@ export { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' export { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; export { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; export { CallToolRequestSchema, ListRootsRequestSchema, ListToolsRequestSchema, PingRequestSchema, ProgressNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +export { Loop } from 'tiny-loop'; export { z } from 'zod'; export { zodToJsonSchema } from 'zod-to-json-schema'; diff --git a/packages/playwright/src/DEPS.list b/packages/playwright/src/DEPS.list index d0c1ac71bc33a..680cf5e8870e8 100644 --- a/packages/playwright/src/DEPS.list +++ b/packages/playwright/src/DEPS.list @@ -14,6 +14,7 @@ common/ ./mcp/sdk/ ./mcp/test/ ./transform/babelBundle.ts +./agents/ [internalsForTest.ts] ** diff --git a/packages/playwright/src/agents/DEPS.list b/packages/playwright/src/agents/DEPS.list index 75300109dfd78..49f26daf1a5d6 100644 --- a/packages/playwright/src/agents/DEPS.list +++ b/packages/playwright/src/agents/DEPS.list @@ -1,3 +1,8 @@ -[generateAgents.ts] +[*] +../mcp/browser/ +../mcp/sdk/ ../mcp/test/ ../common/ + +[generateAgents.ts] +../mcp/test/ diff --git a/packages/playwright/src/agents/agent.ts b/packages/playwright/src/agents/agent.ts new file mode 100644 index 0000000000000..c7f7a97e99002 --- /dev/null +++ b/packages/playwright/src/agents/agent.ts @@ -0,0 +1,97 @@ +/** + * 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 { Loop } from '../mcp/sdk/bundle'; + +import type z from 'zod'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { Loop as LoopType } from 'tiny-loop'; + +type Logger = (category: string, text: string, details?: string) => void; + +export type AgentSpec = { + name: string; + description: string; + model: string; + color: string; + tools: string[]; + instructions: string; + examples: string[]; +}; + +export class Agent> { + readonly loop: LoopType; + readonly spec: AgentSpec; + readonly clients: Map; + readonly resultSchema: Tool['inputSchema']; + + constructor(loopName: 'copilot' | 'claude' | 'openai', spec: AgentSpec, clients: Map, resultSchema: Tool['inputSchema']) { + this.loop = new Loop(loopName); + this.spec = spec; + this.clients = clients; + this.resultSchema = resultSchema; + } + + async runTask(task: string, params: any, options?: { logger?: Logger }): Promise> { + const { clients, tools, callTool } = await this._initClients(); + const prompt = this.spec.description; + try { + return await this.loop.run>(`${prompt}\n\nTask:\n${task}\n\nParams:\n${JSON.stringify(params, null, 2)}`, { + ...options, + tools, + callTool, + resultSchema: this.resultSchema + }); + } finally { + await this._disconnectFromServers(clients); + } + } + + private async _initClients() { + const clients: Record = {}; + const agentToolNames = new Set(this.spec.tools); + const tools: Tool[] = []; + + for (const [name, client] of this.clients.entries()) { + const list = await client.listTools(); + for (const tool of list.tools) { + if (!agentToolNames.has(name + '/' + tool.name)) + continue; + agentToolNames.delete(name + '/' + tool.name); + tools.push({ ...tool, name: name + '__' + tool.name }); + } + clients[name] = client; + } + + if (agentToolNames.size > 0) + throw new Error(`Required tools not found: ${Array.from(agentToolNames).join(', ')}`); + + const callTool: (params: { name: string, arguments: any}) => Promise = async params => { + const [serverName, toolName] = params.name.split('__'); + const client = clients[serverName]; + if (!client) + throw new Error(`Unknown server: ${serverName}`); + return await client.callTool({ name: toolName, arguments: params.arguments }) as CallToolResult; + }; + return { clients, tools, callTool }; + } + + private async _disconnectFromServers(clients: Record) { + for (const client of Object.values(clients)) + await client.close(); + } +} diff --git a/packages/playwright/src/agents/agentParser.ts b/packages/playwright/src/agents/agentParser.ts new file mode 100644 index 0000000000000..fd3c6bd77da6e --- /dev/null +++ b/packages/playwright/src/agents/agentParser.ts @@ -0,0 +1,92 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * 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 fs from 'fs'; +import { yaml } from 'playwright-core/lib/utilsBundle'; + +import type { AgentSpec } from './agent'; + +type AgentSpecHeader = { + name: string; + description: string; + model: string; + color: string; + tools: string[]; +}; + +export async function parseAgentSpec(filePath: string): Promise { + const source = await fs.promises.readFile(filePath, 'utf-8'); + const { header, content } = extractYamlAndContent(source); + const { instructions, examples } = extractInstructionsAndExamples(content); + return { + ...header, + instructions, + examples, + }; +} + +function extractYamlAndContent(markdown: string): { header: AgentSpecHeader; content: string } { + const lines = markdown.split('\n'); + + if (lines[0] !== '---') + throw new Error('Markdown file must start with YAML front matter (---)'); + + let yamlEndIndex = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i] === '---') { + yamlEndIndex = i; + break; + } + } + + if (yamlEndIndex === -1) + throw new Error('YAML front matter must be closed with ---'); + + const yamlLines = lines.slice(1, yamlEndIndex); + const yamlRaw = yamlLines.join('\n'); + const contentLines = lines.slice(yamlEndIndex + 1); + const content = contentLines.join('\n'); + + let header: AgentSpecHeader; + try { + header = yaml.parse(yamlRaw) as AgentSpecHeader; + } catch (error: any) { + throw new Error(`Failed to parse YAML header: ${error.message}`); + } + + if (!header.name) + throw new Error('YAML header must contain a "name" field'); + + if (!header.description) + throw new Error('YAML header must contain a "description" field'); + + return { header, content }; +} + +function extractInstructionsAndExamples(content: string): { instructions: string; examples: string[] } { + const examples: string[] = []; + + const instructions = content.split('')[0].trim(); + const exampleRegex = /([\s\S]*?)<\/example>/g; + let match: RegExpExecArray | null; + + while ((match = exampleRegex.exec(content)) !== null) { + const example = match[1].trim(); + examples.push(example.replace(/[\n]/g, ' ').replace(/ +/g, ' ')); + } + + return { instructions, examples }; +} diff --git a/packages/playwright/src/agents/generateAgents.ts b/packages/playwright/src/agents/generateAgents.ts index 5a5dff14a94b2..a8fc778e9876b 100644 --- a/packages/playwright/src/agents/generateAgents.ts +++ b/packages/playwright/src/agents/generateAgents.ts @@ -22,88 +22,15 @@ import { mkdirIfNeeded } from 'playwright-core/lib/utils'; import { FullConfigInternal } from '../common/config'; import { defaultSeedFile, findSeedFile, seedFileContent, seedProject } from '../mcp/test/seed'; +import { parseAgentSpec } from './agentParser'; -interface AgentHeader { - name: string; - description: string; - model: string; - color: string; - tools: string[]; -} - -interface Agent { - header: AgentHeader; - instructions: string; - examples: string[]; -} +import type { AgentSpec } from './agent'; /* eslint-disable no-console */ -class AgentParser { - static async loadAgents(): Promise { - const files = await fs.promises.readdir(__dirname); - return Promise.all(files.filter(file => file.endsWith('.agent.md')).map(file => AgentParser.parseFile(path.join(__dirname, file)))); - } - - static async parseFile(filePath: string): Promise { - const source = await fs.promises.readFile(filePath, 'utf-8'); - const { header, content } = this.extractYamlAndContent(source); - const { instructions, examples } = this.extractInstructionsAndExamples(content); - return { header, instructions, examples }; - } - - static extractYamlAndContent(markdown: string): { header: AgentHeader; content: string } { - const lines = markdown.split('\n'); - - if (lines[0] !== '---') - throw new Error('Markdown file must start with YAML front matter (---)'); - - let yamlEndIndex = -1; - for (let i = 1; i < lines.length; i++) { - if (lines[i] === '---') { - yamlEndIndex = i; - break; - } - } - - if (yamlEndIndex === -1) - throw new Error('YAML front matter must be closed with ---'); - - const yamlLines = lines.slice(1, yamlEndIndex); - const yamlRaw = yamlLines.join('\n'); - const contentLines = lines.slice(yamlEndIndex + 1); - const content = contentLines.join('\n'); - - let header: AgentHeader; - try { - header = yaml.parse(yamlRaw) as AgentHeader; - } catch (error: any) { - throw new Error(`Failed to parse YAML header: ${error.message}`); - } - - if (!header.name) - throw new Error('YAML header must contain a "name" field'); - - if (!header.description) - throw new Error('YAML header must contain a "description" field'); - - return { header, content }; - } - - static extractInstructionsAndExamples(content: string): { instructions: string; examples: string[] } { - const examples: string[] = []; - - const instructions = content.split('')[0].trim(); - const exampleRegex = /([\s\S]*?)<\/example>/g; - let match: RegExpExecArray | null; - - while ((match = exampleRegex.exec(content)) !== null) { - const example = match[1].trim(); - examples.push(example.replace(/[\n]/g, ' ').replace(/ +/g, ' ')); - } - - return { instructions, examples }; - } +async function loadAgentSpecs(): Promise { + const files = await fs.promises.readdir(__dirname); + return Promise.all(files.filter(file => file.endsWith('.agent.md')).map(file => parseAgentSpec(path.join(__dirname, file)))); } export class ClaudeGenerator { @@ -112,11 +39,11 @@ export class ClaudeGenerator { promptsFolder: prompts ? '.claude/prompts' : undefined, }); - const agents = await AgentParser.loadAgents(); + const agents = await loadAgentSpecs(); await fs.promises.mkdir('.claude/agents', { recursive: true }); for (const agent of agents) - await writeFile(`.claude/agents/${agent.header.name}.md`, ClaudeGenerator.agentSpec(agent), '🤖', 'agent definition'); + await writeFile(`.claude/agents/${agent.name}.md`, ClaudeGenerator.agentSpec(agent), '🤖', 'agent definition'); await writeFile('.mcp.json', JSON.stringify({ mcpServers: { @@ -130,7 +57,7 @@ export class ClaudeGenerator { initRepoDone(); } - static agentSpec(agent: Agent): string { + static agentSpec(agent: AgentSpec): string { const claudeToolMap = new Map([ ['search', ['Glob', 'Grep', 'Read', 'LS']], ['edit', ['Edit', 'MultiEdit', 'Write']], @@ -146,11 +73,11 @@ export class ClaudeGenerator { const examples = agent.examples.length ? ` Examples: ${agent.examples.map(example => `${example}`).join('')}` : ''; const lines: string[] = []; const header = { - name: agent.header.name, - description: agent.header.description + examples, - tools: agent.header.tools.map(tool => asClaudeTool(tool)).join(', '), - model: agent.header.model, - color: agent.header.color, + name: agent.name, + description: agent.description + examples, + tools: agent.tools.map(tool => asClaudeTool(tool)).join(', '), + model: agent.model, + color: agent.color, }; lines.push(`---`); lines.push(yaml.stringify(header, { lineWidth: 100000 }) + `---`); @@ -167,13 +94,13 @@ export class OpencodeGenerator { promptsFolder: prompts ? '.opencode/prompts' : undefined }); - const agents = await AgentParser.loadAgents(); + const agents = await loadAgentSpecs(); for (const agent of agents) { const prompt = [agent.instructions]; prompt.push(''); prompt.push(...agent.examples.map(example => `${example}`)); - await writeFile(`.opencode/prompts/${agent.header.name}.md`, prompt.join('\n'), '🤖', 'agent definition'); + await writeFile(`.opencode/prompts/${agent.name}.md`, prompt.join('\n'), '🤖', 'agent definition'); } await writeFile('opencode.json', OpencodeGenerator.configuration(agents), '🔧', 'opencode configuration'); @@ -181,7 +108,7 @@ export class OpencodeGenerator { initRepoDone(); } - static configuration(agents: Agent[]): string { + static configuration(agents: AgentSpec[]): string { const opencodeToolMap = new Map([ ['search', ['ls', 'glob', 'grep', 'read']], ['edit', ['edit', 'write']], @@ -206,13 +133,13 @@ export class OpencodeGenerator { result['agent'] = {}; for (const agent of agents) { const tools: Record = {}; - result['agent'][agent.header.name] = { - description: agent.header.description, + result['agent'][agent.name] = { + description: agent.description, mode: 'subagent', - prompt: `{file:.opencode/prompts/${agent.header.name}.md}`, + prompt: `{file:.opencode/prompts/${agent.name}.md}`, tools, }; - for (const tool of agent.header.tools) + for (const tool of agent.tools) asOpencodeTool(tools, tool); } @@ -234,11 +161,11 @@ export class CopilotGenerator { promptSuffix: 'prompt' }); - const agents = await AgentParser.loadAgents(); + const agents = await loadAgentSpecs(); await fs.promises.mkdir('.github/agents', { recursive: true }); for (const agent of agents) - await writeFile(`.github/agents/${agent.header.name}.agent.md`, CopilotGenerator.agentSpec(agent), '🤖', 'agent definition'); + await writeFile(`.github/agents/${agent.name}.agent.md`, CopilotGenerator.agentSpec(agent), '🤖', 'agent definition'); await deleteFile(`.github/chatmodes/ 🎭 planner.chatmode.md`, 'legacy planner chatmode'); await deleteFile(`.github/chatmodes/🎭 generator.chatmode.md`, 'legacy generator chatmode'); @@ -266,13 +193,13 @@ export class CopilotGenerator { initRepoDone(); } - static agentSpec(agent: Agent): string { + static agentSpec(agent: AgentSpec): string { const examples = agent.examples.length ? ` Examples: ${agent.examples.map(example => `${example}`).join('')}` : ''; const lines: string[] = []; const header = { - 'name': agent.header.name, - 'description': agent.header.description + examples, - 'tools': agent.header.tools, + 'name': agent.name, + 'description': agent.description + examples, + 'tools': agent.tools, 'model': 'Claude Sonnet 4', 'mcp-servers': CopilotGenerator.mcpServers, }; @@ -302,7 +229,7 @@ export class VSCodeGenerator { await initRepo(config, projectName, { promptsFolder: undefined }); - const agents = await AgentParser.loadAgents(); + const agents = await loadAgentSpecs(); const nameMap = new Map([ ['playwright-test-planner', ' 🎭 planner'], @@ -312,7 +239,7 @@ export class VSCodeGenerator { await fs.promises.mkdir('.github/chatmodes', { recursive: true }); for (const agent of agents) - await writeFile(`.github/chatmodes/${nameMap.get(agent.header.name)}.chatmode.md`, VSCodeGenerator.agentSpec(agent), '🤖', 'chatmode definition'); + await writeFile(`.github/chatmodes/${nameMap.get(agent.name)}.chatmode.md`, VSCodeGenerator.agentSpec(agent), '🤖', 'chatmode definition'); await VSCodeGenerator.appendToMCPJson(); @@ -343,7 +270,7 @@ export class VSCodeGenerator { await writeFile(mcpJsonPath, JSON.stringify(mcpJson, null, 2), '🔧', 'mcp configuration'); } - static agentSpec(agent: Agent): string { + static agentSpec(agent: AgentSpec): string { const vscodeToolMap = new Map([ ['search', ['search/listDirectory', 'search/fileSearch', 'search/textSearch']], ['read', ['search/readFile']], @@ -359,7 +286,7 @@ export class VSCodeGenerator { return `${vscodeMcpName}/${second}`; return vscodeToolMap.get(first) || first; } - const tools = agent.header.tools.map(asVscodeTool).flat().sort((a, b) => { + const tools = agent.tools.map(asVscodeTool).flat().sort((a, b) => { // VSCode insists on the specific tools order when editing agent config. const indexA = vscodeToolsOrder.indexOf(a); const indexB = vscodeToolsOrder.indexOf(b); @@ -374,7 +301,7 @@ export class VSCodeGenerator { const lines: string[] = []; lines.push(`---`); - lines.push(`description: ${agent.header.description}.`); + lines.push(`description: ${agent.description}.`); lines.push(`tools: [${tools}]`); lines.push(`---`); lines.push(''); diff --git a/packages/playwright/src/agents/performTask.ts b/packages/playwright/src/agents/performTask.ts new file mode 100644 index 0000000000000..78aecebd541f8 --- /dev/null +++ b/packages/playwright/src/agents/performTask.ts @@ -0,0 +1,59 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * 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 { colors } from 'playwright-core/lib/utils'; + +import { identityBrowserContextFactory } from '../mcp/browser/browserContextFactory'; +import { BrowserServerBackend } from '../mcp/browser/browserServerBackend'; +import { defaultConfig } from '../mcp/browser/config'; +import { Loop } from '../mcp/sdk/bundle'; +import { wrapInClient } from '../mcp/sdk/server'; + +import type * as playwright from 'playwright-core'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types'; + +export async function performTask(context: playwright.BrowserContext, task: string) { + const backend = new BrowserServerBackend(defaultConfig, identityBrowserContextFactory(context)); + const client = await wrapInClient(backend, { name: 'Internal', version: '0.0.0' }); + const loop = new Loop('copilot'); + + const callTool: (params: { name: string, arguments: any}) => Promise = async params => { + return await client.callTool(params) as CallToolResult; + }; + + try { + return await loop.run(task, { + tools: await backend.listTools(), + callTool, + logger, + }); + } finally { + await client.close(); + } +} + +function logger(category: string, text: string, details = '') { + const trimmedText = trim(text, 100); + const trimmedDetails = trim(details, 100 - trimmedText.length - 1); + // eslint-disable-next-line no-console + console.log(colors.bold(colors.green(category)), trimmedText, colors.dim(trimmedDetails)); +} + +function trim(text: string, maxLength: number) { + if (text.length <= maxLength) + return text; + return text.slice(0, maxLength - 3) + '...'; +} diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index b8118c9039b11..5265de9cd4ebc 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -23,6 +23,7 @@ import { setBoxedStackPrefixes, createGuid, currentZone, debugMode, jsonStringif import { currentTestInfo } from './common/globals'; import { rootTestType } from './common/testType'; import { createCustomMessageHandler } from './mcp/test/browserBackend'; +import { performTask } from './agents/performTask'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test'; import type { ContextReuseMode } from './common/config'; @@ -57,6 +58,7 @@ type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & { _combinedContextOptions: BrowserContextOptions, _setupContextOptions: void; _setupArtifacts: void; + _perform: (task: string) => Promise; _contextFactory: (options?: BrowserContextOptions) => Promise<{ context: BrowserContext, close: () => Promise }>; }; @@ -458,6 +460,12 @@ const playwrightFixtures: Fixtures = ({ await request.dispose(); } }, + + _perform: async ({ context }, use) => { + await use(async (task: string) => { + await performTask(context, task); + }); + }, }); type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined; diff --git a/packages/playwright/src/mcp/browser/browserContextFactory.ts b/packages/playwright/src/mcp/browser/browserContextFactory.ts index 5018b5bf9cc3f..bff1d664799e8 100644 --- a/packages/playwright/src/mcp/browser/browserContextFactory.ts +++ b/packages/playwright/src/mcp/browser/browserContextFactory.ts @@ -51,6 +51,17 @@ export interface BrowserContextFactory { createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise; } +export function identityBrowserContextFactory(browserContext: playwright.BrowserContext): BrowserContextFactory { + return { + createContext: async (clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) => { + return { + browserContext, + close: async () => {} + }; + } + }; +} + class BaseContextFactory implements BrowserContextFactory { readonly config: FullConfig; private _logName: string; diff --git a/packages/playwright/src/mcp/sdk/bundle.ts b/packages/playwright/src/mcp/sdk/bundle.ts index 43fa2d39410cf..8a35b04fceace 100644 --- a/packages/playwright/src/mcp/sdk/bundle.ts +++ b/packages/playwright/src/mcp/sdk/bundle.ts @@ -31,6 +31,7 @@ const ListRootsRequestSchema: typeof import('@modelcontextprotocol/sdk/types.js' const ProgressNotificationSchema: typeof import('@modelcontextprotocol/sdk/types.js').ProgressNotificationSchema = bundle.ProgressNotificationSchema; const ListToolsRequestSchema: typeof import('@modelcontextprotocol/sdk/types.js').ListToolsRequestSchema = bundle.ListToolsRequestSchema; const PingRequestSchema: typeof import('@modelcontextprotocol/sdk/types.js').PingRequestSchema = bundle.PingRequestSchema; +const Loop: typeof import('tiny-loop').Loop = bundle.Loop; const z: typeof import('zod') = bundle.z; export { @@ -48,5 +49,6 @@ export { ListToolsRequestSchema, PingRequestSchema, ProgressNotificationSchema, + Loop, z, }; diff --git a/packages/playwright/src/mcp/sdk/proxyBackend.ts b/packages/playwright/src/mcp/sdk/proxyBackend.ts index ba8fc790f40f9..327fc15f24b73 100644 --- a/packages/playwright/src/mcp/sdk/proxyBackend.ts +++ b/packages/playwright/src/mcp/sdk/proxyBackend.ts @@ -26,7 +26,7 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; export type MCPProvider = { name: string; description: string; - connect(): Promise; + connect(): Transport; }; const errorsDebug = debug('pw:mcp:errors'); @@ -127,7 +127,7 @@ export class ProxyBackend implements ServerBackend { client.setRequestHandler(mcpBundle.ListRootsRequestSchema, () => ({ roots: this._clientInfo?.roots || [] })); client.setRequestHandler(mcpBundle.PingRequestSchema, () => ({})); - const transport = await factory.connect(); + const transport = factory.connect(); await client.connect(transport); this._currentClient = client; return client; diff --git a/packages/playwright/src/mcp/sdk/server.ts b/packages/playwright/src/mcp/sdk/server.ts index d6a60e3e14a14..48d42fb3811c9 100644 --- a/packages/playwright/src/mcp/sdk/server.ts +++ b/packages/playwright/src/mcp/sdk/server.ts @@ -27,6 +27,7 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; export type { Server } from '@modelcontextprotocol/sdk/server/index.js'; export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; const serverDebug = debug('pw:mcp:server'); const serverDebugResponse = debug('pw:mcp:server:response'); @@ -60,11 +61,20 @@ export async function connect(factory: ServerBackendFactory, transport: Transpor await server.connect(transport); } -export async function wrapInProcess(backend: ServerBackend): Promise { +export function wrapInProcess(backend: ServerBackend): Transport { const server = createServer('Internal', '0.0.0', backend, false); return new InProcessTransport(server); } +export async function wrapInClient(backend: ServerBackend, options: { name: string, version: string }): Promise { + const server = createServer('Internal', '0.0.0', backend, false); + const transport = new InProcessTransport(server); + const client = new mcpBundle.Client({ name: options.name, version: options.version }); + await client.connect(transport); + await client.ping(); + return client; +} + export function createServer(name: string, version: string, backend: ServerBackend, runHeartbeat: boolean): Server { const server = new mcpBundle.Server({ name, version }, { capabilities: { diff --git a/packages/playwright/src/mcp/test/DEPS.list b/packages/playwright/src/mcp/test/DEPS.list index 385682de8cd58..4d49a78fb2bcd 100644 --- a/packages/playwright/src/mcp/test/DEPS.list +++ b/packages/playwright/src/mcp/test/DEPS.list @@ -2,6 +2,7 @@ ../sdk/ ../browser/config.ts ../browser/browserServerBackend.ts +../browser/browserContextFactory.ts ../browser/response.ts ../browser/tab.ts ../browser/tools/ diff --git a/packages/playwright/src/mcp/test/browserBackend.ts b/packages/playwright/src/mcp/test/browserBackend.ts index a362acda63b8f..12e8927ecddb7 100644 --- a/packages/playwright/src/mcp/test/browserBackend.ts +++ b/packages/playwright/src/mcp/test/browserBackend.ts @@ -19,10 +19,10 @@ import { defaultConfig } from '../browser/config'; import { BrowserServerBackend } from '../browser/browserServerBackend'; import { Tab } from '../browser/tab'; import { stripAnsiEscapes } from '../../util'; +import { identityBrowserContextFactory } from '../browser/browserContextFactory'; import type * as playwright from '../../../index'; import type { Page } from '../../../../playwright-core/src/client/page'; -import type { BrowserContextFactory } from '../browser/browserContextFactory'; import type { TestInfo } from '../../../test'; export type BrowserMCPRequest = { @@ -45,7 +45,7 @@ export function createCustomMessageHandler(testInfo: TestInfo, context: playwrig if (data.initialize) { if (backend) throw new Error('MCP backend is already initialized'); - backend = new BrowserServerBackend({ ...defaultConfig, capabilities: ['testing'] }, identityFactory(context)); + backend = new BrowserServerBackend({ ...defaultConfig, capabilities: ['testing'] }, identityBrowserContextFactory(context)); await backend.initialize(data.initialize.clientInfo); const pausedMessage = await generatePausedMessage(testInfo, context); return { initialize: { pausedMessage } }; @@ -115,14 +115,3 @@ async function generatePausedMessage(testInfo: TestInfo, context: playwright.Bro return lines.join('\n'); } - -function identityFactory(browserContext: playwright.BrowserContext): BrowserContextFactory { - return { - createContext: async (clientInfo: mcp.ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) => { - return { - browserContext, - close: async () => {} - }; - } - }; -} diff --git a/packages/playwright/src/mcp/test/testBackend.ts b/packages/playwright/src/mcp/test/testBackend.ts index e49c0322012d6..86d724df65191 100644 --- a/packages/playwright/src/mcp/test/testBackend.ts +++ b/packages/playwright/src/mcp/test/testBackend.ts @@ -42,15 +42,15 @@ export class TestServerBackend implements mcp.ServerBackend { ]; private _options: { muteConsole?: boolean, headless?: boolean }; private _context: TestContext | undefined; - private _configOption: string | undefined; + private _configPath: string | undefined; - constructor(configOption: string | undefined, options?: { muteConsole?: boolean, headless?: boolean }) { + constructor(configPath: string | undefined, options?: { muteConsole?: boolean, headless?: boolean }) { this._options = options || {}; - this._configOption = configOption; + this._configPath = configPath; } async initialize(clientInfo: mcp.ClientInfo): Promise { - this._context = new TestContext(clientInfo, this._configOption, this._options); + this._context = new TestContext(clientInfo, this._configPath, this._options); } async listTools(): Promise { @@ -69,7 +69,7 @@ export class TestServerBackend implements mcp.ServerBackend { } serverClosed() { - void this._context!.close(); + void this._context?.close(); } } diff --git a/utils/build/build.js b/utils/build/build.js index e7531892624c7..c6eae6eb79c65 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -260,7 +260,7 @@ bundles.push({ modulePath: 'packages/playwright/bundles/mcp', outdir: 'packages/playwright/lib', entryPoints: ['src/mcpBundleImpl.ts'], - external: ['express'], + external: ['express', '@anthropic-ai/sdk'], alias: { 'raw-body': 'raw-body.ts', },