From b2f7fec37b8effdbf6905f0abb62adf1c29b6c75 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 2 Oct 2026 16:32:46 -0700 Subject: [PATCH] feat(mcp): allow creating tabs in isolated browser contexts `browser_tabs` with `action: 'new'` accepts an optional `isolatedContext` name. Tabs with the same name share cookies and storage, tabs in different contexts (or the default one) are fully isolated. The CLI exposes it as `tab-new --isolated-context=`. Context keeps all browser contexts in one map, applies init scripts, origin filters and routes to each of them, and tracing and video span all contexts. Cookie, storage and network tools act on the current tab's context. --- .claude/skills/playwright-dev/tools.md | 4 +- .../src/tools/backend/context.ts | 175 ++++++++++++------ .../src/tools/backend/cookies.ts | 10 +- .../src/tools/backend/network.ts | 2 +- .../src/tools/backend/response.ts | 5 +- .../src/tools/backend/storage.ts | 4 +- .../playwright-core/src/tools/backend/tab.ts | 9 +- .../playwright-core/src/tools/backend/tabs.ts | 3 +- .../src/tools/backend/tracing.ts | 45 ++--- .../src/tools/cli-daemon/commands.ts | 5 +- .../src/tools/skills/playwright-cli/SKILL.md | 1 + tests/mcp/cli-navigation.spec.ts | 6 + tests/mcp/cookies.spec.ts | 41 ++++ tests/mcp/route.spec.ts | 27 +++ tests/mcp/tabs.spec.ts | 53 ++++++ tests/mcp/tracing.spec.ts | 25 +++ 16 files changed, 318 insertions(+), 97 deletions(-) diff --git a/.claude/skills/playwright-dev/tools.md b/.claude/skills/playwright-dev/tools.md index f26b7c7f9c6d1..07becaa17cc72 100644 --- a/.claude/skills/playwright-dev/tools.md +++ b/.claude/skills/playwright-dev/tools.md @@ -15,7 +15,7 @@ import { defineTool, defineTabTool } from './tool'; **Choose `defineTabTool` vs `defineTool`:** - `defineTabTool` — most tools use this. Receives a `Tab` object, auto-handles modal state (dialogs/file choosers). -- `defineTool` — receives the full `Context`. Use when you need `context.ensureBrowserContext()` without a specific tab, or need custom tab management. +- `defineTool` — receives the full `Context`. Use when you need `context.currentBrowserContext()` (the current tab's browser context) without a specific tab, or need custom tab management. **Tool definition pattern:** @@ -81,7 +81,7 @@ const myContextTool = defineTool({ schema: { /* ... */ type: 'readOnly' }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); + const browserContext = await context.currentBrowserContext(); const cookies = await browserContext.cookies(); response.addTextResult(cookies.map(c => `${c.name}=${c.value}`).join('\n')); }, diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 132f7e790cadf..a4b3dccc5fd30 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -22,7 +22,7 @@ import debug from 'debug'; import { escapeWithQuotes } from '@isomorphic/stringUtils'; import { disposeAll } from '@isomorphic/disposable'; import { eventsHelper } from '@utils/eventsHelper'; -import { isPathInside, isSystemDirectory, isWritable, resolveSymlinks } from '@utils/fileUtils'; +import { isPathInside, isSystemDirectory, isWritable, resolveSymlinks, sanitizeForFilePath } from '@utils/fileUtils'; import { playwright } from '../../inprocess'; import { dedent, languageGeneratorId, secretCode } from './codegen'; @@ -67,6 +67,7 @@ export type ContextConfig = { settle?: number; }; browser?: { + contextOptions?: playwrightTypes.BrowserContextOptions; initScript?: string[]; initPage?: string[]; }; @@ -99,6 +100,8 @@ export type FilenameTemplate = { type VideoParams = { size?: { width: number; height: number }, fps?: number, cursor?: boolean }; +export type TraceInfo = { name: string, isolatedContext?: string }; + // Actions are paced by this delay when the cursor is shown, giving it time to travel. const kCursorDuration = 800; @@ -106,8 +109,10 @@ export class Context { readonly config: ContextConfig; readonly sessionLog: SessionLog | undefined; readonly options: ContextOptions; - private _rawBrowserContext: playwrightTypes.BrowserContext; - private _browserContextPromise: Promise | undefined; + private _defaultBrowserContext: playwrightTypes.BrowserContext; + // Keyed by isolated context name, undefined for the default context. + // Resolves once the context exists and has routes, init scripts and page listeners installed. + private _browserContexts = new Map>(); private _tabs: Tab[] = []; private _currentTab: Tab | undefined; private _routes: RouteEntry[] = []; @@ -116,7 +121,14 @@ export class Context { fileNames: string[]; fileName: string; } | undefined; - private _recordedActions: string[] | undefined; + private _recording: { + browserContext: playwrightTypes.BrowserContext; + actions: string[]; + } | undefined; + private _tracing: { + name: string; + traces: (TraceInfo & { browserContext: playwrightTypes.BrowserContext })[]; + } | undefined; private _disposables: Disposable[] = []; private _webmcpToolsSignature = ''; @@ -134,7 +146,9 @@ export class Context { this.config = options.config; this.sessionLog = options.sessionLog; this.options = options; - this._rawBrowserContext = browserContext; + this._defaultBrowserContext = browserContext; + if (this.config.testIdAttribute) + playwright.selectors.setTestIdAttribute(this.config.testIdAttribute); testDebug('create context'); process.on('unhandledRejection', this._onUnhandledRejection); } @@ -142,12 +156,14 @@ export class Context { async dispose() { process.off('unhandledRejection', this._onUnhandledRejection); await this.stopRecording(); + await this.stopVideoRecording(); await disposeAll(this._disposables); for (const tab of this._tabs) await tab.dispose(); this._tabs.length = 0; this._setCurrentTab(undefined); - await this.stopVideoRecording(); + await Promise.all([...this._browserContexts].filter(([isolatedContext]) => isolatedContext).map(([, browserContext]) => browserContext.then(c => c.close()).catch(() => {}))); + this._browserContexts.clear(); } drainPendingUnhandledRejections(): unknown[] { @@ -162,7 +178,7 @@ export class Context { } debugger() { - return this._rawBrowserContext.debugger; + return this._defaultBrowserContext.debugger; } tabs(): Tab[] { @@ -179,8 +195,8 @@ export class Context { return this._currentTab; } - async newTab(): Promise { - const browserContext = await this.ensureBrowserContext(); + async newTab(isolatedContext?: string): Promise { + const browserContext = await this.ensureBrowserContext(isolatedContext); const page = await browserContext.newPage(); this._setCurrentTab(this._tabs.find(t => t.page === page)!); return this._currentTab!; @@ -232,26 +248,26 @@ export class Context { async startVideoRecording(fileName: string, params: VideoParams) { if (this._video) throw new Error('Video recording has already been started.'); + await this.ensureBrowserContext(); this._video = { params, fileName, fileNames: [] }; - const browserContext = await this.ensureBrowserContext(); - for (const page of browserContext.pages()) - await this._startPageVideo(page); + for (const tab of this._tabs) + await this._startPageVideo(tab.page); } async stopVideoRecording(): Promise { if (!this._video) return []; const video = this._video; - for (const page of this._rawBrowserContext.pages()) - await page.screencast.stop(); + for (const tab of this._tabs) + await tab.page.screencast.stop(); this._video = undefined; return [...video.fileNames]; } async startRecording() { - if (this._recordedActions) + if (this._recording) throw new Error('Recording is already in progress.'); - const browserContext = await this.ensureBrowserContext() as BrowserContextEx; + const browserContext = await this.currentBrowserContext() as BrowserContextEx; if (typeof browserContext._startRecording !== 'function') throw new Error('Recording requires a newer version of Playwright, please upgrade.'); const recordedActions: string[] = []; @@ -272,16 +288,46 @@ export class Context { recordedActions[recordedActions.length - 1] = code; }, }); - this._recordedActions = recordedActions; + this._recording = { browserContext, actions: recordedActions }; } async stopRecording(): Promise { - const recordedActions = this._recordedActions; - if (!recordedActions) + const recording = this._recording; + if (!recording) + return undefined; + this._recording = undefined; + await (recording.browserContext as BrowserContextEx)._stopRecording(); + return recording.actions.filter(code => code.trim()).map(dedent); + } + + async startTracing(): Promise { + if (this._tracing) + throw new Error('Tracing has already been started.'); + await this.ensureBrowserContext(); + const tracing = this._tracing = { name: 'trace-' + Date.now(), traces: [] }; + for (const [isolatedContext, browserContext] of this._browserContexts) + await this._startContextTracing(isolatedContext, await browserContext); + return tracing.traces; + } + + async stopTracing(): Promise { + const tracing = this._tracing; + if (!tracing) return undefined; - this._recordedActions = undefined; - await (this._rawBrowserContext as BrowserContextEx)._stopRecording(); - return recordedActions.filter(code => code.trim()).map(dedent); + this._tracing = undefined; + await Promise.all(tracing.traces.filter(trace => !trace.browserContext.isClosed()).map(trace => trace.browserContext.tracing.stop())); + return tracing.traces; + } + + private async _startContextTracing(isolatedContext: string | undefined, browserContext: playwrightTypes.BrowserContext) { + const tracing = this._tracing; + if (!tracing || tracing.traces.some(trace => trace.browserContext === browserContext)) + return; + let name = isolatedContext ? `${tracing.name}-${sanitizeForFilePath(isolatedContext)}` : tracing.name; + if (tracing.traces.some(trace => trace.name === name)) + name += `-${tracing.traces.length}`; + tracing.traces.push({ name, isolatedContext, browserContext }); + await browserContext.tracing.start({ name, screenshots: true, snapshots: true, live: true }); } codegenLanguage(): CodegenLanguage { @@ -308,8 +354,8 @@ export class Context { } } - private _onPageCreated(page: playwrightTypes.Page) { - const tab = new Tab(this, page, tab => this._onPageClosed(tab)); + private _onPageCreated(page: playwrightTypes.Page, isolatedContext: string | undefined) { + const tab = new Tab(this, page, isolatedContext, tab => this._onPageClosed(tab)); this._tabs.push(tab); if (!this._currentTab) this._setCurrentTab(tab); @@ -351,27 +397,19 @@ export class Context { } async addRoute(entry: RouteEntry): Promise { - const browserContext = await this.ensureBrowserContext(); - await browserContext.route(entry.pattern, entry.handler); + for (const browserContext of await this._allBrowserContexts()) + await browserContext.route(entry.pattern, entry.handler); this._routes.push(entry); } async removeRoute(pattern?: string): Promise { - let removed = 0; - const browserContext = await this.ensureBrowserContext(); - if (pattern) { - const toRemove = this._routes.filter(r => r.pattern === pattern); + const toRemove = pattern ? this._routes.filter(r => r.pattern === pattern) : this._routes; + for (const browserContext of await this._allBrowserContexts()) { for (const route of toRemove) await browserContext.unroute(route.pattern, route.handler); - this._routes = this._routes.filter(r => r.pattern !== pattern); - removed = toRemove.length; - } else { - for (const route of this._routes) - await browserContext.unroute(route.pattern, route.handler); - removed = this._routes.length; - this._routes = []; } - return removed; + this._routes = this._routes.filter(r => !toRemove.includes(r)); + return toRemove.length; } isRunningTool() { @@ -382,42 +420,69 @@ export class Context { this._runningToolName = name; } - private async _setupRequestInterception(context: playwrightTypes.BrowserContext) { + private async _setupRequestInterception(context: playwrightTypes.BrowserContext, disposables: Disposable[]) { if (this.config.network?.allowedOrigins?.length) { - this._disposables.push(await context.route('**', route => route.abort('blockedbyclient'))); + disposables.push(await context.route('**', route => route.abort('blockedbyclient'))); for (const origin of this.config.network.allowedOrigins) { const glob = originOrHostGlob(origin); - this._disposables.push(await context.route(glob, route => route.continue())); + disposables.push(await context.route(glob, route => route.continue())); } } if (this.config.network?.blockedOrigins?.length) { for (const origin of this.config.network.blockedOrigins) - this._disposables.push(await context.route(originOrHostGlob(origin), route => route.abort('blockedbyclient'))); + disposables.push(await context.route(originOrHostGlob(origin), route => route.abort('blockedbyclient'))); } } - async ensureBrowserContext(): Promise { - if (this._browserContextPromise) - return this._browserContextPromise; - this._browserContextPromise = this._initializeBrowserContext(); - return this._browserContextPromise; + async ensureBrowserContext(isolatedContext?: string): Promise { + if (!isolatedContext || isolatedContext === 'default') + isolatedContext = undefined; + let browserContext = this._browserContexts.get(isolatedContext); + if (!browserContext) { + browserContext = this._initializeBrowserContext(isolatedContext); + this._browserContexts.set(isolatedContext, browserContext); + browserContext.catch(() => this._browserContexts.delete(isolatedContext)); + } + return await browserContext; } - private async _initializeBrowserContext() { - if (this.config.testIdAttribute) - playwright.selectors.setTestIdAttribute(this.config.testIdAttribute); - const browserContext = this._rawBrowserContext; - await this._setupRequestInterception(browserContext); + async currentBrowserContext(): Promise { + return this._currentTab?.page.context() ?? await this.ensureBrowserContext(); + } + + private async _allBrowserContexts(): Promise { + await this.ensureBrowserContext(); + return await Promise.all(this._browserContexts.values()); + } + + private async _initializeBrowserContext(isolatedContext: string | undefined): Promise { + const browserContext = isolatedContext ? await this._createIsolatedContext(isolatedContext) : this._defaultBrowserContext; + // Isolated contexts are closed in dispose(), which tears down their routes, scripts and listeners. + const disposables = isolatedContext ? [] : this._disposables; + await this._setupRequestInterception(browserContext, disposables); for (const initScript of this.config.browser?.initScript || []) - this._disposables.push(await browserContext.addInitScript({ path: path.resolve(this.options.cwd, initScript) })); + disposables.push(await browserContext.addInitScript({ path: path.resolve(this.options.cwd, initScript) })); + + for (const route of this._routes) + await browserContext.route(route.pattern, route.handler); for (const page of browserContext.pages()) - this._onPageCreated(page); - this._disposables.push(eventsHelper.addEventListener(browserContext, 'page', page => this._onPageCreated(page))); + this._onPageCreated(page, isolatedContext); + disposables.push(eventsHelper.addEventListener(browserContext, 'page', page => this._onPageCreated(page, isolatedContext))); + await this._startContextTracing(isolatedContext, browserContext); + return browserContext; + } + private async _createIsolatedContext(name: string): Promise { + await this.ensureBrowserContext(); + const browser = this._defaultBrowserContext.browser(); + if (!browser) + throw new Error('Isolated contexts are not supported for this browser.'); + const browserContext = await browser.newContext(this.config.browser?.contextOptions); + browserContext.once('close', () => this._browserContexts.delete(name)); return browserContext; } diff --git a/packages/playwright-core/src/tools/backend/cookies.ts b/packages/playwright-core/src/tools/backend/cookies.ts index fbd80f6797070..fb9fd95f8230c 100644 --- a/packages/playwright-core/src/tools/backend/cookies.ts +++ b/packages/playwright-core/src/tools/backend/cookies.ts @@ -33,7 +33,7 @@ const cookieList = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); + const browserContext = await context.currentBrowserContext(); let cookies = await browserContext.cookies(); if (params.domain) @@ -63,7 +63,7 @@ const cookieGet = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); + const browserContext = await context.currentBrowserContext(); const cookies = await browserContext.cookies(); const cookie = cookies.find(c => c.name === params.name); @@ -96,7 +96,7 @@ const cookieSet = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); + const browserContext = await context.currentBrowserContext(); const tab = await context.ensureTab(); // Get the current page URL to determine default domain @@ -136,7 +136,7 @@ const cookieDelete = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); + const browserContext = await context.currentBrowserContext(); await browserContext.clearCookies({ name: params.name }); response.addCode(`await page.context().clearCookies({ name: ${escapeWithQuotes(params.name)} });`); }, @@ -154,7 +154,7 @@ const cookieClear = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); + const browserContext = await context.currentBrowserContext(); await browserContext.clearCookies(); response.addCode(`await page.context().clearCookies();`); }, diff --git a/packages/playwright-core/src/tools/backend/network.ts b/packages/playwright-core/src/tools/backend/network.ts index 25f9c6f39b704..a5bf1b4454be3 100644 --- a/packages/playwright-core/src/tools/backend/network.ts +++ b/packages/playwright-core/src/tools/backend/network.ts @@ -276,7 +276,7 @@ const networkStateSet = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); + const browserContext = await context.currentBrowserContext(); const offline = params.state === 'offline'; await browserContext.setOffline(offline); response.addTextResult(`Network is now ${params.state}`); diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index 9a8a99f510d42..a54e6c2fb467c 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -354,6 +354,8 @@ export function renderTabMarkdown(tab: TabHeader): string[] { const lines = [`- Page URL: ${tab.url}`]; if (tab.title) lines.push(`- Page Title: ${tab.title}`); + if (tab.isolatedContext) + lines.push(`- Isolated context: ${tab.isolatedContext}`); if (tab.crashed) lines.push(`- Page status: crashed`); const status = tab.mainDocumentStatus; @@ -375,7 +377,8 @@ export function renderTabsMarkdown(tabs: TabHeader[]): string[] { const tab = tabs[i]; const current = tab.current ? ' (current)' : ''; const crashed = tab.crashed ? ' [crashed]' : ''; - lines.push(`- ${i}:${current} [${tab.title}](${tab.url})${crashed}`); + const isolatedContext = tab.isolatedContext ? ` [isolatedContext: ${tab.isolatedContext}]` : ''; + lines.push(`- ${i}:${current} [${tab.title}](${tab.url})${crashed}${isolatedContext}`); } return lines; } diff --git a/packages/playwright-core/src/tools/backend/storage.ts b/packages/playwright-core/src/tools/backend/storage.ts index 53546860f536a..62ace3c1330c6 100644 --- a/packages/playwright-core/src/tools/backend/storage.ts +++ b/packages/playwright-core/src/tools/backend/storage.ts @@ -32,7 +32,7 @@ const storageState = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); + const browserContext = await context.currentBrowserContext(); const state = await browserContext.storageState(); const serializedState = JSON.stringify(state, null, 2); const resolvedFile = await response.resolveClientOutputFile({ prefix: 'storage-state', ext: 'json', suggestedFilename: params.filename }, 'Storage state'); @@ -55,7 +55,7 @@ const setStorageState = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); + const browserContext = await context.currentBrowserContext(); const resolvedFilename = await response.resolveClientFilename(params.filename); await browserContext.setStorageState(resolvedFilename); response.addTextResult(`Storage state restored from ${params.filename}`); diff --git a/packages/playwright-core/src/tools/backend/tab.ts b/packages/playwright-core/src/tools/backend/tab.ts index f23a36e4387f1..a4deb7e5ee21e 100644 --- a/packages/playwright-core/src/tools/backend/tab.ts +++ b/packages/playwright-core/src/tools/backend/tab.ts @@ -83,6 +83,7 @@ export type TabHeader = { mainDocumentStatus?: { status: number, statusText: string }; console: { total: number, warnings: number, errors: number }; webmcpToolCount?: number; + isolatedContext?: string; }; type TabSnapshot = { @@ -96,6 +97,7 @@ type TabSnapshot = { export class Tab extends EventEmitter { readonly context: Context; readonly page: playwright.Page; + readonly isolatedContext: string | undefined; private _lastHeader: TabHeader = { title: 'about:blank', url: 'about:blank', current: false, crashed: false, console: { total: 0, warnings: 0, errors: 0 } }; private _downloads: Download[] = []; private _requests: playwright.Request[] = []; @@ -112,10 +114,11 @@ export class Tab extends EventEmitter { readonly navigationTimeoutOptions: { timeout?: number; }; readonly expectTimeoutOptions: { timeout?: number; }; - constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { + constructor(context: Context, page: playwright.Page, isolatedContext: string | undefined, onPageClose: (tab: Tab) => void) { super(); this.context = context; this.page = page; + this.isolatedContext = isolatedContext; this._onPageClose = onPageClose; const p = page; this._disposables = [ @@ -310,6 +313,7 @@ export class Tab extends EventEmitter { mainDocumentStatus: this._mainDocumentStatus, console: consoleCounts, webmcpToolCount: this._webmcpTools?.tools.length, + isolatedContext: this.isolatedContext, }; if (!tabHeaderEquals(this._lastHeader, newHeader)) { @@ -672,5 +676,6 @@ function tabHeaderEquals(a: TabHeader, b: TabHeader): boolean { a.console.errors === b.console.errors && a.console.warnings === b.console.warnings && a.console.total === b.console.total && - a.webmcpToolCount === b.webmcpToolCount; + a.webmcpToolCount === b.webmcpToolCount && + a.isolatedContext === b.isolatedContext; } diff --git a/packages/playwright-core/src/tools/backend/tabs.ts b/packages/playwright-core/src/tools/backend/tabs.ts index 93ad12a02f508..d910a45431b4c 100644 --- a/packages/playwright-core/src/tools/backend/tabs.ts +++ b/packages/playwright-core/src/tools/backend/tabs.ts @@ -29,6 +29,7 @@ const browserTabs = defineTool({ action: z.enum(['list', 'new', 'close', 'select']).describe('Operation to perform'), index: z.number().optional().describe('Tab index, used for close/select. If omitted for close, current tab is closed.'), url: z.string().optional().describe('URL to navigate to in the new tab, used for new.'), + isolatedContext: z.string().optional().describe('Name of an isolated browser context to create the new tab in, used for new. Tabs in the same isolated context share cookies and storage, tabs in different contexts are fully isolated. If omitted or "default", the tab is created in the default context.'), }), type: 'action', }, @@ -40,7 +41,7 @@ const browserTabs = defineTool({ break; } case 'new': { - const tab = await context.newTab(); + const tab = await context.newTab(params.isolatedContext); if (params.url) { const url = await tab.checkUrlAndNavigate(params.url); response.setIncludeSnapshot(); diff --git a/packages/playwright-core/src/tools/backend/tracing.ts b/packages/playwright-core/src/tools/backend/tracing.ts index c5bc91ce3e581..79736462b23b2 100644 --- a/packages/playwright-core/src/tools/backend/tracing.ts +++ b/packages/playwright-core/src/tools/backend/tracing.ts @@ -17,6 +17,9 @@ import * as z from 'zod'; import { defineTool } from './tool'; +import type { Context, TraceInfo } from './context'; +import type { Response } from './response'; + const tracingStart = defineTool({ capability: 'devtools', @@ -30,21 +33,9 @@ const tracingStart = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); - const tracesDir = await context.outputFile({ prefix: '', suggestedFilename: `traces`, ext: '' }, { origin: 'code' }); - const name = 'trace-' + Date.now(); - await browserContext.tracing.start({ - name, - screenshots: true, - snapshots: true, - live: true, - }); + const traces = await context.startTracing(); response.addTextResult(`Trace recording started`); - response.addFileLink('Action log', `${tracesDir}/${name}.trace`); - response.addFileLink('Network log', `${tracesDir}/${name}.network`); - response.addFileLink('Resources', `${tracesDir}/resources`); - // eslint-disable-next-line no-restricted-syntax - (browserContext.tracing as any)[traceLegendSymbol] = { tracesDir, name }; + await addTraceLinks(context, response, traces, 'Action log'); }, }); @@ -60,25 +51,25 @@ const tracingStop = defineTool({ }, handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); - // eslint-disable-next-line no-restricted-syntax - const traceLegend = (browserContext.tracing as any)[traceLegendSymbol]; - if (!traceLegend) + const traces = await context.stopTracing(); + if (!traces) throw new Error('Tracing is not started'); - await browserContext.tracing.stop(); - // eslint-disable-next-line no-restricted-syntax - delete (browserContext.tracing as any)[traceLegendSymbol]; - response.addTextResult(`Trace recording stopped.`); - response.addFileLink('Trace', `${traceLegend.tracesDir}/${traceLegend.name}.trace`); - response.addFileLink('Network log', `${traceLegend.tracesDir}/${traceLegend.name}.network`); - response.addFileLink('Resources', `${traceLegend.tracesDir}/resources`); + await addTraceLinks(context, response, traces, 'Trace'); }, }); +async function addTraceLinks(context: Context, response: Response, traces: TraceInfo[], traceTitle: string) { + const tracesDir = await context.outputFile({ prefix: '', suggestedFilename: `traces`, ext: '' }, { origin: 'code' }); + for (const trace of traces) { + const suffix = trace.isolatedContext ? ` (isolatedContext: ${trace.isolatedContext})` : ''; + response.addFileLink(traceTitle + suffix, `${tracesDir}/${trace.name}.trace`); + response.addFileLink('Network log' + suffix, `${tracesDir}/${trace.name}.network`); + } + response.addFileLink('Resources', `${tracesDir}/resources`); +} + export default [ tracingStart, tracingStop, ]; - -const traceLegendSymbol = Symbol('tracesDir'); diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index c28b54f855adc..4c42b8169699f 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -610,8 +610,11 @@ const tabNew = declareCommand({ args: z.object({ url: z.string().optional().describe('The URL to navigate to in the new tab. If omitted, the new tab will be blank.'), }), + options: z.object({ + ['isolated-context']: z.string().optional().describe('Name of an isolated browser context to create the tab in. Tabs in the same isolated context share cookies and storage.'), + }), toolName: 'browser_tabs', - toolParams: ({ url }) => ({ action: 'new', url }), + toolParams: ({ url, ['isolated-context']: isolatedContext }) => ({ action: 'new', url, isolatedContext }), }); const tabClose = declareCommand({ diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md index b10dc7b3ebaa0..4e29c62517a04 100644 --- a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md +++ b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md @@ -113,6 +113,7 @@ playwright-cli pdf --filename=page.pdf playwright-cli tab-list playwright-cli tab-new playwright-cli tab-new https://example.com/page +playwright-cli tab-new https://example.com/page --isolated-context=user-b playwright-cli tab-close playwright-cli tab-close 2 playwright-cli tab-select 0 diff --git a/tests/mcp/cli-navigation.spec.ts b/tests/mcp/cli-navigation.spec.ts index ae34d404b79d4..00fc31c34a3c3 100644 --- a/tests/mcp/cli-navigation.spec.ts +++ b/tests/mcp/cli-navigation.spec.ts @@ -58,6 +58,12 @@ test('tab-new with url', async ({ cli, server }) => { expect(output).toContain(`- 1: (current) [Title](${server.HELLO_WORLD})`); }); +test('tab-new with isolated context', async ({ cli, server }) => { + await cli('open'); + const { output } = await cli('tab-new', server.HELLO_WORLD, '--isolated-context=alice'); + expect(output).toContain(`- 1: (current) [Title](${server.HELLO_WORLD}) [isolatedContext: alice]`); +}); + test('run-code', async ({ cli, server }) => { await cli('open', server.HELLO_WORLD); const { output } = await cli('run-code', '() => page.title()'); diff --git a/tests/mcp/cookies.spec.ts b/tests/mcp/cookies.spec.ts index 98b2c54503dc2..26bb7ef04c1c2 100644 --- a/tests/mcp/cookies.spec.ts +++ b/tests/mcp/cookies.spec.ts @@ -334,3 +334,44 @@ test('browser_cookie_clear removes all cookies', async ({ startClient, server }) result: 'No cookies found', }); }); + +test('browser_cookie_set and browser_cookie_get use the current tab context', async ({ startClient, server }) => { + const { client } = await startClient({ + config: { capabilities: ['storage'] }, + }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.EMPTY_PAGE }, + }); + await client.callTool({ + name: 'browser_cookie_set', + arguments: { name: 'user', value: 'default' }, + }); + + await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'new', url: server.EMPTY_PAGE, isolatedContext: 'alice' }, + }); + await client.callTool({ + name: 'browser_cookie_set', + arguments: { name: 'user', value: 'alice' }, + }); + expect(await client.callTool({ + name: 'browser_cookie_get', + arguments: { name: 'user' }, + })).toHaveResponse({ + result: expect.stringContaining('user=alice'), + }); + + await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'select', index: 0 }, + }); + expect(await client.callTool({ + name: 'browser_cookie_get', + arguments: { name: 'user' }, + })).toHaveResponse({ + result: expect.stringContaining('user=default'), + }); +}); diff --git a/tests/mcp/route.spec.ts b/tests/mcp/route.spec.ts index 0f627d30868b7..60a2b3a698d1c 100644 --- a/tests/mcp/route.spec.ts +++ b/tests/mcp/route.spec.ts @@ -257,3 +257,30 @@ test('browser_unroute removes all routes', async ({ client, server }) => { })); expect(list.result).toContain('No active routes'); }); + +test('browser_route applies to isolated contexts', async ({ client, server }) => { + server.setContent('/', `Original`, 'text/html'); + + await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'new', isolatedContext: 'alice' }, + }); + await client.callTool({ + name: 'browser_route', + arguments: { pattern: '**/', body: 'Mocked', contentType: 'text/html' }, + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toHaveResponse({ + snapshot: expect.stringContaining('Mocked'), + }); + + expect(await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'new', url: server.PREFIX, isolatedContext: 'bob' }, + })).toHaveResponse({ + snapshot: expect.stringContaining('Mocked'), + }); +}); diff --git a/tests/mcp/tabs.spec.ts b/tests/mcp/tabs.spec.ts index 3743dd1f25fea..587ea9786ec43 100644 --- a/tests/mcp/tabs.spec.ts +++ b/tests/mcp/tabs.spec.ts @@ -149,3 +149,56 @@ test('reuse first tab when navigating', async ({ startClient, cdpServer, server expect(pages.length).toBe(1); expect(await pages[0].title()).toBe('Title'); }); + +test('create new tab in isolated context', async ({ client, server }) => { + expect(await client.callTool({ + name: 'browser_tabs', + arguments: { + action: 'new', + url: server.HELLO_WORLD, + isolatedContext: 'alice', + }, + })).toHaveResponse({ + result: `- 0: [](about:blank) +- 1: (current) [Title](${server.HELLO_WORLD}) [isolatedContext: alice]`, + page: expect.stringContaining('- Isolated context: alice'), + }); +}); + +test('tabs in the same isolated context share storage', async ({ client, server }) => { + const newTab = (isolatedContext?: string) => client.callTool({ + name: 'browser_tabs', + arguments: { action: 'new', url: server.EMPTY_PAGE, isolatedContext }, + }); + const selectTab = (index: number) => client.callTool({ + name: 'browser_tabs', + arguments: { action: 'select', index }, + }); + const setCookie = (value: string) => client.callTool({ + name: 'browser_evaluate', + arguments: { function: `() => document.cookie = 'user=${value}'` }, + }); + const getCookie = () => client.callTool({ + name: 'browser_evaluate', + arguments: { function: '() => document.cookie' }, + }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.EMPTY_PAGE }, + }); + await setCookie('default'); + await newTab('alice'); + await setCookie('alice'); + await newTab('bob'); + await setCookie('bob'); + await newTab('alice'); + + expect(await getCookie()).toHaveResponse({ result: `"user=alice"` }); + await selectTab(0); + expect(await getCookie()).toHaveResponse({ result: `"user=default"` }); + await selectTab(2); + expect(await getCookie()).toHaveResponse({ result: `"user=bob"` }); + await newTab('default'); + expect(await getCookie()).toHaveResponse({ result: `"user=default"` }); +}); diff --git a/tests/mcp/tracing.spec.ts b/tests/mcp/tracing.spec.ts index 8aa4f23e1aaa9..d0ddd81eb63ad 100644 --- a/tests/mcp/tracing.spec.ts +++ b/tests/mcp/tracing.spec.ts @@ -86,6 +86,31 @@ test('check that trace is saved with browser_start_tracing (no output dir)', asy ]); }); +test('browser_start_tracing traces isolated contexts', async ({ startClient, server }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + + const { client } = await startClient({ args: [`--output-dir=${outputDir}`, '--caps=tracing'] }); + + await client.callTool({ + name: 'browser_start_tracing', + }); + + await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'new', url: server.HELLO_WORLD, isolatedContext: 'alice' }, + }); + + expect(await client.callTool({ + name: 'browser_stop_tracing', + })).toHaveResponse({ + result: expect.stringMatching(/- \[Trace\]\(.*trace-\d+\.trace\)\n[^]*- \[Trace \(isolatedContext: alice\)\]\(.*trace-\d+-alice\.trace\)/), + }); + + const tracesDir = path.join(outputDir, 'traces'); + const aliceTrace = (await fs.promises.readdir(tracesDir)).find(file => /^trace-\d+-alice\.trace$/.test(file)); + expect(await fs.promises.readFile(path.join(tracesDir, aliceTrace!), 'utf-8')).toContain(server.HELLO_WORLD); +}); + test('browser_stop_tracing without start returns error', async ({ startClient }) => { const { client } = await startClient({ args: ['--caps=tracing'],