diff --git a/package-lock.json b/package-lock.json index 384f240cb3e7b..2428dea4eb701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6262,9 +6262,9 @@ } }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { diff --git a/packages/isomorphic/codegen/python.ts b/packages/isomorphic/codegen/python.ts index f193f727d5fb7..1d00c0711eaa5 100644 --- a/packages/isomorphic/codegen/python.ts +++ b/packages/isomorphic/codegen/python.ts @@ -146,7 +146,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { return `expect(${subject}.${this._asLocator(action.selector)}).to_be_visible()`; case 'assertValue': { const assertion = action.value ? `to_have_value(${quote(action.value)})` : `to_be_empty()`; - return `expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; + return `expect(${subject}.${this._asLocator(action.selector)}).${assertion}`; } case 'assertSnapshot': return `expect(${subject}.${this._asLocator(action.selector)}).to_match_aria_snapshot(${quote(action.ariaSnapshot)})`; diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 960a4a6e278a0..c1928ce1e6b5e 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -84,6 +84,8 @@ export class PlaywrightServer { return { error: `HTTP/${request.httpVersion} 428 Precondition Required\r\n\r\n${uaError}` }; }, + isAllowedPathname: pathname => pathname === this._options.path, + onHeaders: headers => { if (process.env.PWTEST_SERVER_WS_HEADERS) headers.push(process.env.PWTEST_SERVER_WS_HEADERS!); diff --git a/packages/playwright-core/src/remote/playwrightWebSocketServer.ts b/packages/playwright-core/src/remote/playwrightWebSocketServer.ts index 11e9c3786dc8d..9465f8b442633 100644 --- a/packages/playwright-core/src/remote/playwrightWebSocketServer.ts +++ b/packages/playwright-core/src/remote/playwrightWebSocketServer.ts @@ -26,9 +26,12 @@ import type { PlaywrightInitializeResult } from './playwrightConnection'; export class PlaywrightWebSocketServer { private _wsServer: WSServer; private _browser: Browser; + private _path: string; constructor(browser: Browser, path: string) { this._browser = browser; + this._path = path; + browser.on(Browser.Events.Disconnected, () => this.close()); const semaphore = new Semaphore(Infinity); @@ -38,6 +41,7 @@ export class PlaywrightWebSocketServer { }, onUpgrade: () => undefined, onHeaders: () => {}, + isAllowedPathname: pathname => pathname === this._path, onConnection: (request, url, ws, id) => { debugLogger.log('server', `[${id}] ws client connected`); return new PlaywrightConnection( @@ -61,8 +65,8 @@ export class PlaywrightWebSocketServer { }; } - async listen(port: number = 0, hostname?: string, path?: string): Promise { - return await this._wsServer.listen(port, hostname, path || '/'); + async listen(port: number = 0, hostname?: string): Promise { + return await this._wsServer.listen(port, hostname, this._path); } async close() { diff --git a/packages/playwright-core/src/server/bidi/bidiConnection.ts b/packages/playwright-core/src/server/bidi/bidiConnection.ts index b38976787bdc2..d15e8df7e8ba6 100644 --- a/packages/playwright-core/src/server/bidi/bidiConnection.ts +++ b/packages/playwright-core/src/server/bidi/bidiConnection.ts @@ -248,6 +248,8 @@ export class BidiSession extends EventEmitter { const callback = this._callbacks.get(object.id)!; this._callbacks.delete(object.id); if (object.type === 'error') { + if (object.error === 'no such frame') + callback.error.type = 'closed'; callback.error.setMessage(object.error + '\nMessage: ' + object.message); callback.reject(callback.error); } else if (object.type === 'success') { diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 56fff6415a054..608fdab72caf2 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -222,8 +222,8 @@ export class BrowserServer { let endpoint: string; if (options.host !== undefined || options.port !== undefined) { - this._wsServer = new PlaywrightWebSocketServer(this._browser, '/'); - endpoint = await this._wsServer.listen(options.port ?? 0, options.host, '/' + createGuid()); + this._wsServer = new PlaywrightWebSocketServer(this._browser, '/' + createGuid()); + endpoint = await this._wsServer.listen(options.port ?? 0, options.host); } else { this._pipeServer = new PlaywrightPipeServer(this._browser); this._pipeSocketPath = await this._socketPath(); diff --git a/packages/playwright-core/src/tools/backend/codegen.ts b/packages/playwright-core/src/tools/backend/codegen.ts new file mode 100644 index 0000000000000..852926ec45d62 --- /dev/null +++ b/packages/playwright-core/src/tools/backend/codegen.ts @@ -0,0 +1,86 @@ +/** + * 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 { CSharpLanguageGenerator } from '@isomorphic/codegen/csharp'; +import { JavaLanguageGenerator } from '@isomorphic/codegen/java'; +import { JavaScriptLanguageGenerator } from '@isomorphic/codegen/javascript'; +import { PythonLanguageGenerator } from '@isomorphic/codegen/python'; + +import type * as actions from '@isomorphic/codegen/actions'; +import type { LanguageGenerator } from '@isomorphic/codegen/types'; + +export type CodegenLanguage = 'typescript' | 'python' | 'java' | 'csharp'; + +export type CodeItem = string | actions.ActionInContext; + +export function actionInContext(action: actions.Action): actions.ActionInContext { + return { pageGuid: 'page', action, signals: [] }; +} + +export function renderCode(items: CodeItem[], language: CodegenLanguage): string[] { + const generator = createGenerator(language); + generator.reset(); + const options = { browserName: 'chromium', launchOptions: {}, contextOptions: {} }; + const lines: string[] = []; + for (const item of items) { + if (typeof item === 'string') { + lines.push(item); + continue; + } + const text = generator.generateAction(item, options); + if (text) + lines.push(...dedent(text).split('\n')); + } + return lines; +} + +export function secretCode(language: CodegenLanguage, secretName: string): string { + switch (language) { + case 'typescript': return `process.env['${secretName}']`; + case 'python': return `os.environ["${secretName}"]`; + case 'java': return `System.getenv("${secretName}")`; + case 'csharp': return `Environment.GetEnvironmentVariable("${secretName}")`; + default: return `"SECRET_${secretName}"`; + } +} + +export function substituteSecrets(lines: string[], language: CodegenLanguage, secretNames: string[]): string[] { + if (!secretNames.length) + return lines; + return lines.map(line => { + for (const name of secretNames) { + for (const quote of [`'`, `"`]) + line = line.replaceAll(`${quote}SECRET_${name}${quote}`, secretCode(language, name)); + } + return line; + }); +} + +function createGenerator(language: CodegenLanguage): LanguageGenerator { + switch (language) { + case 'typescript': return new JavaScriptLanguageGenerator(/* isTest */ true); + case 'python': return new PythonLanguageGenerator(/* isAsync */ false, /* isPyTest */ false); + case 'java': return new JavaLanguageGenerator('library'); + case 'csharp': return new CSharpLanguageGenerator('library'); + } +} + +function dedent(text: string): string { + const lines = text.split('\n'); + const indents = lines.filter(line => line.trim()).map(line => line.length - line.trimStart().length); + const indent = indents.length ? Math.min(...indents) : 0; + return lines.map(line => line.substring(indent)).join('\n'); +} diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index aa8545962e6f2..5d7e41ad31513 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -25,6 +25,7 @@ import { eventsHelper } from '@utils/eventsHelper'; import { isPathInside, isSystemDirectory, isWritable } from '@utils/fileUtils'; import { playwright } from '../../inprocess'; +import { secretCode } from './codegen'; import { Tab } from './tab'; import type * as playwrightTypes from '../../..'; @@ -37,7 +38,7 @@ const testDebug = debug('pw:mcp:test'); export type ContextConfig = { allowUnrestrictedFileAccess?: boolean; capabilities?: ToolCapability[]; - codegen?: 'typescript' | 'none'; + codegen?: 'typescript' | 'python' | 'java' | 'csharp' | 'none'; console?: { level?: 'error' | 'warning' | 'info' | 'debug' }; imageResponses?: 'allow' | 'omit'; network?: { @@ -348,12 +349,14 @@ export class Context { throw new Error(`Access to "file:" protocol is blocked. Attempted URL: "${url}"`); } - lookupSecret(secretName: string): { value: string, code: string } { + lookupSecret(secretName: string): { value: string, code: string, isSecret: boolean } { if (!this.config.secrets?.[secretName]) - return { value: secretName, code: escapeWithQuotes(secretName, '\'') }; + return { value: secretName, code: escapeWithQuotes(secretName, '\''), isSecret: false }; + const codegen = this.config.codegen ?? 'typescript'; return { value: this.config.secrets[secretName]!, - code: `process.env['${secretName}']`, + code: secretCode(codegen === 'none' ? 'typescript' : codegen, secretName), + isSecret: true, }; } diff --git a/packages/playwright-core/src/tools/backend/form.ts b/packages/playwright-core/src/tools/backend/form.ts index ffd0f3a38995c..193e2b3d084e7 100644 --- a/packages/playwright-core/src/tools/backend/form.ts +++ b/packages/playwright-core/src/tools/backend/form.ts @@ -15,7 +15,6 @@ */ import * as z from 'zod'; -import { escapeWithQuotes } from '@isomorphic/stringUtils'; import { defineTabTool } from './tool'; import { elementSchema } from './snapshot'; @@ -39,18 +38,17 @@ const fillForm = defineTabTool({ handle: async (tab, params, response) => { for (const field of params.fields) { - const { locator, resolved } = await tab.targetLocator({ element: field.name, target: field.target }); - const locatorSource = `await page.${resolved}`; + const { locator, selector } = await tab.targetLocator({ element: field.name, target: field.target }); if (field.type === 'textbox' || field.type === 'slider') { const secret = tab.context.lookupSecret(field.value); await locator.fill(secret.value, tab.actionTimeoutOptions); - response.addCode(`${locatorSource}.fill(${secret.code});`); + response.addAction({ name: 'fill', selector, text: secret.isSecret ? `SECRET_${field.value}` : field.value }); } else if (field.type === 'checkbox' || field.type === 'radio') { await locator.setChecked(field.value === 'true', tab.actionTimeoutOptions); - response.addCode(`${locatorSource}.setChecked(${field.value});`); + response.addAction({ name: field.value === 'true' ? 'check' : 'uncheck', selector }); } else if (field.type === 'combobox') { await locator.selectOption({ label: field.value }, tab.actionTimeoutOptions); - response.addCode(`${locatorSource}.selectOption(${escapeWithQuotes(field.value)});`); + response.addAction({ name: 'select', selector, options: [field.value] }); } } }, diff --git a/packages/playwright-core/src/tools/backend/keyboard.ts b/packages/playwright-core/src/tools/backend/keyboard.ts index 34de28a584deb..f8f3abd271a05 100644 --- a/packages/playwright-core/src/tools/backend/keyboard.ts +++ b/packages/playwright-core/src/tools/backend/keyboard.ts @@ -92,7 +92,7 @@ const type = defineTabTool({ }, handle: async (tab, params, response) => { - const { locator, resolved } = await tab.targetLocator(params); + const { locator, resolved, selector } = await tab.targetLocator(params); const secret = tab.context.lookupSecret(params.text); const action = async () => { @@ -101,13 +101,13 @@ const type = defineTabTool({ response.addCode(`await page.${resolved}.pressSequentially(${secret.code});`); await locator.pressSequentially(secret.value, tab.actionTimeoutOptions); } else { - response.addCode(`await page.${resolved}.fill(${secret.code});`); + response.addAction({ name: 'fill', selector, text: secret.isSecret ? `SECRET_${params.text}` : params.text }); await locator.fill(secret.value, tab.actionTimeoutOptions); } if (params.submit) { response.setIncludeSnapshot(); - response.addCode(`await page.${resolved}.press('Enter');`); + response.addAction({ name: 'press', selector, key: 'Enter', modifiers: 0 }); await locator.press('Enter', tab.actionTimeoutOptions); } }; diff --git a/packages/playwright-core/src/tools/backend/navigate.ts b/packages/playwright-core/src/tools/backend/navigate.ts index b3de47e83d991..683f318649463 100644 --- a/packages/playwright-core/src/tools/backend/navigate.ts +++ b/packages/playwright-core/src/tools/backend/navigate.ts @@ -15,7 +15,6 @@ */ import * as z from 'zod'; -import { escapeWithQuotes } from '@isomorphic/stringUtils'; import { defineTool, defineTabTool } from './tool'; const navigate = defineTool({ @@ -36,7 +35,7 @@ const navigate = defineTool({ const url = await tab.checkUrlAndNavigate(params.url); response.setIncludeSnapshot(); - response.addCode(`await page.goto(${escapeWithQuotes(url)});`); + response.addAction({ name: 'navigate', url }); }, }); diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index e73adf48792ba..eb1ed1f0dabb2 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -18,12 +18,15 @@ import fs from 'fs'; import path from 'path'; import debug from 'debug'; +import { actionInContext, renderCode, substituteSecrets } from './codegen'; import { renderModalStates } from './tab'; import { scaleImageToFitMessage } from './screenshot'; import { outputDir as resolveOutputDir } from './context'; import type * as playwright from '../../..'; +import type * as actions from '@isomorphic/codegen/actions'; +import type { CodeItem } from './codegen'; import type { TabHeader } from './tab'; import type { CallToolResult, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import type { Context, FilenameTemplate } from './context'; @@ -42,13 +45,13 @@ type Section = { title: string; content: SectionContent; isError?: boolean; - codeframe?: 'yaml' | 'js' | 'json'; + codeframe?: 'yaml' | 'js' | 'json' | 'python' | 'java' | 'csharp'; }; export class Response { private _results: string[] = []; private _errors: string[] = []; - private _code: string[] = []; + private _code: CodeItem[] = []; private _context: Context; private _includeSnapshot: 'none' | 'full' | 'explicit' = 'none'; private _includeSnapshotFileName: string | undefined; @@ -145,6 +148,10 @@ export class Response { this._code.push(code); } + addAction(action: actions.Action) { + this._code.push(actionInContext(action)); + } + setIncludeSnapshot() { this._includeSnapshot = this._context.config.snapshot?.mode ?? 'full'; this._includeSnapshotBoxes = this._context.config.snapshot?.boxes; @@ -262,7 +269,7 @@ export class Response { private async _build(): Promise { const sections: Section[] = []; - const addSection = (title: string, content: SectionContent, codeframe?: 'yaml' | 'js' | 'json') => { + const addSection = (title: string, content: SectionContent, codeframe?: Section['codeframe']) => { const section = { title, content, isError: title === 'Error', codeframe }; sections.push(section); return content; @@ -275,8 +282,11 @@ export class Response { addSection('Result', this._results); // Code - if (this._context.config.codegen !== 'none' && this._code.length) - addSection('Ran Playwright code', this._code, 'js'); + const codegen = this._context.config.codegen ?? 'typescript'; + if (codegen !== 'none' && this._code.length) { + const code = substituteSecrets(renderCode(this._code, codegen), codegen, Object.keys(this._context.config.secrets ?? {})); + addSection('Ran Playwright code', code, codegen === 'typescript' ? 'js' : codegen); + } // Render tab titles upon changes or when more than one tab. const snapshotToFile = this._includeSnapshot !== 'explicit' || !!this._includeSnapshotFileName; @@ -411,7 +421,7 @@ export function parseResponse(response: CallToolResult, cwd?: string) { const events = sections.get('Events'); const modalState = sections.get('Modal state'); const paused = sections.get('Paused'); - const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, ''); + const codeNoFrame = code?.replace(/^```(?:js|python|java|csharp)\n/, '').replace(/\n```$/, ''); const isError = response.isError; const attachments = response.content.length > 1 ? response.content.slice(1) : undefined; diff --git a/packages/playwright-core/src/tools/backend/snapshot.ts b/packages/playwright-core/src/tools/backend/snapshot.ts index aa2d8db27b7b2..2df46b433e9a6 100644 --- a/packages/playwright-core/src/tools/backend/snapshot.ts +++ b/packages/playwright-core/src/tools/backend/snapshot.ts @@ -15,7 +15,7 @@ */ import * as z from 'zod'; -import { formatObject, formatObjectOrVoid } from '@isomorphic/stringUtils'; +import { fromKeyboardModifiers } from '@isomorphic/codegen/language'; import { defineTabTool } from './tool'; import type * as playwright from '../../..'; @@ -74,18 +74,19 @@ const click = defineTabTool({ handle: async (tab, params, response) => { response.setIncludeSnapshot(); - const { locator, resolved } = await tab.targetLocator(params); + const { locator, selector } = await tab.targetLocator(params); const options = { button: params.button, modifiers: params.modifiers, ...tab.actionTimeoutOptions, }; - const optionsArg = formatObjectOrVoid(options); - - if (params.doubleClick) - response.addCode(`await page.${resolved}.dblclick(${optionsArg});`); - else - response.addCode(`await page.${resolved}.click(${optionsArg});`); + response.addAction({ + name: 'click', + selector, + button: params.button ?? 'left', + modifiers: fromKeyboardModifiers(params.modifiers), + clickCount: params.doubleClick ? 2 : 1, + }); await tab.waitForCompletion(async () => { if (params.doubleClick) @@ -140,8 +141,8 @@ const hover = defineTabTool({ handle: async (tab, params, response) => { response.setIncludeSnapshot(); - const { locator, resolved } = await tab.targetLocator(params); - response.addCode(`await page.${resolved}.hover();`); + const { locator, selector } = await tab.targetLocator(params); + response.addAction({ name: 'hover', selector }); await locator.hover(tab.actionTimeoutOptions); }, @@ -164,8 +165,8 @@ const selectOption = defineTabTool({ handle: async (tab, params, response) => { response.setIncludeSnapshot(); - const { locator, resolved } = await tab.targetLocator(params); - response.addCode(`await page.${resolved}.selectOption(${formatObject(params.values)});`); + const { locator, selector } = await tab.targetLocator(params); + response.addAction({ name: 'select', selector, options: params.values }); await locator.selectOption(params.values, tab.actionTimeoutOptions); }, @@ -200,8 +201,8 @@ const check = defineTabTool({ }, handle: async (tab, params, response) => { - const { locator, resolved } = await tab.targetLocator(params); - response.addCode(`await page.${resolved}.check();`); + const { locator, selector } = await tab.targetLocator(params); + response.addAction({ name: 'check', selector }); await locator.check(tab.actionTimeoutOptions); }, }); @@ -218,8 +219,8 @@ const uncheck = defineTabTool({ }, handle: async (tab, params, response) => { - const { locator, resolved } = await tab.targetLocator(params); - response.addCode(`await page.${resolved}.uncheck();`); + const { locator, selector } = await tab.targetLocator(params); + response.addAction({ name: 'uncheck', selector }); await locator.uncheck(tab.actionTimeoutOptions); }, }); diff --git a/packages/playwright-core/src/tools/backend/tab.ts b/packages/playwright-core/src/tools/backend/tab.ts index 7d17a14e949b1..fff5d14d12ca5 100644 --- a/packages/playwright-core/src/tools/backend/tab.ts +++ b/packages/playwright-core/src/tools/backend/tab.ts @@ -488,12 +488,12 @@ export class Tab extends EventEmitter { await this._raceAgainstModalStates(() => waitForCompletion(this, callback)); } - async targetLocator(params: { element?: string, target: string }): Promise<{ locator: playwright.Locator, resolved: string }> { + async targetLocator(params: { element?: string, target: string }): Promise<{ locator: playwright.Locator, resolved: string, selector: string }> { await this._initializedPromise; return (await this.targetLocators([params]))[0]; } - async targetLocators(params: { element?: string, target: string }[]): Promise<{ locator: playwright.Locator, resolved: string }[]> { + async targetLocators(params: { element?: string, target: string }[]): Promise<{ locator: playwright.Locator, resolved: string, selector: string }[]> { await this._initializedPromise; return Promise.all(params.map(async param => { if (!param.target.match(/^(f\d+)?e\d+$/)) { @@ -502,14 +502,14 @@ export class Tab extends EventEmitter { if (!handle) throw new Error(`"${param.target}" does not match any elements.`); handle.dispose().catch(() => {}); - return { locator: this.page.locator(selector), resolved: asLocator('javascript', selector) }; + return { locator: this.page.locator(selector), resolved: asLocator('javascript', selector), selector }; } else { try { let locator = this.page.locator(`aria-ref=${param.target}`); if (param.element) locator = locator.describe(param.element); const resolved = await locator.normalize(); - return { locator, resolved: resolved.toString() }; + return { locator, resolved: resolved.toString(), selector: locatorSelector(resolved) }; } catch (e) { throw new Error(`Ref ${param.target} not found in the current page snapshot. Try capturing new snapshot.`); } @@ -534,6 +534,10 @@ export type ConsoleMessage = { toString(): string; }; +export function locatorSelector(locator: playwright.Locator): string { + return (locator as unknown as { _selector: string })._selector; +} + function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage { return { type: message.type(), diff --git a/packages/playwright-core/src/tools/backend/tabs.ts b/packages/playwright-core/src/tools/backend/tabs.ts index 8e97521d3f46b..93ad12a02f508 100644 --- a/packages/playwright-core/src/tools/backend/tabs.ts +++ b/packages/playwright-core/src/tools/backend/tabs.ts @@ -15,7 +15,6 @@ */ import * as z from 'zod'; -import { escapeWithQuotes } from '@isomorphic/stringUtils'; import { defineTool } from './tool'; import { renderTabsMarkdown } from './response'; @@ -45,7 +44,7 @@ const browserTabs = defineTool({ if (params.url) { const url = await tab.checkUrlAndNavigate(params.url); response.setIncludeSnapshot(); - response.addCode(`await page.goto(${escapeWithQuotes(url)});`); + response.addAction({ name: 'navigate', url }); } break; } diff --git a/packages/playwright-core/src/tools/backend/verify.ts b/packages/playwright-core/src/tools/backend/verify.ts index 29fd7f59771cd..a27eb72ef5ad8 100644 --- a/packages/playwright-core/src/tools/backend/verify.ts +++ b/packages/playwright-core/src/tools/backend/verify.ts @@ -18,6 +18,7 @@ import * as z from 'zod'; import { escapeWithQuotes } from '@isomorphic/stringUtils'; import { defineTabTool } from './tool'; +import { locatorSelector } from './tab'; import type * as playwright from '../../..'; const verifyElement = defineTabTool({ @@ -38,7 +39,7 @@ const verifyElement = defineTabTool({ const locator = frame.getByRole(params.role as Parameters[0], { name: params.accessibleName }); if (await locator.count() > 0) { const resolved = await locator.normalize(); - response.addCode(`await expect(page.${resolved}).toBeVisible();`); + response.addAction({ name: 'assertVisible', selector: locatorSelector(resolved) }); response.addTextResult('Done'); return; } @@ -64,7 +65,7 @@ const verifyText = defineTabTool({ const locator = frame.getByText(params.text).filter({ visible: true }); if (await locator.count() > 0) { const resolved = await locator.normalize(); - response.addCode(`await expect(page.${resolved}).toBeVisible();`); + response.addAction({ name: 'assertVisible', selector: locatorSelector(resolved) }); response.addTextResult('Done'); return; } @@ -98,11 +99,9 @@ const verifyList = defineTabTool({ } itemTexts.push((await itemLocator.textContent(tab.expectTimeoutOptions))!); } - const ariaSnapshot = `\` -- list: -${itemTexts.map(t => ` - listitem: ${escapeWithQuotes(t, '"')}`).join('\n')} -\``; - response.addCode(`await expect(page.locator('body')).toMatchAriaSnapshot(${ariaSnapshot});`); + const ariaSnapshot = `- list: +${itemTexts.map(t => ` - listitem: ${escapeWithQuotes(t, '"')}`).join('\n')}`; + response.addAction({ name: 'assertSnapshot', selector: 'body', ariaSnapshot }); response.addTextResult('Done'); }, }); @@ -123,23 +122,21 @@ const verifyValue = defineTabTool({ }, handle: async (tab, params, response) => { - const { locator, resolved } = await tab.targetLocator(params); - const locatorSource = `page.${resolved}`; + const { locator, selector } = await tab.targetLocator(params); if (params.type === 'textbox' || params.type === 'slider' || params.type === 'combobox') { const value = await locator.inputValue(tab.expectTimeoutOptions); if (value !== params.value) { response.addError(`Expected value "${params.value}", but got "${value}"`); return; } - response.addCode(`await expect(${locatorSource}).toHaveValue(${escapeWithQuotes(params.value)});`); + response.addAction({ name: 'assertValue', selector, value: params.value }); } else if (params.type === 'checkbox' || params.type === 'radio') { const value = await locator.isChecked(tab.expectTimeoutOptions); if (value !== (params.value === 'true')) { response.addError(`Expected value "${params.value}", but got "${value}"`); return; } - const matcher = value ? 'toBeChecked' : 'not.toBeChecked'; - response.addCode(`await expect(${locatorSource}).${matcher}();`); + response.addAction({ name: 'assertChecked', selector, checked: value }); } response.addTextResult('Done'); }, diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 2de9348bf62b7..e24ae1a6c42d8 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -26,16 +26,15 @@ */ import { spawn } from 'child_process'; -import http from 'http'; import os from 'os'; import debug from 'debug'; -import ws, { WebSocketServer as wsServer } from 'ws'; +import ws from 'ws'; import { ManualPromise } from '@isomorphic/manualPromise'; +import { WSServer } from '@utils/wsServer'; import { registry } from '../../server/registry/index'; import { findPlaywrightExtensionProfile, playwrightExtensionId } from '../utils/extension'; -import { addressToString } from '../utils/mcp/http'; import { logUnhandledError } from './log'; import { ExtensionProtocolV2 } from './cdpRelayV2'; import * as protocol from './protocol'; @@ -43,7 +42,7 @@ import * as protocol from './protocol'; import type websocket from 'ws'; import type { ExtensionCommandV2, ExtensionEventsV2 } from './protocol'; import type { CDPMessage } from './browserModel'; -import type { WebSocket, WebSocketServer } from 'ws'; +import type { WebSocket } from 'ws'; const debugLogger = debug('pw:mcp:relay'); @@ -58,23 +57,20 @@ type CDPCommand = { type CDPResponse = CDPMessage; export class CDPRelayServer { - private _httpServer: http.Server; - private _wsHost: string; + private _wsServer: WSServer; + private _wsHost!: string; private _browserChannel: string; private _executablePath?: string; private _userDataDir?: string; private _cdpPath: string; private _extensionPath: string; - private _wss: WebSocketServer; private _cdpConnection: WebSocket | null = null; private _extensionConnection: ExtensionConnection | null = null; private _protocolVersion: number; private _handler: ExtensionProtocolV2; private _extensionConnectionPromise = new ManualPromise(); - constructor(server: http.Server, browserChannel: string, executablePath?: string, userDataDir?: string) { - this._httpServer = server; - this._wsHost = addressToString(server.address(), { protocol: 'ws' }); + constructor(browserChannel: string, executablePath?: string, userDataDir?: string) { this._browserChannel = browserChannel; this._executablePath = executablePath; this._userDataDir = userDataDir; @@ -92,8 +88,27 @@ export class CDPRelayServer { this._extensionPath = `/extension/${uuid}`; void this._extensionConnectionPromise.catch(logUnhandledError); - this._wss = new wsServer({ server }); - this._wss.on('connection', this._onConnection.bind(this)); + this._wsServer = new WSServer({ + onRequest: (request, response) => { + response.statusCode = 404; + response.end(); + }, + onHeaders: () => {}, + onUpgrade: () => undefined, + isAllowedPathname: pathname => pathname === this._cdpPath || pathname === this._extensionPath, + onConnection: (request, url, ws) => { + debugLogger(`New connection to ${url.pathname}`); + if (url.pathname === this._cdpPath) + this._handlePlaywrightConnection(ws); + else + this._handleExtensionConnection(ws); + return undefined; + }, + }); + } + + async start(): Promise { + this._wsHost = await this._wsServer.listen(0, undefined, ''); } cdpEndpoint() { @@ -161,8 +176,7 @@ export class CDPRelayServer { stop(): void { this._closeConnections('Server stopped'); - this._wss.close(); - this._httpServer.close(); + void this._wsServer.close().catch(logUnhandledError); } private _closeConnections(reason: string) { @@ -170,19 +184,6 @@ export class CDPRelayServer { this._closeExtensionConnection(reason); } - private _onConnection(ws: WebSocket, request: http.IncomingMessage): void { - const url = new URL(`http://localhost${request.url}`); - debugLogger(`New connection to ${url.pathname}`); - if (url.pathname === this._cdpPath) { - this._handlePlaywrightConnection(ws); - } else if (url.pathname === this._extensionPath) { - this._handleExtensionConnection(ws); - } else { - debugLogger(`Invalid path: ${url.pathname}`); - ws.close(4004, 'Invalid path'); - } - } - private _handlePlaywrightConnection(ws: WebSocket): void { if (!this._extensionConnection) { debugLogger('Rejecting Playwright connection: extension not connected'); diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 9dbda16a1f9ac..08863db5ee056 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -246,5 +246,5 @@ export type Config = { /** * Specify the language to use for code generation. */ - codegen?: 'typescript' | 'none'; + codegen?: 'typescript' | 'python' | 'java' | 'csharp' | 'none'; }; diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 85f634a850f80..8125fd348d0f0 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -43,7 +43,7 @@ export type CLIOptions = { cdpEndpoint?: string; cdpHeader?: Record; cdpTimeout?: number; - codegen?: 'typescript' | 'none'; + codegen?: 'typescript' | 'python' | 'java' | 'csharp' | 'none'; config?: string; consoleLevel?: 'error' | 'warning' | 'info' | 'debug'; device?: string; diff --git a/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts b/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts index 8c3f6f3e8bac7..4d8b554d92d55 100644 --- a/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts +++ b/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts @@ -15,7 +15,6 @@ */ import debug from 'debug'; -import { createHttpServer, startHttpServer } from '@utils/network'; import { defaultUserDataDirForChannel } from '@utils/chromiumChannels'; import { playwright } from '../../inprocess'; import { isPlaywrightExtensionInstalled, playwrightExtensionInstallUrl } from '../utils/extension'; @@ -34,14 +33,13 @@ export async function createExtensionBrowser(channel: string, executablePath: st throw new Error(`Playwright Extension not found in "${userDataDir}". Install it from ${playwrightExtensionInstallUrl}`); } - const httpServer = createHttpServer(); - await startHttpServer(httpServer, {}); - const relay = new CDPRelayServer(httpServer, channel, executablePath, userDataDir); + const relay = new CDPRelayServer(channel, executablePath, userDataDir); + await relay.start(); debugLogger(`CDP relay server started, extension endpoint: ${relay.extensionEndpoint()}.`); try { await relay.establishExtensionConnection(clientName); - const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true, timeout: 0 }); + const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true, timeout: 0, noDefaults: true }); browser.on('disconnected', () => relay.stop()); return browser; } catch (error) { diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 29b2e6ba33925..cc7de4f504d3a 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -42,7 +42,7 @@ export function decorateMCPCommand(command: Command) { .option('--cdp-endpoint ', 'CDP endpoint to connect to.') .option('--cdp-header ', 'CDP headers to send with the connect request, multiple can be specified.', headerParser) .option('--cdp-timeout ', 'timeout in milliseconds for connecting to CDP endpoint, defaults to 30000ms', numberParser) - .option('--codegen ', 'specify the language to use for code generation, possible values: "typescript", "none". Default is "typescript".', enumParser.bind(null, '--codegen', ['none', 'typescript'])) + .option('--codegen ', 'specify the language to use for code generation, possible values: "typescript", "python", "java", "csharp", "none". Default is "typescript".', enumParser.bind(null, '--codegen', ['none', 'typescript', 'python', 'java', 'csharp'])) .option('--config ', 'path to the configuration file.') .option('--console-level ', 'level of console messages to return: "error", "warning", "info", "debug". Each level includes the messages of more severe levels.', enumParser.bind(null, '--console-level', ['error', 'warning', 'info', 'debug'])) .option('--device ', 'device to emulate, for example: "iPhone 15"') diff --git a/packages/utils/httpServer.ts b/packages/utils/httpServer.ts index 4e36467248c14..e85b30d4af686 100644 --- a/packages/utils/httpServer.ts +++ b/packages/utils/httpServer.ts @@ -89,6 +89,11 @@ export class HttpServer { const pathname = new URL(request.url ?? '/', 'http://localhost').pathname; if (pathname !== wsPath) return; + if (!isAllowedHost(request, this._allowedHosts)) { + socket.write(`HTTP/${request.httpVersion} 403 Forbidden\r\n\r\n`); + socket.destroy(); + return; + } wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request)); }); // HMR end @@ -268,14 +273,10 @@ export class HttpServer { return; } - if (this._allowedHosts) { - const host = request.headers.host?.toLowerCase(); - const hostname = host ? hostnameFromHostHeader(host) : undefined; - if (!hostname || !this._allowedHosts.has(hostname)) { - response.statusCode = 403; - response.end(); - return; - } + if (!isAllowedHost(request, this._allowedHosts)) { + response.statusCode = 403; + response.end(); + return; } request.on('error', () => response.end()); @@ -309,12 +310,21 @@ export function computeAllowedHosts(requested: string | undefined, bound: string return new Set(['localhost', '127.0.0.1', '[::1]']); } +// A null allowlist disables the check (server deliberately bound to a public address). +export function isAllowedHost(request: http.IncomingMessage, allowedHosts: Set | null): boolean { + if (!allowedHosts) + return true; + const host = request.headers.host?.toLowerCase(); + const hostname = host ? hostnameFromHostHeader(host) : undefined; + return !!hostname && allowedHosts.has(hostname); +} + // Bracket IPv6 literals so they can be used as the host part of a URL. export function urlHostFromAddress(address: { address: string, family: string }): string { return address.family === 'IPv6' ? `[${address.address}]` : address.address; } -export function hostnameFromHostHeader(host: string): string { +function hostnameFromHostHeader(host: string): string { if (host.startsWith('[')) { const end = host.indexOf(']'); return end < 0 ? host : host.substring(0, end + 1); diff --git a/packages/utils/wsServer.ts b/packages/utils/wsServer.ts index 93625ecb60ed0..1715dea87c4f8 100644 --- a/packages/utils/wsServer.ts +++ b/packages/utils/wsServer.ts @@ -15,7 +15,7 @@ */ import { WebSocketServer as wsServer } from 'ws'; -import { computeAllowedHosts, hostnameFromHostHeader, urlHostFromAddress } from './httpServer'; +import { computeAllowedHosts, isAllowedHost, urlHostFromAddress } from './httpServer'; import { createHttpServer } from './network'; import { debugLogger } from './debugLogger'; @@ -46,7 +46,8 @@ export type WSServerDelegate = { onRequest: (request: http.IncomingMessage, response: http.ServerResponse) => void; onHeaders: (headers: string[]) => void; onUpgrade: (request: http.IncomingMessage, socket: stream.Duplex) => { error: string } | undefined; - onConnection: (request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) => WSConnection; + onConnection: (request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) => WSConnection | undefined; + isAllowedPathname: (pathname: string) => boolean; }; export class WSServer { @@ -60,7 +61,7 @@ export class WSServer { this._delegate = delegate; } - async listen(port: number = 0, hostname: string | undefined, path: string): Promise { + async listen(port: number = 0, hostname: string | undefined, defaultPath: string): Promise { debugLogger.log('server', `Server started at ${new Date()}`); // Default to loopback so the WebSocket RPC is not exposed to the network unless @@ -79,14 +80,14 @@ export class WSServer { return; } if (typeof address === 'string') { - resolve(`${address}${path}`); + resolve(`${address}${defaultPath}`); return; } // Advertise the bound IP literal in the wsEndpoint so the client connects to // the same address family the server bound to. Otherwise the client and // server resolvers can disagree on what 'localhost' means (see #40605). this._allowedHosts = computeAllowedHosts(hostname, address.address); - resolve(`ws://${urlHostFromAddress(address)}:${address.port}${path}`); + resolve(`ws://${urlHostFromAddress(address)}:${address.port}${defaultPath}`); }).on('error', reject); }); @@ -101,12 +102,12 @@ export class WSServer { server.on('upgrade', (request, socket, head) => { const pathname = new URL('http://localhost' + request.url!).pathname; - if (pathname !== path) { + if (!this._delegate.isAllowedPathname(pathname)) { socket.write(`HTTP/${request.httpVersion} 400 Bad Request\r\n\r\n`); socket.destroy(); return; } - if (this._allowedHosts && !this._isAllowedOrigin(request.headers.origin)) { + if (!isAllowedHost(request, this._allowedHosts) || !this._isAllowedOrigin(request.headers.origin)) { socket.write(`HTTP/${request.httpVersion} 403 Forbidden\r\n\r\n`); socket.destroy(); return; @@ -133,25 +134,26 @@ export class WSServer { } private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { - if (this._allowedHosts) { - const host = request.headers.host?.toLowerCase(); - const hostname = host ? hostnameFromHostHeader(host) : undefined; - if (!hostname || !this._allowedHosts.has(hostname)) { - response.statusCode = 403; - response.end(); - return; - } + if (!isAllowedHost(request, this._allowedHosts)) { + response.statusCode = 403; + response.end(); + return; } this._delegate.onRequest(request, response); } private _isAllowedOrigin(origin: string | undefined): boolean { - if (!origin) + if (!this._allowedHosts || !origin) return true; try { - const hostname = new URL(origin).hostname.toLowerCase(); + const url = new URL(origin); + // Only web page origins are subject to the check; e.g. browser + // extensions are allowed. + if (url.protocol !== 'http:' && url.protocol !== 'https:') + return true; + const hostname = url.hostname.toLowerCase(); const bracketed = hostname.includes(':') ? `[${hostname}]` : hostname; - return this._allowedHosts!.has(hostname) || this._allowedHosts!.has(bracketed); + return this._allowedHosts.has(hostname) || this._allowedHosts.has(bracketed); } catch { return false; } diff --git a/tests/extension/extension.spec.ts b/tests/extension/extension.spec.ts index 17574346072f0..ba6a64a1f6d17 100644 --- a/tests/extension/extension.spec.ts +++ b/tests/extension/extension.spec.ts @@ -17,6 +17,8 @@ import fs from 'fs/promises'; import path from 'path'; +import WebSocket from 'ws'; + import { test, testWithOldExtensionVersion, expect, extensionId, clickAllowAndSelect, connectAndNavigate, readExtensionToken, startWithExtensionFlag } from './extension-fixtures'; import { utils } from '../../packages/playwright-core/lib/coreBundle'; @@ -171,6 +173,37 @@ test(`snapshot of an existing page`, async ({ browserWithExtension, startClient, }); }); +test(`extension connection uses noDefaults`, { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42117' }, +}, async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + await page.emulateMedia({ media: 'print' }); + expect(await page.evaluate(() => matchMedia('print').matches)).toBe(true); + + const client = await startWithExtensionFlag(browserWithExtension, startClient); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + const snapshotResponse = client.callTool({ + name: 'browser_snapshot', + arguments: { }, + }); + + const selectorPage = await confirmationPagePromise; + await clickAllowAndSelect(selectorPage, 'Title'); + + expect(await snapshotResponse).toHaveResponse({ + inlineSnapshot: expect.stringContaining(`Hello, world!`), + }); + + expect(await page.evaluate(() => matchMedia('print').matches)).toBe(true); +}); + testWithOldExtensionVersion(`works with old extension version`, async ({ startExtensionClient, server }) => { // Prelaunch the browser, so that it is properly closed after the test. const { browserContext, client } = await startExtensionClient(); @@ -405,3 +438,45 @@ test(`reconnects after the extension connection drops`, { snapshot: expect.stringContaining(`Hello, world!`), }); }); + +test(`relay rejects websocket upgrades with forged host or origin`, { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1694' }, +}, async ({ startExtensionClient, server }) => { + const { browserContext, client } = await startExtensionClient(); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + const connectPage = await confirmationPagePromise; + const relayUrl = new URL(connectPage.url()).searchParams.get('mcpRelayUrl')!; + expect(relayUrl).toBeTruthy(); + await clickAllowAndSelect(connectPage, 'Welcome'); + await navigateResponse; + + expect(await wsUpgradeResult(relayUrl, { host: 'evil.com' })).toBe(403); + expect(await wsUpgradeResult(relayUrl, { host: 'evil.com:80' })).toBe(403); + expect(await wsUpgradeResult(relayUrl, { origin: 'http://evil.com' })).toBe(403); + expect(await wsUpgradeResult(relayUrl, { origin: 'https://evil.com' })).toBe(403); + + // Control: default headers pass the upgrade validation. + expect(await wsUpgradeResult(relayUrl)).toBe('connected'); +}); + +function wsUpgradeResult(url: string, headers?: Record): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, { headers }); + ws.on('open', () => { + ws.close(); + resolve('connected'); + }); + ws.on('unexpected-response', (request, response) => { + request.destroy(); + resolve(response.statusCode!); + }); + ws.on('error', reject); + }); +} diff --git a/tests/mcp/cli-core.spec.ts b/tests/mcp/cli-core.spec.ts index cf909bb2c331f..264458dccb9e4 100644 --- a/tests/mcp/cli-core.spec.ts +++ b/tests/mcp/cli-core.spec.ts @@ -79,10 +79,10 @@ test('click with --modifiers', { annotation: { type: 'issue', description: 'http await cli('open', server.PREFIX); const single = await cli('click', 'e2', '--modifiers', 'Control'); - expect(single.output).toContain(`await page.getByRole('button', { name: 'Submit' }).click({\n modifiers: ['Control']\n});`); + expect(single.output).toContain(`await page.getByRole('button', { name: 'Submit' }).click({\n modifiers: ['ControlOrMeta']\n});`); const repeated = await cli('click', 'e2', '--modifiers', 'Control', '--modifiers', 'Shift'); - expect(repeated.output).toContain(`await page.getByRole('button', { name: 'Submit' }).click({\n modifiers: ['Control', 'Shift']\n});`); + expect(repeated.output).toContain(`await page.getByRole('button', { name: 'Submit' }).click({\n modifiers: ['ControlOrMeta', 'Shift']\n});`); }); test('type', async ({ cli, server }) => { diff --git a/tests/mcp/click.spec.ts b/tests/mcp/click.spec.ts index 67b950fa05bc9..b61303133a9d4 100644 --- a/tests/mcp/click.spec.ts +++ b/tests/mcp/click.spec.ts @@ -136,7 +136,7 @@ test('browser_click (modifiers)', async ({ client, server, mcpBrowser }) => { })).toHaveResponse({ code: [ `await page.getByRole('button', { name: 'Submit' }).click({`, - ` modifiers: ['Control']`, + ` modifiers: ['ControlOrMeta']`, `});` ].join('\n'), snapshot: expect.stringContaining(`generic [ref=e3]: ctrlKey:true metaKey:false shiftKey:false altKey:false`), @@ -169,7 +169,7 @@ test('browser_click (modifiers)', async ({ client, server, mcpBrowser }) => { })).toHaveResponse({ code: [ `await page.getByRole('button', { name: 'Submit' }).click({`, - ` modifiers: ['Shift', 'Alt']`, + ` modifiers: ['Alt', 'Shift']`, `});` ].join('\n'), snapshot: expect.stringContaining(`generic [ref=e3]: ctrlKey:false metaKey:false shiftKey:true altKey:true`), diff --git a/tests/mcp/codegen.spec.ts b/tests/mcp/codegen.spec.ts new file mode 100644 index 0000000000000..6fa811393c18d --- /dev/null +++ b/tests/mcp/codegen.spec.ts @@ -0,0 +1,158 @@ +/** + * 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 fs from 'fs'; +import { test, expect } from './fixtures'; + +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +async function navigateToForm(client: Client, server: any) { + server.setContent('/', ` + Title + + + `, 'text/html'); + return await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); +} + +test('codegen python', async ({ startClient, server }) => { + const { client } = await startClient({ args: ['--codegen=python'] }); + expect(await navigateToForm(client, server)).toHaveResponse({ + code: `page.goto("${server.PREFIX}")`, + }); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { element: 'Submit button', target: 'e2' }, + })).toHaveResponse({ + code: `page.get_by_role("button", name="Submit").click()`, + }); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { element: 'Submit button', target: 'e2', modifiers: ['Control'] }, + })).toHaveResponse({ + code: `page.get_by_role("button", name="Submit").click(modifiers=["ControlOrMeta"])`, + }); + + expect(await client.callTool({ + name: 'browser_type', + arguments: { element: 'textbox', target: 'e3', text: `it's a secret`, submit: true }, + })).toHaveResponse({ + code: `page.get_by_role("textbox").fill("it's a secret")\npage.get_by_role("textbox").press("Enter")`, + }); + + // Page-level keyboard input has no action equivalent and stays as JavaScript. + expect(await client.callTool({ + name: 'browser_press_key', + arguments: { key: 'Escape' }, + })).toHaveResponse({ + code: `// Press Escape\nawait page.keyboard.press('Escape');`, + }); +}); + +test('codegen java', async ({ startClient, server }) => { + const { client } = await startClient({ args: ['--codegen=java'] }); + expect(await navigateToForm(client, server)).toHaveResponse({ + code: `page.navigate("${server.PREFIX}");`, + }); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { element: 'Submit button', target: 'e2' }, + })).toHaveResponse({ + code: `page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).click();`, + }); + + expect(await client.callTool({ + name: 'browser_type', + arguments: { element: 'textbox', target: 'e3', text: 'hello', submit: false }, + })).toHaveResponse({ + code: `page.getByRole(AriaRole.TEXTBOX).fill("hello");`, + }); +}); + +test('codegen csharp', async ({ startClient, server }) => { + const { client } = await startClient({ args: ['--codegen=csharp'] }); + expect(await navigateToForm(client, server)).toHaveResponse({ + code: `await page.GotoAsync("${server.PREFIX}");`, + }); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { element: 'Submit button', target: 'e2' }, + })).toHaveResponse({ + code: `await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`, + }); + + expect(await client.callTool({ + name: 'browser_type', + arguments: { element: 'textbox', target: 'e3', text: 'hello', submit: false }, + })).toHaveResponse({ + code: `await page.GetByRole(AriaRole.Textbox).FillAsync("hello");`, + }); +}); + +test('codegen verify tools', async ({ startClient, server }) => { + const { client } = await startClient({ args: ['--codegen=python', '--caps=testing'] }); + await navigateToForm(client, server); + await client.callTool({ + name: 'browser_type', + arguments: { element: 'textbox', target: 'e3', text: 'hello', submit: false }, + }); + + expect(await client.callTool({ + name: 'browser_verify_value', + arguments: { type: 'textbox', element: 'textbox', target: 'e3', value: 'hello' }, + })).toHaveResponse({ + code: `expect(page.get_by_role("textbox")).to_have_value("hello")`, + }); +}); + +test('codegen renders secrets as environment lookups', async ({ startClient, server }) => { + const secretsFile = test.info().outputPath('secrets.env'); + await fs.promises.writeFile(secretsFile, 'X-PASSWORD=password123'); + + for (const [language, code] of [ + ['typescript', `await page.getByRole('textbox').fill(process.env['X-PASSWORD']);`], + ['python', `page.get_by_role("textbox").fill(os.environ["X-PASSWORD"])`], + ['java', `page.getByRole(AriaRole.TEXTBOX).fill(System.getenv("X-PASSWORD"));`], + ['csharp', `await page.GetByRole(AriaRole.Textbox).FillAsync(Environment.GetEnvironmentVariable("X-PASSWORD"));`], + ] as const) { + const { client } = await startClient({ args: [`--codegen=${language}`, '--secrets', secretsFile] }); + await navigateToForm(client, server); + expect(await client.callTool({ + name: 'browser_type', + arguments: { element: 'textbox', target: 'e3', text: 'X-PASSWORD', submit: false }, + })).toHaveResponse({ code }); + await client.close(); + } +}); + +test('codegen falls back to JavaScript for scripted lines', async ({ startClient, server }) => { + const { client } = await startClient({ args: ['--codegen=python'] }); + await navigateToForm(client, server); + + expect(await client.callTool({ + name: 'browser_evaluate', + arguments: { function: '() => document.title' }, + })).toHaveResponse({ + code: `await page.evaluate('() => document.title');`, + }); +}); diff --git a/tests/mcp/form.spec.ts b/tests/mcp/form.spec.ts index 46e5c6a8bcb6f..9a6fa3cdd06f7 100644 --- a/tests/mcp/form.spec.ts +++ b/tests/mcp/form.spec.ts @@ -97,7 +97,7 @@ test('browser_fill_form (textbox)', async ({ client, server }) => { await page.getByRole('textbox', { name: 'Email' }).fill('john.doe@example.com'); await page.getByRole('slider', { name: 'Age' }).fill('25'); await page.getByLabel('Choose a country United').selectOption('United States'); -await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).setChecked(true);`, +await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).check();`, }); const response = await client.callTool({ diff --git a/tests/mcp/verify.spec.ts b/tests/mcp/verify.spec.ts index a98a00fa6f080..ebacc7525b9e6 100644 --- a/tests/mcp/verify.spec.ts +++ b/tests/mcp/verify.spec.ts @@ -261,11 +261,11 @@ test('browser_verify_list_visible', async ({ client, server }) => { })).toHaveResponse({ result: 'Done', code: expect.stringContaining(`await expect(page.locator('body')).toMatchAriaSnapshot(\` -- list: - - listitem: "Apple" - - listitem: "Banana" - - listitem: "Cherry" -\`);`), + - list: + - listitem: "Apple" + - listitem: "Banana" + - listitem: "Cherry" + \`);`), }); }); @@ -295,10 +295,10 @@ test('browser_verify_list_visible (partial items)', async ({ client, server }) = })).toHaveResponse({ result: 'Done', code: expect.stringContaining(`await expect(page.locator('body')).toMatchAriaSnapshot(\` -- list: - - listitem: "Apple" - - listitem: "Cherry" -\`);`), + - list: + - listitem: "Apple" + - listitem: "Cherry" + \`);`), }); });