Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ The Playwright MCP provides a set of tools for browser automation. Here are all
- `ref` (string): Exact target element reference from the page snapshot
- `values` (array): Array of values to select in the dropdown.

- **browser_choose_file**
- Description: Choose one or multiple files to upload
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.

- **browser_press_key**
- Description: Press a key on the keyboard
- Parameters:
Expand Down Expand Up @@ -291,6 +296,11 @@ Vision Mode provides tools for visual-based interactions using screenshots. Here
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`

- **browser_choose_file**
- Description: Choose one or multiple files to upload
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.

- **browser_save_as_pdf**
- Description: Save page as PDF
- Parameters: None
Expand Down
14 changes: 14 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class Context {
private _page: playwright.Page | undefined;
private _console: playwright.ConsoleMessage[] = [];
private _createPagePromise: Promise<playwright.Page> | undefined;
private _fileChooser: playwright.FileChooser | undefined;

constructor(userDataDir: string, launchOptions?: playwright.LaunchOptions) {
this._userDataDir = userDataDir;
Expand All @@ -40,6 +41,7 @@ export class Context {
this._console.length = 0;
});
page.on('close', () => this._onPageClose());
page.on('filechooser', chooser => this._fileChooser = chooser);
this._page = page;
this._browser = browser;
return page;
Expand All @@ -55,6 +57,7 @@ export class Context {
this._createPagePromise = undefined;
this._browser = undefined;
this._page = undefined;
this._fileChooser = undefined;
this._console.length = 0;
}

Expand All @@ -74,6 +77,17 @@ export class Context {
await this._page.close();
}

async submitFileChooser(paths: string[]) {
if (!this._fileChooser)
throw new Error('No file chooser visible');
await this._fileChooser.setFiles(paths);
this._fileChooser = undefined;
}

hasFileChooser() {
return !!this._fileChooser;
}

private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> {
if (process.env.PLAYWRIGHT_WS_ENDPOINT) {
const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT);
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const snapshotTools: Tool[] = [
common.navigate(true),
common.goBack(true),
common.goForward(true),
common.chooseFile(true),
snapshot.snapshot,
snapshot.click,
snapshot.hover,
Expand All @@ -49,6 +50,7 @@ const screenshotTools: Tool[] = [
common.navigate(false),
common.goBack(false),
common.goForward(false),
common.chooseFile(false),
screenshot.screenshot,
screenshot.moveMouse,
screenshot.click,
Expand Down
20 changes: 19 additions & 1 deletion src/tools/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const navigate: ToolFactory = snapshot => ({
// Cap load event to 5 seconds, the page is operational at this point.
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
if (snapshot)
return captureAriaSnapshot(page);
return captureAriaSnapshot(context, page);
return {
content: [{
type: 'text',
Expand Down Expand Up @@ -156,3 +156,21 @@ export const close: Tool = {
};
},
};

const chooseFileSchema = z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
});

export const chooseFile: ToolFactory = snapshot => ({
schema: {
name: 'browser_choose_file',
description: 'Choose one or multiple files to upload',
inputSchema: zodToJsonSchema(chooseFileSchema),
},
handle: async (context, params) => {
const validatedParams = chooseFileSchema.parse(params);
return await runAndWait(context, `Chose files ${validatedParams.paths.join(', ')}`, async () => {
await context.submitFileChooser(validatedParams.paths);
}, snapshot);
},
});
2 changes: 1 addition & 1 deletion src/tools/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const snapshot: Tool = {
},

handle: async context => {
return await captureAriaSnapshot(context.existingPage());
return await captureAriaSnapshot(context, context.existingPage());
},
};

Expand Down
29 changes: 18 additions & 11 deletions src/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ async function waitForCompletion<R>(page: playwright.Page, callback: () => Promi
export async function runAndWait(context: Context, status: string, callback: (page: playwright.Page) => Promise<any>, snapshot: boolean = false): Promise<ToolResult> {
const page = context.existingPage();
await waitForCompletion(page, () => callback(page));
return snapshot ? captureAriaSnapshot(page, status) : {
return snapshot ? captureAriaSnapshot(context, page, status) : {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd clear the file chooser after the action.

content: [{ type: 'text', text: status }],
};
}
Expand All @@ -89,16 +89,23 @@ export async function captureAllFrameSnapshot(page: playwright.Page): Promise<st
return scopedSnapshots.join('\n');
}

export async function captureAriaSnapshot(page: playwright.Page, status: string = ''): Promise<ToolResult> {
export async function captureAriaSnapshot(context: Context, page: playwright.Page, status: string = ''): Promise<ToolResult> {
const lines = [];
if (status)
lines.push(`${status}\n`);
lines.push(
`- Page URL: ${page.url()}`,
`- Page Title: ${await page.title()}`
);
if (context.hasFileChooser())
lines.push(`- There is a file chooser visible that requires browser_choose_file to be called`);
lines.push(
`- Page Snapshot`,
'```yaml',
await captureAllFrameSnapshot(page),
'```',
);
return {
content: [{ type: 'text', text: `${status ? `${status}\n` : ''}
- Page URL: ${page.url()}
- Page Title: ${await page.title()}
- Page Snapshot
\`\`\`yaml
${await captureAllFrameSnapshot(page)}
\`\`\`
`
}],
content: [{ type: 'text', text: lines.join('\n') }],
};
}
52 changes: 52 additions & 0 deletions tests/basic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import fs from 'fs/promises';
import { test, expect } from './fixtures';

test('test tool list', async ({ server, visionServer }) => {
Expand All @@ -36,6 +37,9 @@ test('test tool list', async ({ server, visionServer }) => {
expect.objectContaining({
name: 'browser_go_forward',
}),
expect.objectContaining({
name: 'browser_choose_file',
}),
expect.objectContaining({
name: 'browser_snapshot',
}),
Expand Down Expand Up @@ -450,3 +454,51 @@ test('stitched aria frames', async ({ server }) => {
},
}));
});

test('browser_choose_file', async ({ server }) => {
let response = await server.send({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /></html>',
},
},
});

expect(response.result.content[0].text).toContain('- textbox [ref=s1e4]');

response = await server.send({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's1e4',
},
},
});

expect(response.result.content[0].text).toContain('There is a file chooser visible that requires browser_choose_file to be called');

const filePath = test.info().outputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!');
response = await server.send({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_choose_file',
arguments: {
paths: [filePath],
},
},
});

expect(response.result.content[0].text).not.toContain('There is a file chooser visible that requires browser_choose_file to be called');
expect(response.result.content[0].text).toContain('textbox [ref=s3e4]: C:\\fakepath\\test.txt');
});