Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/isomorphic/codegen/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)})`;
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/remote/playwrightServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -61,8 +65,8 @@ export class PlaywrightWebSocketServer {
};
}

async listen(port: number = 0, hostname?: string, path?: string): Promise<string> {
return await this._wsServer.listen(port, hostname, path || '/');
async listen(port: number = 0, hostname?: string): Promise<string> {
return await this._wsServer.listen(port, hostname, this._path);
}

async close() {
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/server/bidi/bidiConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/server/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
86 changes: 86 additions & 0 deletions packages/playwright-core/src/tools/backend/codegen.ts
Original file line number Diff line number Diff line change
@@ -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');
}
11 changes: 7 additions & 4 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '../../..';
Expand All @@ -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?: {
Expand Down Expand Up @@ -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,
};
}

Expand Down
10 changes: 4 additions & 6 deletions packages/playwright-core/src/tools/backend/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/

import * as z from 'zod';
import { escapeWithQuotes } from '@isomorphic/stringUtils';

import { defineTabTool } from './tool';
import { elementSchema } from './snapshot';
Expand All @@ -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] });
}
}
},
Expand Down
6 changes: 3 additions & 3 deletions packages/playwright-core/src/tools/backend/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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);
}
};
Expand Down
3 changes: 1 addition & 2 deletions packages/playwright-core/src/tools/backend/navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/

import * as z from 'zod';
import { escapeWithQuotes } from '@isomorphic/stringUtils';
import { defineTool, defineTabTool } from './tool';

const navigate = defineTool({
Expand All @@ -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 });
},
});

Expand Down
22 changes: 16 additions & 6 deletions packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -262,7 +269,7 @@ export class Response {

private async _build(): Promise<Section[]> {
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading