chore: allow multiple tabs (#129)

This commit is contained in:
Pavel Feldman 2025-04-03 19:24:17 -07:00 committed by GitHub
parent b358e47d71
commit e36d4ea695
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 354 additions and 92 deletions

View File

@ -44,46 +44,78 @@ export class Context {
private _options: ContextOptions;
private _browser: playwright.Browser | undefined;
private _browserContext: playwright.BrowserContext | undefined;
private _pages: Page[] = [];
private _currentPage: Page | undefined;
private _createContextPromise: Promise<playwright.Page> | undefined;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
constructor(options: ContextOptions) {
this._options = options;
}
async createPage(): Promise<playwright.Page> {
if (this._createContextPromise)
return this._createContextPromise;
this._createContextPromise = (async () => {
const { browser, browserContext } = await this._createBrowserContext();
const pages = browserContext.pages();
for (const page of pages)
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
let page = pages[0];
if (!page)
page = await browserContext.newPage();
this._currentPage = this._pages[0];
this._browser = browser;
this._browserContext = browserContext;
return page;
})();
return this._createContextPromise;
tabs(): Tab[] {
return this._tabs;
}
currentTab(): Tab {
if (!this._currentTab)
throw new Error('Navigate to a location to create a tab');
return this._currentTab;
}
async newTab(): Promise<Tab> {
const browserContext = await this._ensureBrowserContext();
const page = await browserContext.newPage();
this._currentTab = this._tabs.find(t => t.page === page)!;
return this._currentTab;
}
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) {
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) {
this._pages = this._pages.filter(p => p !== page);
if (this._currentPage === page)
this._currentPage = this._pages[0];
private _onPageClosed(tab: Tab) {
this._tabs = this._tabs.filter(t => t !== tab);
if (this._currentTab === tab)
this._currentTab = this._tabs[0];
const browser = this._browser;
if (this._browserContext && !this._pages.length) {
if (this._browserContext && !this._tabs.length) {
void this._browserContext.close().then(() => browser?.close()).catch(() => {});
this._createContextPromise = undefined;
this._browser = 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() {
if (!this._browserContext)
return;
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 }> {
if (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;
private _console: playwright.ConsoleMessage[] = [];
private _fileChooser: playwright.FileChooser | 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._onPageClose = onPageClose;
page.on('console', event => this._console.push(event));
@ -181,7 +219,13 @@ class Page {
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 {
if (!options?.noClearFileChooser)
this._fileChooser = undefined;
@ -193,22 +237,24 @@ class Page {
if (options?.captureSnapshot)
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 {
content: [{
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, {
waitForCompletion: true,
...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, {
captureSnapshot: true,
waitForCompletion: true,

View File

@ -18,6 +18,7 @@ import { createServerWithTools } from './server';
import * as snapshot from './tools/snapshot';
import * as common from './tools/common';
import * as screenshot from './tools/screenshot';
import * as tabs from './tools/tabs';
import { console } from './resources/console';
import type { Tool } from './tools/tool';
@ -30,6 +31,8 @@ const commonTools: Tool[] = [
common.pdf,
common.close,
common.install,
tabs.listTabs,
tabs.newTab,
];
const snapshotTools: Tool[] = [
@ -45,6 +48,8 @@ const snapshotTools: Tool[] = [
common.chooseFile(true),
common.pressKey(true),
...commonTools,
tabs.selectTab(true),
tabs.closeTab(true),
];
const screenshotTools: Tool[] = [
@ -59,6 +64,8 @@ const screenshotTools: Tool[] = [
common.chooseFile(false),
common.pressKey(false),
...commonTools,
tabs.selectTab(false),
tabs.closeTab(false),
];
const resources: Resource[] = [

View File

@ -24,7 +24,7 @@ export const console: Resource = {
},
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');
return [{
uri,

View File

@ -36,11 +36,9 @@ export const navigate: ToolFactory = captureSnapshot => ({
},
handle: async (context, params) => {
const validatedParams = navigateSchema.parse(params);
await context.createPage();
return await context.currentPage().run(async page => {
await page.page.goto(validatedParams.url, { waitUntil: 'domcontentloaded' });
// Cap load event to 5 seconds, the page is operational at this point.
await page.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
const currentTab = await context.ensureTab();
return await currentTab.run(async tab => {
await tab.navigate(validatedParams.url);
}, {
status: `Navigated to ${validatedParams.url}`,
captureSnapshot,
@ -57,8 +55,8 @@ export const goBack: ToolFactory = snapshot => ({
inputSchema: zodToJsonSchema(goBackSchema),
},
handle: async context => {
return await context.currentPage().runAndWait(async page => {
await page.page.goBack();
return await context.currentTab().runAndWait(async tab => {
await tab.page.goBack();
}, {
status: 'Navigated back',
captureSnapshot: snapshot,
@ -75,8 +73,8 @@ export const goForward: ToolFactory = snapshot => ({
inputSchema: zodToJsonSchema(goForwardSchema),
},
handle: async context => {
return await context.currentPage().runAndWait(async page => {
await page.page.goForward();
return await context.currentTab().runAndWait(async tab => {
await tab.page.goForward();
}, {
status: 'Navigated forward',
captureSnapshot: snapshot,
@ -118,8 +116,8 @@ export const pressKey: (captureSnapshot: boolean) => Tool = captureSnapshot => (
},
handle: async (context, params) => {
const validatedParams = pressKeySchema.parse(params);
return await context.currentPage().runAndWait(async page => {
await page.page.keyboard.press(validatedParams.key);
return await context.currentTab().runAndWait(async tab => {
await tab.page.keyboard.press(validatedParams.key);
}, {
status: `Pressed key ${validatedParams.key}`,
captureSnapshot,
@ -136,9 +134,9 @@ export const pdf: Tool = {
inputSchema: zodToJsonSchema(pdfSchema),
},
handle: async context => {
const page = context.currentPage();
const tab = context.currentTab();
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 {
content: [{
type: 'text',
@ -179,9 +177,9 @@ export const chooseFile: ToolFactory = captureSnapshot => ({
},
handle: async (context, params) => {
const validatedParams = chooseFileSchema.parse(params);
const page = context.currentPage();
return await page.runAndWait(async () => {
await page.submitFileChooser(validatedParams.paths);
const tab = context.currentTab();
return await tab.runAndWait(async () => {
await tab.submitFileChooser(validatedParams.paths);
}, {
status: `Chose files ${validatedParams.paths.join(', ')}`,
captureSnapshot,

View File

@ -27,8 +27,8 @@ export const screenshot: Tool = {
},
handle: async context => {
const page = context.currentPage();
const screenshot = await page.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
const tab = context.currentTab();
const screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
return {
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
};
@ -53,8 +53,8 @@ export const moveMouse: Tool = {
handle: async (context, params) => {
const validatedParams = moveMouseSchema.parse(params);
const page = context.currentPage();
await page.page.mouse.move(validatedParams.x, validatedParams.y);
const tab = context.currentTab();
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
return {
content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
};
@ -74,11 +74,11 @@ export const click: Tool = {
},
handle: async (context, params) => {
return await context.currentPage().runAndWait(async page => {
return await context.currentTab().runAndWait(async tab => {
const validatedParams = clickSchema.parse(params);
await page.page.mouse.move(validatedParams.x, validatedParams.y);
await page.page.mouse.down();
await page.page.mouse.up();
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
await tab.page.mouse.down();
await tab.page.mouse.up();
}, {
status: 'Clicked mouse',
});
@ -101,11 +101,11 @@ export const drag: Tool = {
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
return await context.currentPage().runAndWait(async page => {
await page.page.mouse.move(validatedParams.startX, validatedParams.startY);
await page.page.mouse.down();
await page.page.mouse.move(validatedParams.endX, validatedParams.endY);
await page.page.mouse.up();
return await context.currentTab().runAndWait(async tab => {
await tab.page.mouse.move(validatedParams.startX, validatedParams.startY);
await tab.page.mouse.down();
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
await tab.page.mouse.up();
}, {
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) => {
const validatedParams = typeSchema.parse(params);
return await context.currentPage().runAndWait(async page => {
await page.page.keyboard.type(validatedParams.text);
return await context.currentTab().runAndWait(async tab => {
await tab.page.keyboard.type(validatedParams.text);
if (validatedParams.submit)
await page.page.keyboard.press('Enter');
await tab.page.keyboard.press('Enter');
}, {
status: `Typed text "${validatedParams.text}"`,
});

View File

@ -28,7 +28,7 @@ export const snapshot: Tool = {
},
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) => {
const validatedParams = elementSchema.parse(params);
return await context.currentPage().runAndWaitWithSnapshot(async page => {
const locator = page.lastSnapshot().refLocator(validatedParams.ref);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
await locator.click();
}, {
status: `Clicked "${validatedParams.element}"`,
@ -71,9 +71,9 @@ export const drag: Tool = {
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
return await context.currentPage().runAndWaitWithSnapshot(async page => {
const startLocator = page.lastSnapshot().refLocator(validatedParams.startRef);
const endLocator = page.lastSnapshot().refLocator(validatedParams.endRef);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const startLocator = tab.lastSnapshot().refLocator(validatedParams.startRef);
const endLocator = tab.lastSnapshot().refLocator(validatedParams.endRef);
await startLocator.dragTo(endLocator);
}, {
status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`,
@ -90,8 +90,8 @@ export const hover: Tool = {
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
return await context.currentPage().runAndWaitWithSnapshot(async page => {
const locator = page.lastSnapshot().refLocator(validatedParams.ref);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
await locator.hover();
}, {
status: `Hovered over "${validatedParams.element}"`,
@ -114,8 +114,8 @@ export const type: Tool = {
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
return await context.currentPage().runAndWaitWithSnapshot(async page => {
const locator = page.lastSnapshot().refLocator(validatedParams.ref);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
if (validatedParams.slowly)
await locator.pressSequentially(validatedParams.text);
else
@ -141,8 +141,8 @@ export const selectOption: Tool = {
handle: async (context, params) => {
const validatedParams = selectOptionSchema.parse(params);
return await context.currentPage().runAndWaitWithSnapshot(async page => {
const locator = page.lastSnapshot().refLocator(validatedParams.ref);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
await locator.selectOption(validatedParams.values);
}, {
status: `Selected option in "${validatedParams.element}"`,
@ -163,9 +163,9 @@ export const screenshot: Tool = {
handle: async (context, 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 screenshot = await page.page.screenshot(options);
const screenshot = await tab.page.screenshot(options);
return {
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }],
};

98
src/tools/tabs.ts Normal file
View 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(),
}],
};
},
});

View File

@ -37,6 +37,10 @@ test('test tool list', async ({ client, visionClient }) => {
'browser_save_as_pdf',
'browser_close',
'browser_install',
'browser_list_tabs',
'browser_new_tab',
'browser_select_tab',
'browser_close_tab',
]);
const { tools: visionTools } = await visionClient.listTools();
@ -55,6 +59,10 @@ test('test tool list', async ({ client, visionClient }) => {
'browser_save_as_pdf',
'browser_close',
'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>',
},
})).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 Title: Title
- 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>',
},
})).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 Title: Title
- 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>',
},
})).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 Title: Title
- 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>',
},
})).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 Title: Title
- Page Snapshot

View File

@ -86,10 +86,17 @@ export const expect = baseExpect.extend({
const isNot = this.isNot;
try {
const text = (response.content as any)[0].text;
if (isNot)
baseExpect(text).not.toMatch(content);
else
baseExpect(text).toMatch(content);
if (typeof content === 'string') {
if (isNot)
baseExpect(text.trim()).not.toBe(content.trim());
else
baseExpect(text.trim()).toBe(content.trim());
} else {
if (isNot)
baseExpect(text).not.toMatch(content);
else
baseExpect(text).toMatch(content);
}
} catch (e) {
return {
pass: isNot,

90
tests/tabs.spec.ts Normal file
View 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
\`\`\``);
});