diff --git a/src/context.ts b/src/context.ts index 4dd4736..57c4168 100644 --- a/src/context.ts +++ b/src/context.ts @@ -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 | undefined; + private _tabs: Tab[] = []; + private _currentTab: Tab | undefined; constructor(options: ContextOptions) { this._options = options; } - async createPage(): Promise { - 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 { + 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 { + if (this._currentTab) + return this._currentTab; + + const context = await this._ensureBrowserContext(); + await context.newPage(); + return this._currentTab!; + } + + async listTabs(): Promise { + 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, options?: RunOptions): Promise { + 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, options?: RunOptions): Promise { 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, options?: RunOptions): Promise { + async runAndWait(callback: (tab: Tab) => Promise, options?: RunOptions): Promise { return await this.run(callback, { waitForCompletion: true, ...options, }); } - async runAndWaitWithSnapshot(callback: (page: Page) => Promise, options?: RunOptions): Promise { + async runAndWaitWithSnapshot(callback: (tab: Tab) => Promise, options?: RunOptions): Promise { return await this.run(callback, { captureSnapshot: true, waitForCompletion: true, diff --git a/src/index.ts b/src/index.ts index c5969d3..3437b11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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[] = [ diff --git a/src/resources/console.ts b/src/resources/console.ts index c93f838..3ba4c95 100644 --- a/src/resources/console.ts +++ b/src/resources/console.ts @@ -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, diff --git a/src/tools/common.ts b/src/tools/common.ts index 86b22e3..62acb91 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -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, diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index b9c5bb4..936cf35 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -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}"`, }); diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index f974b19..c6a87dc 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -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' }], }; diff --git a/src/tools/tabs.ts b/src/tools/tabs.ts new file mode 100644 index 0000000..8dfdafd --- /dev/null +++ b/src/tools/tabs.ts @@ -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(), + }], + }; + }, +}); diff --git a/tests/basic.spec.ts b/tests/basic.spec.ts index b138ed1..9457385 100644 --- a/tests/basic.spec.ts +++ b/tests/basic.spec.ts @@ -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,TitleHello, world!', }, })).toHaveTextContent(` +Navigated to data:text/html,TitleHello, world! + - Page URL: data:text/html,TitleHello, world! - Page Title: Title - Page Snapshot @@ -128,6 +138,8 @@ test('test reopen browser', async ({ client }) => { url: 'data:text/html,TitleHello, world!', }, })).toHaveTextContent(` +Navigated to data:text/html,TitleHello, world! + - Page URL: data:text/html,TitleHello, world! - Page Title: Title - Page Snapshot @@ -326,6 +338,8 @@ test('cdp server', async ({ cdpEndpoint, startClient }) => { url: 'data:text/html,TitleHello, world!', }, })).toHaveTextContent(` +Navigated to data:text/html,TitleHello, world! + - Page URL: data:text/html,TitleHello, world! - Page Title: Title - Page Snapshot @@ -343,6 +357,8 @@ test('save as pdf', async ({ client }) => { url: 'data:text/html,TitleHello, world!', }, })).toHaveTextContent(` +Navigated to data:text/html,TitleHello, world! + - Page URL: data:text/html,TitleHello, world! - Page Title: Title - Page Snapshot diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 5cb48ae..45a3aa5 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -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, diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts new file mode 100644 index 0000000..940c0dd --- /dev/null +++ b/tests/tabs.spec.ts @@ -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}${body}`, + }, + }); +} + +test('create new tab', async ({ client }) => { + expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(` +- Page URL: data:text/html,Tab oneBody one +- 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,Tab oneBody one) +- 2: (current) [Tab two] (data:text/html,Tab twoBody two) + +Current tab: +- Page URL: data:text/html,Tab twoBody two +- 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,Tab oneBody one) +- 2: [Tab two] (data:text/html,Tab twoBody two) + +Current tab: +- Page URL: data:text/html,Tab oneBody one +- 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,Tab oneBody one +- Page Title: Tab one +- Page Snapshot +\`\`\`yaml +- text: Body one +\`\`\``); +});