mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-26 08:32:26 +08:00
chore: move state to tab, do not cache snapshot (#730)
This commit is contained in:
parent
cfcca40b90
commit
468c84eb8f
@ -60,13 +60,6 @@ export function createConnection(config: FullConfig, browserContextFactory: Brow
|
|||||||
if (!tool)
|
if (!tool)
|
||||||
return errorResult(`Tool "${request.params.name}" not found`);
|
return errorResult(`Tool "${request.params.name}" not found`);
|
||||||
|
|
||||||
|
|
||||||
const modalStates = context.modalStates().map(state => state.type);
|
|
||||||
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
|
||||||
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
|
|
||||||
if (!tool.clearsModalState && modalStates.length)
|
|
||||||
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await context.run(tool, request.params.arguments);
|
return await context.run(tool, request.params.arguments);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
168
src/context.ts
168
src/context.ts
@ -17,19 +17,12 @@
|
|||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
|
||||||
import { ManualPromise } from './manualPromise.js';
|
|
||||||
import { Tab } from './tab.js';
|
import { Tab } from './tab.js';
|
||||||
import { outputFile } from './config.js';
|
|
||||||
|
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
import type { Tool } from './tools/tool.js';
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
type PendingAction = {
|
|
||||||
dialogShown: ManualPromise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const testDebug = debug('pw:mcp:test');
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
@ -39,9 +32,6 @@ export class Context {
|
|||||||
private _browserContextFactory: BrowserContextFactory;
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
|
||||||
private _pendingAction: PendingAction | undefined;
|
|
||||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
|
||||||
clientVersion: { name: string; version: string; } | undefined;
|
clientVersion: { name: string; version: string; } | undefined;
|
||||||
|
|
||||||
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
||||||
@ -51,42 +41,13 @@ export class Context {
|
|||||||
testDebug('create context');
|
testDebug('create context');
|
||||||
}
|
}
|
||||||
|
|
||||||
clientSupportsImages(): boolean {
|
|
||||||
if (this.config.imageResponses === 'omit')
|
|
||||||
return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
modalStates(): ModalState[] {
|
|
||||||
return this._modalStates;
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalState(modalState: ModalState, inTab: Tab) {
|
|
||||||
this._modalStates.push({ ...modalState, tab: inTab });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearModalState(modalState: ModalState) {
|
|
||||||
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
|
||||||
}
|
|
||||||
|
|
||||||
modalStatesMarkdown(): string[] {
|
|
||||||
const result: string[] = ['### Modal state'];
|
|
||||||
if (this._modalStates.length === 0)
|
|
||||||
result.push('- There is no modal state present');
|
|
||||||
for (const state of this._modalStates) {
|
|
||||||
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
|
||||||
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
tabs(): Tab[] {
|
tabs(): Tab[] {
|
||||||
return this._tabs;
|
return this._tabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentTabOrDie(): Tab {
|
currentTabOrDie(): Tab {
|
||||||
if (!this._currentTab)
|
if (!this._currentTab)
|
||||||
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
|
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
|
||||||
return this._currentTab;
|
return this._currentTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,9 +70,9 @@ export class Context {
|
|||||||
return this._currentTab!;
|
return this._currentTab!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTabsMarkdown(): Promise<string> {
|
async listTabsMarkdown(): Promise<string[]> {
|
||||||
if (!this._tabs.length)
|
if (!this._tabs.length)
|
||||||
return '### No tabs open';
|
return ['### No tabs open'];
|
||||||
const lines: string[] = ['### Open tabs'];
|
const lines: string[] = ['### Open tabs'];
|
||||||
for (let i = 0; i < this._tabs.length; i++) {
|
for (let i = 0; i < this._tabs.length; i++) {
|
||||||
const tab = this._tabs[i];
|
const tab = this._tabs[i];
|
||||||
@ -120,7 +81,7 @@ export class Context {
|
|||||||
const current = tab === this._currentTab ? ' (current)' : '';
|
const current = tab === this._currentTab ? ' (current)' : '';
|
||||||
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeTab(index: number | undefined) {
|
async closeTab(index: number | undefined) {
|
||||||
@ -137,28 +98,8 @@ export class Context {
|
|||||||
if (resultOverride)
|
if (resultOverride)
|
||||||
return resultOverride;
|
return resultOverride;
|
||||||
|
|
||||||
if (!this._currentTab) {
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tab = this.currentTabOrDie();
|
const tab = this.currentTabOrDie();
|
||||||
// TODO: race against modal dialogs to resolve clicks.
|
const { actionResult, snapshot } = await tab.run(action || (() => Promise.resolve()), { waitForNetwork, captureSnapshot });
|
||||||
const actionResult = await this._raceAgainstModalDialogs(async () => {
|
|
||||||
try {
|
|
||||||
if (waitForNetwork)
|
|
||||||
return await waitForCompletion(this, tab, async () => action?.()) ?? undefined;
|
|
||||||
else
|
|
||||||
return await action?.() ?? undefined;
|
|
||||||
} finally {
|
|
||||||
if (captureSnapshot && !this._javaScriptBlocked())
|
|
||||||
await tab.captureSnapshot();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
result.push(`### Ran Playwright code
|
result.push(`### Ran Playwright code
|
||||||
@ -166,8 +107,8 @@ export class Context {
|
|||||||
${code.join('\n')}
|
${code.join('\n')}
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
if (this.modalStates().length) {
|
if (tab.modalStates().length) {
|
||||||
result.push('', ...this.modalStatesMarkdown());
|
result.push('', ...tab.modalStatesMarkdown());
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -176,37 +117,13 @@ ${code.join('\n')}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = tab.takeRecentConsoleMessages();
|
result.push(...tab.takeRecentConsoleMarkdown());
|
||||||
if (messages.length) {
|
result.push(...tab.listDownloadsMarkdown());
|
||||||
result.push('', `### New console messages`);
|
|
||||||
for (const message of messages)
|
|
||||||
result.push(`- ${trim(message.toString(), 100)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._downloads.length) {
|
if (snapshot) {
|
||||||
result.push('', '### Downloads');
|
|
||||||
for (const entry of this._downloads) {
|
|
||||||
if (entry.finished)
|
|
||||||
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
|
||||||
else
|
|
||||||
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (captureSnapshot && tab.hasSnapshot()) {
|
|
||||||
if (this.tabs().length > 1)
|
if (this.tabs().length > 1)
|
||||||
result.push('', await this.listTabsMarkdown());
|
result.push('', ...(await this.listTabsMarkdown()));
|
||||||
|
result.push('', snapshot);
|
||||||
if (this.tabs().length > 1)
|
|
||||||
result.push('', '### Current tab');
|
|
||||||
else
|
|
||||||
result.push('', '### Page state');
|
|
||||||
|
|
||||||
result.push(
|
|
||||||
`- Page URL: ${tab.page.url()}`,
|
|
||||||
`- Page Title: ${await tab.title()}`
|
|
||||||
);
|
|
||||||
result.push(tab.snapshotOrDie().text());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = actionResult?.content ?? [];
|
const content = actionResult?.content ?? [];
|
||||||
@ -222,58 +139,6 @@ ${code.join('\n')}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForTimeout(time: number) {
|
|
||||||
if (!this._currentTab || this._javaScriptBlocked()) {
|
|
||||||
await new Promise(f => setTimeout(f, time));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await callOnPageNoTrace(this._currentTab.page, page => {
|
|
||||||
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
|
||||||
this._pendingAction = {
|
|
||||||
dialogShown: new ManualPromise(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let result: ToolActionResult | undefined;
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
action().then(r => result = r),
|
|
||||||
this._pendingAction.dialogShown,
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
this._pendingAction = undefined;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _javaScriptBlocked(): boolean {
|
|
||||||
return this._modalStates.some(state => state.type === 'dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogShown(tab: Tab, dialog: playwright.Dialog) {
|
|
||||||
this.setModalState({
|
|
||||||
type: 'dialog',
|
|
||||||
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
|
||||||
dialog,
|
|
||||||
}, tab);
|
|
||||||
this._pendingAction?.dialogShown.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
async downloadStarted(tab: Tab, download: playwright.Download) {
|
|
||||||
const entry = {
|
|
||||||
download,
|
|
||||||
finished: false,
|
|
||||||
outputFile: await outputFile(this.config, download.suggestedFilename())
|
|
||||||
};
|
|
||||||
this._downloads.push(entry);
|
|
||||||
await download.saveAs(entry.outputFile);
|
|
||||||
entry.finished = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onPageCreated(page: playwright.Page) {
|
private _onPageCreated(page: playwright.Page) {
|
||||||
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||||
this._tabs.push(tab);
|
this._tabs.push(tab);
|
||||||
@ -282,7 +147,6 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _onPageClosed(tab: Tab) {
|
private _onPageClosed(tab: Tab) {
|
||||||
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
|
|
||||||
const index = this._tabs.indexOf(tab);
|
const index = this._tabs.indexOf(tab);
|
||||||
if (index === -1)
|
if (index === -1)
|
||||||
return;
|
return;
|
||||||
@ -353,9 +217,3 @@ ${code.join('\n')}
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function trim(text: string, maxLength: number) {
|
|
||||||
if (text.length <= maxLength)
|
|
||||||
return text;
|
|
||||||
return text.slice(0, maxLength) + '...';
|
|
||||||
}
|
|
||||||
|
@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
|
||||||
import { callOnPageNoTrace } from './tools/utils.js';
|
|
||||||
|
|
||||||
type PageEx = playwright.Page & {
|
|
||||||
_snapshotForAI: () => Promise<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class PageSnapshot {
|
|
||||||
private _page: playwright.Page;
|
|
||||||
private _text!: string;
|
|
||||||
|
|
||||||
constructor(page: playwright.Page) {
|
|
||||||
this._page = page;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
|
||||||
const snapshot = new PageSnapshot(page);
|
|
||||||
await snapshot._build();
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
text(): string {
|
|
||||||
return this._text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _build() {
|
|
||||||
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
|
||||||
this._text = [
|
|
||||||
`- Page Snapshot:`,
|
|
||||||
'```yaml',
|
|
||||||
snapshot,
|
|
||||||
'```',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
refLocator(params: { element: string, ref: string }): playwright.Locator {
|
|
||||||
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
|
|
||||||
}
|
|
||||||
}
|
|
183
src/tab.ts
183
src/tab.ts
@ -16,20 +16,33 @@
|
|||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { PageSnapshot } from './pageSnapshot.js';
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
import { callOnPageNoTrace } from './tools/utils.js';
|
|
||||||
import { logUnhandledError } from './log.js';
|
import { logUnhandledError } from './log.js';
|
||||||
|
import { ManualPromise } from './manualPromise.js';
|
||||||
|
import { ModalState } from './tools/tool.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
import type { Context } from './context.js';
|
import type { Context } from './context.js';
|
||||||
|
import type { ToolActionResult } from './tools/tool.js';
|
||||||
|
|
||||||
|
type PageEx = playwright.Page & {
|
||||||
|
_snapshotForAI: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingAction = {
|
||||||
|
dialogShown: ManualPromise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export class Tab {
|
export class Tab {
|
||||||
readonly context: Context;
|
readonly context: Context;
|
||||||
readonly page: playwright.Page;
|
readonly page: playwright.Page;
|
||||||
private _consoleMessages: ConsoleMessage[] = [];
|
private _consoleMessages: ConsoleMessage[] = [];
|
||||||
private _recentConsoleMessages: ConsoleMessage[] = [];
|
private _recentConsoleMessages: ConsoleMessage[] = [];
|
||||||
|
private _pendingAction: PendingAction | undefined;
|
||||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||||
private _snapshot: PageSnapshot | undefined;
|
|
||||||
private _onPageClose: (tab: Tab) => void;
|
private _onPageClose: (tab: Tab) => void;
|
||||||
|
private _modalStates: ModalState[] = [];
|
||||||
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
|
|
||||||
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
@ -41,20 +54,63 @@ export class Tab {
|
|||||||
page.on('response', response => this._requests.set(response.request(), response));
|
page.on('response', response => this._requests.set(response.request(), response));
|
||||||
page.on('close', () => this._onClose());
|
page.on('close', () => this._onClose());
|
||||||
page.on('filechooser', chooser => {
|
page.on('filechooser', chooser => {
|
||||||
this.context.setModalState({
|
this.setModalState({
|
||||||
type: 'fileChooser',
|
type: 'fileChooser',
|
||||||
description: 'File chooser',
|
description: 'File chooser',
|
||||||
fileChooser: chooser,
|
fileChooser: chooser,
|
||||||
}, this);
|
});
|
||||||
});
|
});
|
||||||
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
page.on('dialog', dialog => this._dialogShown(dialog));
|
||||||
page.on('download', download => {
|
page.on('download', download => {
|
||||||
void this.context.downloadStarted(this, download);
|
void this._downloadStarted(download);
|
||||||
});
|
});
|
||||||
page.setDefaultNavigationTimeout(60000);
|
page.setDefaultNavigationTimeout(60000);
|
||||||
page.setDefaultTimeout(5000);
|
page.setDefaultTimeout(5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modalStates(): ModalState[] {
|
||||||
|
return this._modalStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalState(modalState: ModalState) {
|
||||||
|
this._modalStates.push(modalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearModalState(modalState: ModalState) {
|
||||||
|
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
modalStatesMarkdown(): string[] {
|
||||||
|
const result: string[] = ['### Modal state'];
|
||||||
|
if (this._modalStates.length === 0)
|
||||||
|
result.push('- There is no modal state present');
|
||||||
|
for (const state of this._modalStates) {
|
||||||
|
const tool = this.context.tools.find(tool => tool.clearsModalState === state.type);
|
||||||
|
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dialogShown(dialog: playwright.Dialog) {
|
||||||
|
this.setModalState({
|
||||||
|
type: 'dialog',
|
||||||
|
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
||||||
|
dialog,
|
||||||
|
});
|
||||||
|
this._pendingAction?.dialogShown.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _downloadStarted(download: playwright.Download) {
|
||||||
|
const entry = {
|
||||||
|
download,
|
||||||
|
finished: false,
|
||||||
|
outputFile: await outputFile(this.context.config, download.suggestedFilename())
|
||||||
|
};
|
||||||
|
this._downloads.push(entry);
|
||||||
|
await download.saveAs(entry.outputFile);
|
||||||
|
entry.finished = true;
|
||||||
|
}
|
||||||
|
|
||||||
private _clearCollectedArtifacts() {
|
private _clearCollectedArtifacts() {
|
||||||
this._consoleMessages.length = 0;
|
this._consoleMessages.length = 0;
|
||||||
this._recentConsoleMessages.length = 0;
|
this._recentConsoleMessages.length = 0;
|
||||||
@ -105,16 +161,6 @@ export class Tab {
|
|||||||
await this.waitForLoadState('load', { timeout: 5000 });
|
await this.waitForLoadState('load', { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
hasSnapshot(): boolean {
|
|
||||||
return !!this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshotOrDie(): PageSnapshot {
|
|
||||||
if (!this._snapshot)
|
|
||||||
throw new Error('No snapshot available');
|
|
||||||
return this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
consoleMessages(): ConsoleMessage[] {
|
consoleMessages(): ConsoleMessage[] {
|
||||||
return this._consoleMessages;
|
return this._consoleMessages;
|
||||||
}
|
}
|
||||||
@ -123,15 +169,102 @@ export class Tab {
|
|||||||
return this._requests;
|
return this._requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureSnapshot() {
|
takeRecentConsoleMarkdown(): string[] {
|
||||||
this._snapshot = await PageSnapshot.create(this.page);
|
if (!this._recentConsoleMessages.length)
|
||||||
|
return [];
|
||||||
|
const result = this._recentConsoleMessages.map(message => {
|
||||||
|
return `- ${trim(message.toString(), 100)}`;
|
||||||
|
});
|
||||||
|
return ['', `### New console messages`, ...result];
|
||||||
}
|
}
|
||||||
|
|
||||||
takeRecentConsoleMessages(): ConsoleMessage[] {
|
listDownloadsMarkdown(): string[] {
|
||||||
const result = this._recentConsoleMessages.slice();
|
if (!this._downloads.length)
|
||||||
this._recentConsoleMessages.length = 0;
|
return [];
|
||||||
|
|
||||||
|
const result: string[] = ['', '### Downloads'];
|
||||||
|
for (const entry of this._downloads) {
|
||||||
|
if (entry.finished)
|
||||||
|
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||||
|
else
|
||||||
|
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async captureSnapshot(): Promise<string> {
|
||||||
|
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||||
|
return [
|
||||||
|
`### Page state`,
|
||||||
|
`- Page URL: ${this.page.url()}`,
|
||||||
|
`- Page Title: ${await this.page.title()}`,
|
||||||
|
`- Page Snapshot:`,
|
||||||
|
'```yaml',
|
||||||
|
snapshot,
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _javaScriptBlocked(): boolean {
|
||||||
|
return this._modalStates.some(state => state.type === 'dialog');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _raceAgainstModalDialogs<R>(action: () => Promise<R>): Promise<R | undefined> {
|
||||||
|
this._pendingAction = {
|
||||||
|
dialogShown: new ManualPromise(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: R | undefined;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
action().then(r => result = r),
|
||||||
|
this._pendingAction.dialogShown,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
this._pendingAction = undefined;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(callback: () => Promise<ToolActionResult>, options: { waitForNetwork?: boolean, captureSnapshot?: boolean }): Promise<{ actionResult: ToolActionResult | undefined, snapshot: string | undefined }> {
|
||||||
|
let snapshot: string | undefined;
|
||||||
|
const actionResult = await this._raceAgainstModalDialogs(async () => {
|
||||||
|
try {
|
||||||
|
if (options.waitForNetwork)
|
||||||
|
return await waitForCompletion(this, async () => callback?.()) ?? undefined;
|
||||||
|
else
|
||||||
|
return await callback?.() ?? undefined;
|
||||||
|
} finally {
|
||||||
|
if (options.captureSnapshot && !this._javaScriptBlocked())
|
||||||
|
snapshot = await this.captureSnapshot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { actionResult, snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> {
|
||||||
|
return (await this.refLocators([params]))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
|
||||||
|
const snapshot = await this.captureSnapshot();
|
||||||
|
return params.map(param => {
|
||||||
|
if (!snapshot.includes(`[ref=${param.ref}]`))
|
||||||
|
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
|
||||||
|
return this.page.locator(`aria-ref=${param.ref}`).describe(param.element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForTimeout(time: number) {
|
||||||
|
if (this._javaScriptBlocked()) {
|
||||||
|
await new Promise(f => setTimeout(f, time));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callOnPageNoTrace(this.page, page => {
|
||||||
|
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConsoleMessage = {
|
export type ConsoleMessage = {
|
||||||
@ -162,3 +295,9 @@ function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
|
|||||||
toString: () => String(errorOrValue),
|
toString: () => String(errorOrValue),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trim(text: string, maxLength: number) {
|
||||||
|
if (text.length <= maxLength)
|
||||||
|
return text;
|
||||||
|
return text.slice(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool, defineTool } from './tool.js';
|
||||||
|
|
||||||
const close = defineTool({
|
const close = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
@ -38,7 +38,7 @@ const close = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resize = defineTool({
|
const resize = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_resize',
|
name: 'browser_resize',
|
||||||
@ -51,9 +51,7 @@ const resize = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Resize browser window to ${params.width}x${params.height}`,
|
`// Resize browser window to ${params.width}x${params.height}`,
|
||||||
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
|
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
|
||||||
|
@ -15,9 +15,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const console = defineTool({
|
const console = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
@ -26,8 +26,8 @@ const console = defineTool({
|
|||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async tab => {
|
||||||
const messages = context.currentTabOrDie().consoleMessages();
|
const messages = tab.consoleMessages();
|
||||||
const log = messages.map(message => message.toString()).join('\n');
|
const log = messages.map(message => message.toString()).join('\n');
|
||||||
return {
|
return {
|
||||||
code: [`// <internal code to get console messages>`],
|
code: [`// <internal code to get console messages>`],
|
||||||
|
@ -15,9 +15,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const handleDialog = defineTool({
|
const handleDialog = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@ -31,8 +31,8 @@ const handleDialog = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const dialogState = context.modalStates().find(state => state.type === 'dialog');
|
const dialogState = tab.modalStates().find(state => state.type === 'dialog');
|
||||||
if (!dialogState)
|
if (!dialogState)
|
||||||
throw new Error('No dialog visible');
|
throw new Error('No dialog visible');
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ const handleDialog = defineTool({
|
|||||||
else
|
else
|
||||||
await dialogState.dialog.dismiss();
|
await dialogState.dialog.dismiss();
|
||||||
|
|
||||||
context.clearModalState(dialogState);
|
tab.clearModalState(dialogState);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
|
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../javascript.js';
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ const evaluateSchema = z.object({
|
|||||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const evaluate = defineTool({
|
const evaluate = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_evaluate',
|
name: 'browser_evaluate',
|
||||||
@ -38,14 +38,12 @@ const evaluate = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
|
|
||||||
let locator: playwright.Locator | undefined;
|
let locator: playwright.Locator | undefined;
|
||||||
if (params.ref && params.element) {
|
if (params.ref && params.element) {
|
||||||
const snapshot = tab.snapshotOrDie();
|
locator = await tab.refLocator({ ref: params.ref, element: params.element });
|
||||||
locator = snapshot.refLocator({ ref: params.ref, element: params.element });
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
|
code.push(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
|
||||||
} else {
|
} else {
|
||||||
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
|
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
|
||||||
|
@ -15,9 +15,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const uploadFile = defineTool({
|
const uploadFile = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@ -30,8 +30,8 @@ const uploadFile = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
|
const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
|
||||||
if (!modalState)
|
if (!modalState)
|
||||||
throw new Error('No file chooser visible');
|
throw new Error('No file chooser visible');
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ const uploadFile = defineTool({
|
|||||||
|
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
await modalState.fileChooser.setFiles(params.paths);
|
await modalState.fileChooser.setFiles(params.paths);
|
||||||
context.clearModalState(modalState);
|
tab.clearModalState(modalState);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -16,12 +16,12 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
import { elementSchema } from './snapshot.js';
|
import { elementSchema } from './snapshot.js';
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../javascript.js';
|
||||||
|
|
||||||
const pressKey = defineTool({
|
const pressKey = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@ -34,9 +34,7 @@ const pressKey = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Press ${params.key}`,
|
`// Press ${params.key}`,
|
||||||
`await page.keyboard.press('${params.key}');`,
|
`await page.keyboard.press('${params.key}');`,
|
||||||
@ -59,7 +57,7 @@ const typeSchema = elementSchema.extend({
|
|||||||
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const type = defineTool({
|
const type = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_type',
|
name: 'browser_type',
|
||||||
@ -69,9 +67,8 @@ const type = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const locator = await tab.refLocator(params);
|
||||||
const locator = snapshot.refLocator(params);
|
|
||||||
|
|
||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
const steps: (() => Promise<void>)[] = [];
|
const steps: (() => Promise<void>)[] = [];
|
||||||
|
@ -15,13 +15,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const elementSchema = z.object({
|
const elementSchema = z.object({
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mouseMove = defineTool({
|
const mouseMove = defineTabTool({
|
||||||
capability: 'vision',
|
capability: 'vision',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_mouse_move_xy',
|
name: 'browser_mouse_move_xy',
|
||||||
@ -34,8 +34,7 @@ const mouseMove = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Move mouse to (${params.x}, ${params.y})`,
|
`// Move mouse to (${params.x}, ${params.y})`,
|
||||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||||
@ -50,7 +49,7 @@ const mouseMove = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mouseClick = defineTool({
|
const mouseClick = defineTabTool({
|
||||||
capability: 'vision',
|
capability: 'vision',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_mouse_click_xy',
|
name: 'browser_mouse_click_xy',
|
||||||
@ -63,8 +62,7 @@ const mouseClick = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Click mouse at coordinates (${params.x}, ${params.y})`,
|
`// Click mouse at coordinates (${params.x}, ${params.y})`,
|
||||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||||
@ -85,7 +83,7 @@ const mouseClick = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mouseDrag = defineTool({
|
const mouseDrag = defineTabTool({
|
||||||
capability: 'vision',
|
capability: 'vision',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_mouse_drag_xy',
|
name: 'browser_mouse_drag_xy',
|
||||||
@ -100,9 +98,7 @@ const mouseDrag = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
|
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
|
||||||
`await page.mouse.move(${params.startX}, ${params.startY});`,
|
`await page.mouse.move(${params.startX}, ${params.startY});`,
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTool, defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const navigate = defineTool({
|
const navigate = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
@ -47,7 +47,7 @@ const navigate = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goBack = defineTool({
|
const goBack = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_back',
|
name: 'browser_navigate_back',
|
||||||
@ -57,8 +57,7 @@ const goBack = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async tab => {
|
||||||
const tab = await context.ensureTab();
|
|
||||||
await tab.page.goBack();
|
await tab.page.goBack();
|
||||||
const code = [
|
const code = [
|
||||||
`// Navigate back`,
|
`// Navigate back`,
|
||||||
@ -73,7 +72,7 @@ const goBack = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goForward = defineTool({
|
const goForward = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_forward',
|
name: 'browser_navigate_forward',
|
||||||
@ -82,8 +81,7 @@ const goForward = defineTool({
|
|||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async tab => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
await tab.page.goForward();
|
await tab.page.goForward();
|
||||||
const code = [
|
const code = [
|
||||||
`// Navigate forward`,
|
`// Navigate forward`,
|
||||||
|
@ -15,11 +15,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
const requests = defineTool({
|
const requests = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@ -30,8 +30,8 @@ const requests = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async tab => {
|
||||||
const requests = context.currentTabOrDie().requests();
|
const requests = tab.requests();
|
||||||
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
|
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
|
||||||
return {
|
return {
|
||||||
code: [`// <internal code to list network requests>`],
|
code: [`// <internal code to list network requests>`],
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../javascript.js';
|
||||||
import { outputFile } from '../config.js';
|
import { outputFile } from '../config.js';
|
||||||
@ -24,7 +24,7 @@ const pdfSchema = z.object({
|
|||||||
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = defineTool({
|
const pdf = defineTabTool({
|
||||||
capability: 'pdf',
|
capability: 'pdf',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@ -35,9 +35,8 @@ const pdf = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
||||||
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Save page as ${fileName}`,
|
`// Save page as ${fileName}`,
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../javascript.js';
|
||||||
import { outputFile } from '../config.js';
|
import { outputFile } from '../config.js';
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
@ -41,7 +41,7 @@ const screenshotSchema = z.object({
|
|||||||
path: ['fullPage']
|
path: ['fullPage']
|
||||||
});
|
});
|
||||||
|
|
||||||
const screenshot = defineTool({
|
const screenshot = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
@ -51,10 +51,9 @@ const screenshot = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const fileType = params.raw ? 'png' : 'jpeg';
|
const fileType = params.raw ? 'png' : 'jpeg';
|
||||||
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
||||||
const options: playwright.PageScreenshotOptions = {
|
const options: playwright.PageScreenshotOptions = {
|
||||||
type: fileType,
|
type: fileType,
|
||||||
quality: fileType === 'png' ? undefined : 50,
|
quality: fileType === 'png' ? undefined : 50,
|
||||||
@ -70,14 +69,14 @@ const screenshot = defineTool({
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Only get snapshot when element screenshot is needed
|
// Only get snapshot when element screenshot is needed
|
||||||
const locator = params.ref ? tab.snapshotOrDie().refLocator({ element: params.element || '', ref: params.ref }) : null;
|
const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
||||||
|
|
||||||
if (locator)
|
if (locator)
|
||||||
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||||
else
|
else
|
||||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
|
||||||
const includeBase64 = context.clientSupportsImages();
|
const includeBase64 = tab.context.config.imageResponses !== 'omit';
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
return {
|
return {
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool, defineTool } from './tool.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../javascript.js';
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
@ -51,7 +51,7 @@ const clickSchema = elementSchema.extend({
|
|||||||
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
|
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const click = defineTool({
|
const click = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@ -61,9 +61,8 @@ const click = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
const locator = await tab.refLocator(params);
|
||||||
const locator = tab.snapshotOrDie().refLocator(params);
|
|
||||||
const button = params.button;
|
const button = params.button;
|
||||||
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
||||||
|
|
||||||
@ -85,7 +84,7 @@ const click = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const drag = defineTool({
|
const drag = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_drag',
|
name: 'browser_drag',
|
||||||
@ -100,10 +99,11 @@ const drag = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const [startLocator, endLocator] = await tab.refLocators([
|
||||||
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
|
{ ref: params.startRef, element: params.startElement },
|
||||||
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
|
{ ref: params.endRef, element: params.endElement },
|
||||||
|
]);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||||
@ -119,7 +119,7 @@ const drag = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const hover = defineTool({
|
const hover = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_hover',
|
name: 'browser_hover',
|
||||||
@ -129,9 +129,8 @@ const hover = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const locator = await tab.refLocator(params);
|
||||||
const locator = snapshot.refLocator(params);
|
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Hover over ${params.element}`,
|
`// Hover over ${params.element}`,
|
||||||
@ -151,7 +150,7 @@ const selectOptionSchema = elementSchema.extend({
|
|||||||
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectOption = defineTool({
|
const selectOption = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
@ -161,9 +160,8 @@ const selectOption = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const locator = await tab.refLocator(params);
|
||||||
const locator = snapshot.refLocator(params);
|
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
||||||
|
@ -37,7 +37,7 @@ const listTabs = defineTool({
|
|||||||
resultOverride: {
|
resultOverride: {
|
||||||
content: [{
|
content: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: await context.listTabsMarkdown(),
|
text: (await context.listTabsMarkdown()).join('\n'),
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -85,9 +85,9 @@ const newTab = defineTool({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
await context.newTab();
|
const tab = await context.newTab();
|
||||||
if (params.url)
|
if (params.url)
|
||||||
await context.currentTabOrDie().navigate(params.url);
|
await tab.navigate(params.url);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// <internal code to open a new tab>`,
|
`// <internal code to open a new tab>`,
|
||||||
|
@ -19,6 +19,7 @@ import type { z } from 'zod';
|
|||||||
import type { Context } from '../context.js';
|
import type { Context } from '../context.js';
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { ToolCapability } from '../../config.js';
|
import type { ToolCapability } from '../../config.js';
|
||||||
|
import type { Tab } from '../tab.js';
|
||||||
|
|
||||||
export type ToolSchema<Input extends InputType> = {
|
export type ToolSchema<Input extends InputType> = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -64,3 +65,25 @@ export type Tool<Input extends InputType = InputType> = {
|
|||||||
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
||||||
return tool;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TabTool<Input extends InputType = InputType> = {
|
||||||
|
capability: ToolCapability;
|
||||||
|
schema: ToolSchema<Input>;
|
||||||
|
clearsModalState?: ModalState['type'];
|
||||||
|
handle: (tab: Tab, params: z.output<Input>) => Promise<ToolResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defineTabTool<Input extends InputType>(tool: TabTool<Input>): Tool<Input> {
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const modalStates = tab.modalStates().map(state => state.type);
|
||||||
|
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||||
|
throw new Error(`The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||||
|
if (!tool.clearsModalState && modalStates.length)
|
||||||
|
throw new Error(`Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||||
|
return tool.handle(tab, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -18,10 +18,9 @@
|
|||||||
import { asLocator } from 'playwright-core/lib/utils';
|
import { asLocator } from 'playwright-core/lib/utils';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { Context } from '../context.js';
|
|
||||||
import type { Tab } from '../tab.js';
|
import type { Tab } from '../tab.js';
|
||||||
|
|
||||||
export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
|
export async function waitForCompletion<R>(tab: Tab, callback: () => Promise<R>): Promise<R> {
|
||||||
const requests = new Set<playwright.Request>();
|
const requests = new Set<playwright.Request>();
|
||||||
let frameNavigated = false;
|
let frameNavigated = false;
|
||||||
let waitCallback: () => void = () => {};
|
let waitCallback: () => void = () => {};
|
||||||
@ -65,7 +64,7 @@ export async function waitForCompletion<R>(context: Context, tab: Tab, callback:
|
|||||||
if (!requests.size && !frameNavigated)
|
if (!requests.size && !frameNavigated)
|
||||||
waitCallback();
|
waitCallback();
|
||||||
await waitBarrier;
|
await waitBarrier;
|
||||||
await context.waitForTimeout(1000);
|
await tab.waitForTimeout(1000);
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
dispose();
|
dispose();
|
||||||
|
@ -41,7 +41,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
|||||||
element: 'Hello, world!',
|
element: 'Hello, world!',
|
||||||
ref: 'f0',
|
ref: 'f0',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
|
})).toHaveTextContent(`Error: No open pages available. Use the \"browser_navigate\" tool to navigate to a page first.`);
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_snapshot',
|
name: 'browser_snapshot',
|
||||||
|
@ -242,7 +242,7 @@ test('old locator error message', async ({ client, server }) => {
|
|||||||
element: 'Button 2',
|
element: 'Button 2',
|
||||||
ref: 'e3',
|
ref: 'e3',
|
||||||
},
|
},
|
||||||
})).toContainTextContent('Ref not found');
|
})).toContainTextContent('Ref e3 not found in the current page snapshot. Try capturing new snapshot.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
|
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
|
||||||
|
@ -38,7 +38,7 @@ test('browser_file_upload', async ({ client, server }, testInfo) => {
|
|||||||
name: 'browser_file_upload',
|
name: 'browser_file_upload',
|
||||||
arguments: { paths: [] },
|
arguments: { paths: [] },
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
The tool "browser_file_upload" can only be used when there is related modal state present.
|
Error: The tool "browser_file_upload" can only be used when there is related modal state present.
|
||||||
### Modal state
|
### Modal state
|
||||||
- There is no modal state present
|
- There is no modal state present
|
||||||
`.trim());
|
`.trim());
|
||||||
@ -88,7 +88,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state.
|
expect(response).toContainTextContent(`Error: Tool "browser_click" does not handle the modal state.
|
||||||
### Modal state
|
### Modal state
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@ test('create new tab', async ({ client }) => {
|
|||||||
- 0: [] (about:blank)
|
- 0: [] (about:blank)
|
||||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
|
|
||||||
### Current tab
|
### Page state
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
@ -63,7 +63,7 @@ test('create new tab', async ({ client }) => {
|
|||||||
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||||
|
|
||||||
### Current tab
|
### Page state
|
||||||
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||||
- Page Title: Tab two
|
- Page Title: Tab two
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
@ -75,23 +75,21 @@ test('create new tab', async ({ client }) => {
|
|||||||
test('select tab', async ({ client }) => {
|
test('select tab', async ({ client }) => {
|
||||||
await createTab(client, 'Tab one', 'Body one');
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
await createTab(client, 'Tab two', 'Body two');
|
await createTab(client, 'Tab two', 'Body two');
|
||||||
expect(await client.callTool({
|
|
||||||
|
const result = await client.callTool({
|
||||||
name: 'browser_tab_select',
|
name: 'browser_tab_select',
|
||||||
arguments: {
|
arguments: {
|
||||||
index: 1,
|
index: 1,
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
});
|
||||||
### Ran Playwright code
|
expect(result).toContainTextContent(`
|
||||||
\`\`\`js
|
|
||||||
// <internal code to select tab 1>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Open tabs
|
### Open tabs
|
||||||
- 0: [] (about:blank)
|
- 0: [] (about:blank)
|
||||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)`);
|
||||||
|
|
||||||
### Current tab
|
expect(result).toContainTextContent(`
|
||||||
|
### Page state
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
@ -103,22 +101,20 @@ test('select tab', async ({ client }) => {
|
|||||||
test('close tab', async ({ client }) => {
|
test('close tab', async ({ client }) => {
|
||||||
await createTab(client, 'Tab one', 'Body one');
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
await createTab(client, 'Tab two', 'Body two');
|
await createTab(client, 'Tab two', 'Body two');
|
||||||
expect(await client.callTool({
|
|
||||||
|
const result = await client.callTool({
|
||||||
name: 'browser_tab_close',
|
name: 'browser_tab_close',
|
||||||
arguments: {
|
arguments: {
|
||||||
index: 2,
|
index: 2,
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
});
|
||||||
### Ran Playwright code
|
expect(result).toContainTextContent(`
|
||||||
\`\`\`js
|
|
||||||
// <internal code to close tab 2>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Open tabs
|
### Open tabs
|
||||||
- 0: [] (about:blank)
|
- 0: [] (about:blank)
|
||||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
||||||
|
|
||||||
### Current tab
|
expect(result).toContainTextContent(`
|
||||||
|
### Page state
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user