mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-27 00:52:27 +08:00
chore: allow multiple tabs (#129)
This commit is contained in:
parent
b358e47d71
commit
e36d4ea695
128
src/context.ts
128
src/context.ts
@ -44,46 +44,78 @@ export class Context {
|
|||||||
private _options: ContextOptions;
|
private _options: ContextOptions;
|
||||||
private _browser: playwright.Browser | undefined;
|
private _browser: playwright.Browser | undefined;
|
||||||
private _browserContext: playwright.BrowserContext | undefined;
|
private _browserContext: playwright.BrowserContext | undefined;
|
||||||
private _pages: Page[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentPage: Page | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _createContextPromise: Promise<playwright.Page> | undefined;
|
|
||||||
|
|
||||||
constructor(options: ContextOptions) {
|
constructor(options: ContextOptions) {
|
||||||
this._options = options;
|
this._options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPage(): Promise<playwright.Page> {
|
tabs(): Tab[] {
|
||||||
if (this._createContextPromise)
|
return this._tabs;
|
||||||
return this._createContextPromise;
|
}
|
||||||
this._createContextPromise = (async () => {
|
|
||||||
const { browser, browserContext } = await this._createBrowserContext();
|
currentTab(): Tab {
|
||||||
const pages = browserContext.pages();
|
if (!this._currentTab)
|
||||||
for (const page of pages)
|
throw new Error('Navigate to a location to create a tab');
|
||||||
this._onPageCreated(page);
|
return this._currentTab;
|
||||||
browserContext.on('page', page => this._onPageCreated(page));
|
}
|
||||||
let page = pages[0];
|
|
||||||
if (!page)
|
async newTab(): Promise<Tab> {
|
||||||
page = await browserContext.newPage();
|
const browserContext = await this._ensureBrowserContext();
|
||||||
this._currentPage = this._pages[0];
|
const page = await browserContext.newPage();
|
||||||
this._browser = browser;
|
this._currentTab = this._tabs.find(t => t.page === page)!;
|
||||||
this._browserContext = browserContext;
|
return this._currentTab;
|
||||||
return page;
|
}
|
||||||
})();
|
|
||||||
return this._createContextPromise;
|
async selectTab(index: number) {
|
||||||
|
this._currentTab = this._tabs[index - 1];
|
||||||
|
await this._currentTab.page.bringToFront();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureTab(): Promise<Tab> {
|
||||||
|
if (this._currentTab)
|
||||||
|
return this._currentTab;
|
||||||
|
|
||||||
|
const context = await this._ensureBrowserContext();
|
||||||
|
await context.newPage();
|
||||||
|
return this._currentTab!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTabs(): Promise<string> {
|
||||||
|
if (!this._tabs.length)
|
||||||
|
return 'No tabs open';
|
||||||
|
const lines: string[] = ['Open tabs:'];
|
||||||
|
for (let i = 0; i < this._tabs.length; i++) {
|
||||||
|
const tab = this._tabs[i];
|
||||||
|
const title = await tab.page.title();
|
||||||
|
const url = tab.page.url();
|
||||||
|
const current = tab === this._currentTab ? ' (current)' : '';
|
||||||
|
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeTab(index: number | undefined) {
|
||||||
|
const tab = index === undefined ? this.currentTab() : this._tabs[index - 1];
|
||||||
|
await tab.page.close();
|
||||||
|
return await this.listTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onPageCreated(page: playwright.Page) {
|
private _onPageCreated(page: playwright.Page) {
|
||||||
this._pages.push(new Page(page, page => this._onPageClose(page)));
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||||
|
this._tabs.push(tab);
|
||||||
|
if (!this._currentTab)
|
||||||
|
this._currentTab = tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onPageClose(page: Page) {
|
private _onPageClosed(tab: Tab) {
|
||||||
this._pages = this._pages.filter(p => p !== page);
|
this._tabs = this._tabs.filter(t => t !== tab);
|
||||||
if (this._currentPage === page)
|
if (this._currentTab === tab)
|
||||||
this._currentPage = this._pages[0];
|
this._currentTab = this._tabs[0];
|
||||||
const browser = this._browser;
|
const browser = this._browser;
|
||||||
if (this._browserContext && !this._pages.length) {
|
if (this._browserContext && !this._tabs.length) {
|
||||||
void this._browserContext.close().then(() => browser?.close()).catch(() => {});
|
void this._browserContext.close().then(() => browser?.close()).catch(() => {});
|
||||||
this._createContextPromise = undefined;
|
|
||||||
this._browser = undefined;
|
this._browser = undefined;
|
||||||
this._browserContext = undefined;
|
this._browserContext = undefined;
|
||||||
}
|
}
|
||||||
@ -108,18 +140,22 @@ export class Context {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPage(): Page {
|
|
||||||
if (!this._currentPage)
|
|
||||||
throw new Error('Navigate to a location to create a page');
|
|
||||||
return this._currentPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
if (!this._browserContext)
|
if (!this._browserContext)
|
||||||
return;
|
return;
|
||||||
await this._browserContext.close();
|
await this._browserContext.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _ensureBrowserContext() {
|
||||||
|
if (!this._browserContext) {
|
||||||
|
const context = await this._createBrowserContext();
|
||||||
|
this._browser = context.browser;
|
||||||
|
this._browserContext = context.browserContext;
|
||||||
|
this._browserContext.on('page', page => this._onPageCreated(page));
|
||||||
|
}
|
||||||
|
return this._browserContext;
|
||||||
|
}
|
||||||
|
|
||||||
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||||
if (this._options.remoteEndpoint) {
|
if (this._options.remoteEndpoint) {
|
||||||
const url = new URL(this._options.remoteEndpoint);
|
const url = new URL(this._options.remoteEndpoint);
|
||||||
@ -154,14 +190,16 @@ export class Context {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Page {
|
class Tab {
|
||||||
|
readonly context: Context;
|
||||||
readonly page: playwright.Page;
|
readonly page: playwright.Page;
|
||||||
private _console: playwright.ConsoleMessage[] = [];
|
private _console: playwright.ConsoleMessage[] = [];
|
||||||
private _fileChooser: playwright.FileChooser | undefined;
|
private _fileChooser: playwright.FileChooser | undefined;
|
||||||
private _snapshot: PageSnapshot | undefined;
|
private _snapshot: PageSnapshot | undefined;
|
||||||
private _onPageClose: (page: Page) => void;
|
private _onPageClose: (tab: Tab) => void;
|
||||||
|
|
||||||
constructor(page: playwright.Page, onPageClose: (page: Page) => void) {
|
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
||||||
|
this.context = context;
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this._onPageClose = onPageClose;
|
this._onPageClose = onPageClose;
|
||||||
page.on('console', event => this._console.push(event));
|
page.on('console', event => this._console.push(event));
|
||||||
@ -181,7 +219,13 @@ class Page {
|
|||||||
this._onPageClose(this);
|
this._onPageClose(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(callback: (page: Page) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
async navigate(url: string) {
|
||||||
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
|
// Cap load event to 5 seconds, the page is operational at this point.
|
||||||
|
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
if (!options?.noClearFileChooser)
|
if (!options?.noClearFileChooser)
|
||||||
this._fileChooser = undefined;
|
this._fileChooser = undefined;
|
||||||
@ -193,22 +237,24 @@ class Page {
|
|||||||
if (options?.captureSnapshot)
|
if (options?.captureSnapshot)
|
||||||
this._snapshot = await PageSnapshot.create(this.page);
|
this._snapshot = await PageSnapshot.create(this.page);
|
||||||
}
|
}
|
||||||
|
const tabList = this.context.tabs().length > 1 ? await this.context.listTabs() + '\n\nCurrent tab:' + '\n' : '';
|
||||||
|
const snapshot = this._snapshot?.text({ status: options?.status, hasFileChooser: !!this._fileChooser }) ?? options?.status ?? '';
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: this._snapshot?.text({ status: options?.status, hasFileChooser: !!this._fileChooser }) ?? options?.status ?? '',
|
text: tabList + snapshot,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async runAndWait(callback: (page: Page) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
async runAndWait(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||||
return await this.run(callback, {
|
return await this.run(callback, {
|
||||||
waitForCompletion: true,
|
waitForCompletion: true,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async runAndWaitWithSnapshot(callback: (page: Page) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
async runAndWaitWithSnapshot(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||||
return await this.run(callback, {
|
return await this.run(callback, {
|
||||||
captureSnapshot: true,
|
captureSnapshot: true,
|
||||||
waitForCompletion: true,
|
waitForCompletion: true,
|
||||||
|
@ -18,6 +18,7 @@ import { createServerWithTools } from './server';
|
|||||||
import * as snapshot from './tools/snapshot';
|
import * as snapshot from './tools/snapshot';
|
||||||
import * as common from './tools/common';
|
import * as common from './tools/common';
|
||||||
import * as screenshot from './tools/screenshot';
|
import * as screenshot from './tools/screenshot';
|
||||||
|
import * as tabs from './tools/tabs';
|
||||||
import { console } from './resources/console';
|
import { console } from './resources/console';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
import type { Tool } from './tools/tool';
|
||||||
@ -30,6 +31,8 @@ const commonTools: Tool[] = [
|
|||||||
common.pdf,
|
common.pdf,
|
||||||
common.close,
|
common.close,
|
||||||
common.install,
|
common.install,
|
||||||
|
tabs.listTabs,
|
||||||
|
tabs.newTab,
|
||||||
];
|
];
|
||||||
|
|
||||||
const snapshotTools: Tool[] = [
|
const snapshotTools: Tool[] = [
|
||||||
@ -45,6 +48,8 @@ const snapshotTools: Tool[] = [
|
|||||||
common.chooseFile(true),
|
common.chooseFile(true),
|
||||||
common.pressKey(true),
|
common.pressKey(true),
|
||||||
...commonTools,
|
...commonTools,
|
||||||
|
tabs.selectTab(true),
|
||||||
|
tabs.closeTab(true),
|
||||||
];
|
];
|
||||||
|
|
||||||
const screenshotTools: Tool[] = [
|
const screenshotTools: Tool[] = [
|
||||||
@ -59,6 +64,8 @@ const screenshotTools: Tool[] = [
|
|||||||
common.chooseFile(false),
|
common.chooseFile(false),
|
||||||
common.pressKey(false),
|
common.pressKey(false),
|
||||||
...commonTools,
|
...commonTools,
|
||||||
|
tabs.selectTab(false),
|
||||||
|
tabs.closeTab(false),
|
||||||
];
|
];
|
||||||
|
|
||||||
const resources: Resource[] = [
|
const resources: Resource[] = [
|
||||||
|
@ -24,7 +24,7 @@ export const console: Resource = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
read: async (context, uri) => {
|
read: async (context, uri) => {
|
||||||
const messages = await context.currentPage().console();
|
const messages = await context.currentTab().console();
|
||||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||||
return [{
|
return [{
|
||||||
uri,
|
uri,
|
||||||
|
@ -36,11 +36,9 @@ export const navigate: ToolFactory = captureSnapshot => ({
|
|||||||
},
|
},
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = navigateSchema.parse(params);
|
const validatedParams = navigateSchema.parse(params);
|
||||||
await context.createPage();
|
const currentTab = await context.ensureTab();
|
||||||
return await context.currentPage().run(async page => {
|
return await currentTab.run(async tab => {
|
||||||
await page.page.goto(validatedParams.url, { waitUntil: 'domcontentloaded' });
|
await tab.navigate(validatedParams.url);
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
|
||||||
await page.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
||||||
}, {
|
}, {
|
||||||
status: `Navigated to ${validatedParams.url}`,
|
status: `Navigated to ${validatedParams.url}`,
|
||||||
captureSnapshot,
|
captureSnapshot,
|
||||||
@ -57,8 +55,8 @@ export const goBack: ToolFactory = snapshot => ({
|
|||||||
inputSchema: zodToJsonSchema(goBackSchema),
|
inputSchema: zodToJsonSchema(goBackSchema),
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
return await context.currentPage().runAndWait(async page => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
await page.page.goBack();
|
await tab.page.goBack();
|
||||||
}, {
|
}, {
|
||||||
status: 'Navigated back',
|
status: 'Navigated back',
|
||||||
captureSnapshot: snapshot,
|
captureSnapshot: snapshot,
|
||||||
@ -75,8 +73,8 @@ export const goForward: ToolFactory = snapshot => ({
|
|||||||
inputSchema: zodToJsonSchema(goForwardSchema),
|
inputSchema: zodToJsonSchema(goForwardSchema),
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
return await context.currentPage().runAndWait(async page => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
await page.page.goForward();
|
await tab.page.goForward();
|
||||||
}, {
|
}, {
|
||||||
status: 'Navigated forward',
|
status: 'Navigated forward',
|
||||||
captureSnapshot: snapshot,
|
captureSnapshot: snapshot,
|
||||||
@ -118,8 +116,8 @@ export const pressKey: (captureSnapshot: boolean) => Tool = captureSnapshot => (
|
|||||||
},
|
},
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = pressKeySchema.parse(params);
|
const validatedParams = pressKeySchema.parse(params);
|
||||||
return await context.currentPage().runAndWait(async page => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
await page.page.keyboard.press(validatedParams.key);
|
await tab.page.keyboard.press(validatedParams.key);
|
||||||
}, {
|
}, {
|
||||||
status: `Pressed key ${validatedParams.key}`,
|
status: `Pressed key ${validatedParams.key}`,
|
||||||
captureSnapshot,
|
captureSnapshot,
|
||||||
@ -136,9 +134,9 @@ export const pdf: Tool = {
|
|||||||
inputSchema: zodToJsonSchema(pdfSchema),
|
inputSchema: zodToJsonSchema(pdfSchema),
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const page = context.currentPage();
|
const tab = context.currentTab();
|
||||||
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
|
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
|
||||||
await page.page.pdf({ path: fileName });
|
await tab.page.pdf({ path: fileName });
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -179,9 +177,9 @@ export const chooseFile: ToolFactory = captureSnapshot => ({
|
|||||||
},
|
},
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = chooseFileSchema.parse(params);
|
const validatedParams = chooseFileSchema.parse(params);
|
||||||
const page = context.currentPage();
|
const tab = context.currentTab();
|
||||||
return await page.runAndWait(async () => {
|
return await tab.runAndWait(async () => {
|
||||||
await page.submitFileChooser(validatedParams.paths);
|
await tab.submitFileChooser(validatedParams.paths);
|
||||||
}, {
|
}, {
|
||||||
status: `Chose files ${validatedParams.paths.join(', ')}`,
|
status: `Chose files ${validatedParams.paths.join(', ')}`,
|
||||||
captureSnapshot,
|
captureSnapshot,
|
||||||
|
@ -27,8 +27,8 @@ export const screenshot: Tool = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const page = context.currentPage();
|
const tab = context.currentTab();
|
||||||
const screenshot = await page.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
|
const screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
|
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
|
||||||
};
|
};
|
||||||
@ -53,8 +53,8 @@ export const moveMouse: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = moveMouseSchema.parse(params);
|
const validatedParams = moveMouseSchema.parse(params);
|
||||||
const page = context.currentPage();
|
const tab = context.currentTab();
|
||||||
await page.page.mouse.move(validatedParams.x, validatedParams.y);
|
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
|
content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
|
||||||
};
|
};
|
||||||
@ -74,11 +74,11 @@ export const click: Tool = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
return await context.currentPage().runAndWait(async page => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
const validatedParams = clickSchema.parse(params);
|
const validatedParams = clickSchema.parse(params);
|
||||||
await page.page.mouse.move(validatedParams.x, validatedParams.y);
|
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
||||||
await page.page.mouse.down();
|
await tab.page.mouse.down();
|
||||||
await page.page.mouse.up();
|
await tab.page.mouse.up();
|
||||||
}, {
|
}, {
|
||||||
status: 'Clicked mouse',
|
status: 'Clicked mouse',
|
||||||
});
|
});
|
||||||
@ -101,11 +101,11 @@ export const drag: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = dragSchema.parse(params);
|
const validatedParams = dragSchema.parse(params);
|
||||||
return await context.currentPage().runAndWait(async page => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
await page.page.mouse.move(validatedParams.startX, validatedParams.startY);
|
await tab.page.mouse.move(validatedParams.startX, validatedParams.startY);
|
||||||
await page.page.mouse.down();
|
await tab.page.mouse.down();
|
||||||
await page.page.mouse.move(validatedParams.endX, validatedParams.endY);
|
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
|
||||||
await page.page.mouse.up();
|
await tab.page.mouse.up();
|
||||||
}, {
|
}, {
|
||||||
status: `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
|
status: `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
|
||||||
});
|
});
|
||||||
@ -126,10 +126,10 @@ export const type: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = typeSchema.parse(params);
|
const validatedParams = typeSchema.parse(params);
|
||||||
return await context.currentPage().runAndWait(async page => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
await page.page.keyboard.type(validatedParams.text);
|
await tab.page.keyboard.type(validatedParams.text);
|
||||||
if (validatedParams.submit)
|
if (validatedParams.submit)
|
||||||
await page.page.keyboard.press('Enter');
|
await tab.page.keyboard.press('Enter');
|
||||||
}, {
|
}, {
|
||||||
status: `Typed text "${validatedParams.text}"`,
|
status: `Typed text "${validatedParams.text}"`,
|
||||||
});
|
});
|
||||||
|
@ -28,7 +28,7 @@ export const snapshot: Tool = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
return await context.currentPage().run(async () => {}, { captureSnapshot: true });
|
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -46,8 +46,8 @@ export const click: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = elementSchema.parse(params);
|
const validatedParams = elementSchema.parse(params);
|
||||||
return await context.currentPage().runAndWaitWithSnapshot(async page => {
|
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||||
const locator = page.lastSnapshot().refLocator(validatedParams.ref);
|
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||||
await locator.click();
|
await locator.click();
|
||||||
}, {
|
}, {
|
||||||
status: `Clicked "${validatedParams.element}"`,
|
status: `Clicked "${validatedParams.element}"`,
|
||||||
@ -71,9 +71,9 @@ export const drag: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = dragSchema.parse(params);
|
const validatedParams = dragSchema.parse(params);
|
||||||
return await context.currentPage().runAndWaitWithSnapshot(async page => {
|
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||||
const startLocator = page.lastSnapshot().refLocator(validatedParams.startRef);
|
const startLocator = tab.lastSnapshot().refLocator(validatedParams.startRef);
|
||||||
const endLocator = page.lastSnapshot().refLocator(validatedParams.endRef);
|
const endLocator = tab.lastSnapshot().refLocator(validatedParams.endRef);
|
||||||
await startLocator.dragTo(endLocator);
|
await startLocator.dragTo(endLocator);
|
||||||
}, {
|
}, {
|
||||||
status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`,
|
status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`,
|
||||||
@ -90,8 +90,8 @@ export const hover: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = elementSchema.parse(params);
|
const validatedParams = elementSchema.parse(params);
|
||||||
return await context.currentPage().runAndWaitWithSnapshot(async page => {
|
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||||
const locator = page.lastSnapshot().refLocator(validatedParams.ref);
|
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||||
await locator.hover();
|
await locator.hover();
|
||||||
}, {
|
}, {
|
||||||
status: `Hovered over "${validatedParams.element}"`,
|
status: `Hovered over "${validatedParams.element}"`,
|
||||||
@ -114,8 +114,8 @@ export const type: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = typeSchema.parse(params);
|
const validatedParams = typeSchema.parse(params);
|
||||||
return await context.currentPage().runAndWaitWithSnapshot(async page => {
|
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||||
const locator = page.lastSnapshot().refLocator(validatedParams.ref);
|
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||||
if (validatedParams.slowly)
|
if (validatedParams.slowly)
|
||||||
await locator.pressSequentially(validatedParams.text);
|
await locator.pressSequentially(validatedParams.text);
|
||||||
else
|
else
|
||||||
@ -141,8 +141,8 @@ export const selectOption: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = selectOptionSchema.parse(params);
|
const validatedParams = selectOptionSchema.parse(params);
|
||||||
return await context.currentPage().runAndWaitWithSnapshot(async page => {
|
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||||
const locator = page.lastSnapshot().refLocator(validatedParams.ref);
|
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||||
await locator.selectOption(validatedParams.values);
|
await locator.selectOption(validatedParams.values);
|
||||||
}, {
|
}, {
|
||||||
status: `Selected option in "${validatedParams.element}"`,
|
status: `Selected option in "${validatedParams.element}"`,
|
||||||
@ -163,9 +163,9 @@ export const screenshot: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = screenshotSchema.parse(params);
|
const validatedParams = screenshotSchema.parse(params);
|
||||||
const page = context.currentPage();
|
const tab = context.currentTab();
|
||||||
const options: playwright.PageScreenshotOptions = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' };
|
const options: playwright.PageScreenshotOptions = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' };
|
||||||
const screenshot = await page.page.screenshot(options);
|
const screenshot = await tab.page.screenshot(options);
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }],
|
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }],
|
||||||
};
|
};
|
||||||
|
98
src/tools/tabs.ts
Normal file
98
src/tools/tabs.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 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 { z } from 'zod';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
|
import type { ToolFactory, Tool } from './tool';
|
||||||
|
|
||||||
|
export const listTabs: Tool = {
|
||||||
|
schema: {
|
||||||
|
name: 'browser_list_tabs',
|
||||||
|
description: 'List browser tabs',
|
||||||
|
inputSchema: zodToJsonSchema(z.object({})),
|
||||||
|
},
|
||||||
|
handle: async context => {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: await context.listTabs(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTabSchema = z.object({
|
||||||
|
index: z.number().describe('The index of the tab to select'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const selectTab: ToolFactory = captureSnapshot => ({
|
||||||
|
schema: {
|
||||||
|
name: 'browser_select_tab',
|
||||||
|
description: 'Select a tab by index',
|
||||||
|
inputSchema: zodToJsonSchema(selectTabSchema),
|
||||||
|
},
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const validatedParams = selectTabSchema.parse(params);
|
||||||
|
await context.selectTab(validatedParams.index);
|
||||||
|
const currentTab = await context.ensureTab();
|
||||||
|
return await currentTab.run(async () => {}, { captureSnapshot });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTabSchema = z.object({
|
||||||
|
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const newTab: Tool = {
|
||||||
|
schema: {
|
||||||
|
name: 'browser_new_tab',
|
||||||
|
description: 'Open a new tab',
|
||||||
|
inputSchema: zodToJsonSchema(newTabSchema),
|
||||||
|
},
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const validatedParams = newTabSchema.parse(params);
|
||||||
|
await context.newTab();
|
||||||
|
if (validatedParams.url)
|
||||||
|
await context.currentTab().navigate(validatedParams.url);
|
||||||
|
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeTabSchema = z.object({
|
||||||
|
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const closeTab: ToolFactory = captureSnapshot => ({
|
||||||
|
schema: {
|
||||||
|
name: 'browser_close_tab',
|
||||||
|
description: 'Close a tab',
|
||||||
|
inputSchema: zodToJsonSchema(closeTabSchema),
|
||||||
|
},
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const validatedParams = closeTabSchema.parse(params);
|
||||||
|
await context.closeTab(validatedParams.index);
|
||||||
|
const currentTab = await context.currentTab();
|
||||||
|
if (currentTab)
|
||||||
|
return await currentTab.run(async () => {}, { captureSnapshot });
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: await context.listTabs(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@ -37,6 +37,10 @@ test('test tool list', async ({ client, visionClient }) => {
|
|||||||
'browser_save_as_pdf',
|
'browser_save_as_pdf',
|
||||||
'browser_close',
|
'browser_close',
|
||||||
'browser_install',
|
'browser_install',
|
||||||
|
'browser_list_tabs',
|
||||||
|
'browser_new_tab',
|
||||||
|
'browser_select_tab',
|
||||||
|
'browser_close_tab',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { tools: visionTools } = await visionClient.listTools();
|
const { tools: visionTools } = await visionClient.listTools();
|
||||||
@ -55,6 +59,10 @@ test('test tool list', async ({ client, visionClient }) => {
|
|||||||
'browser_save_as_pdf',
|
'browser_save_as_pdf',
|
||||||
'browser_close',
|
'browser_close',
|
||||||
'browser_install',
|
'browser_install',
|
||||||
|
'browser_list_tabs',
|
||||||
|
'browser_new_tab',
|
||||||
|
'browser_select_tab',
|
||||||
|
'browser_close_tab',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -75,6 +83,8 @@ test('test browser_navigate', async ({ client }) => {
|
|||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
|
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
@ -128,6 +138,8 @@ test('test reopen browser', async ({ client }) => {
|
|||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
|
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
@ -326,6 +338,8 @@ test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
|||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
|
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
@ -343,6 +357,8 @@ test('save as pdf', async ({ client }) => {
|
|||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
|
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
|
@ -86,10 +86,17 @@ export const expect = baseExpect.extend({
|
|||||||
const isNot = this.isNot;
|
const isNot = this.isNot;
|
||||||
try {
|
try {
|
||||||
const text = (response.content as any)[0].text;
|
const text = (response.content as any)[0].text;
|
||||||
if (isNot)
|
if (typeof content === 'string') {
|
||||||
baseExpect(text).not.toMatch(content);
|
if (isNot)
|
||||||
else
|
baseExpect(text.trim()).not.toBe(content.trim());
|
||||||
baseExpect(text).toMatch(content);
|
else
|
||||||
|
baseExpect(text.trim()).toBe(content.trim());
|
||||||
|
} else {
|
||||||
|
if (isNot)
|
||||||
|
baseExpect(text).not.toMatch(content);
|
||||||
|
else
|
||||||
|
baseExpect(text).toMatch(content);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
pass: isNot,
|
pass: isNot,
|
||||||
|
90
tests/tabs.spec.ts
Normal file
90
tests/tabs.spec.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 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 { test, expect } from './fixtures';
|
||||||
|
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
|
async function createTab(client: Client, title: string, body: string) {
|
||||||
|
return await client.callTool({
|
||||||
|
name: 'browser_new_tab',
|
||||||
|
arguments: {
|
||||||
|
url: `data:text/html,<title>${title}</title><body>${body}</body>`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('create new tab', async ({ client }) => {
|
||||||
|
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
|
||||||
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
|
- Page Title: Tab one
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- text: Body one
|
||||||
|
\`\`\``);
|
||||||
|
|
||||||
|
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||||
|
Open tabs:
|
||||||
|
- 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>)
|
||||||
|
|
||||||
|
Current tab:
|
||||||
|
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||||
|
- Page Title: Tab two
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- text: Body two
|
||||||
|
\`\`\``);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select tab', async ({ client }) => {
|
||||||
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
|
await createTab(client, 'Tab two', 'Body two');
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_select_tab',
|
||||||
|
arguments: {
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
Open tabs:
|
||||||
|
- 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>)
|
||||||
|
|
||||||
|
Current tab:
|
||||||
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
|
- Page Title: Tab one
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- text: Body one
|
||||||
|
\`\`\``);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close tab', async ({ client }) => {
|
||||||
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
|
await createTab(client, 'Tab two', 'Body two');
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_close_tab',
|
||||||
|
arguments: {
|
||||||
|
index: 2,
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
|
- Page Title: Tab one
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- text: Body one
|
||||||
|
\`\`\``);
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user