Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/playwright-dev/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down Expand Up @@ -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'));
},
Expand Down
175 changes: 120 additions & 55 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +67,7 @@ export type ContextConfig = {
settle?: number;
};
browser?: {
contextOptions?: playwrightTypes.BrowserContextOptions;
initScript?: string[];
initPage?: string[];
};
Expand Down Expand Up @@ -99,15 +100,19 @@ 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;

export class Context {
readonly config: ContextConfig;
readonly sessionLog: SessionLog | undefined;
readonly options: ContextOptions;
private _rawBrowserContext: playwrightTypes.BrowserContext;
private _browserContextPromise: Promise<playwrightTypes.BrowserContext> | 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drop the comment?

private _browserContexts = new Map<string | undefined, Promise<playwrightTypes.BrowserContext>>();
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
private _routes: RouteEntry[] = [];
Expand All @@ -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 = '';
Expand All @@ -134,20 +146,24 @@ 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);
}

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(() => {})));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can context name be '' ?

this._browserContexts.clear();
}

drainPendingUnhandledRejections(): unknown[] {
Expand All @@ -162,7 +178,7 @@ export class Context {
}

debugger() {
return this._rawBrowserContext.debugger;
return this._defaultBrowserContext.debugger;
}

tabs(): Tab[] {
Expand All @@ -179,8 +195,8 @@ export class Context {
return this._currentTab;
}

async newTab(): Promise<Tab> {
const browserContext = await this.ensureBrowserContext();
async newTab(isolatedContext?: string): Promise<Tab> {
const browserContext = await this.ensureBrowserContext(isolatedContext);
const page = await browserContext.newPage();
this._setCurrentTab(this._tabs.find(t => t.page === page)!);
return this._currentTab!;
Expand Down Expand Up @@ -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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will it also start video for a context created later?

@pavelfeldman Pavel Feldman (pavelfeldman) Oct 3, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Video is wired per page.

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<string[]> {
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[] = [];
Expand All @@ -272,16 +288,46 @@ export class Context {
recordedActions[recordedActions.length - 1] = code;
},
});
this._recordedActions = recordedActions;
this._recording = { browserContext, actions: recordedActions };
}

async stopRecording(): Promise<string[] | undefined> {
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<TraceInfo[]> {
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<TraceInfo[] | undefined> {
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 {
Expand All @@ -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);
Expand Down Expand Up @@ -351,27 +397,19 @@ export class Context {
}

async addRoute(entry: RouteEntry): Promise<void> {
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<number> {
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() {
Expand All @@ -382,42 +420,69 @@ export class Context {
this._runningToolName = name;
}

private async _setupRequestInterception(context: playwrightTypes.BrowserContext) {
private async _setupRequestInterception(context: playwrightTypes.BrowserContext, disposables: Disposable[]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it also add entries from this._routes ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, in _initializeBrowserContext

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<playwrightTypes.BrowserContext> {
if (this._browserContextPromise)
return this._browserContextPromise;
this._browserContextPromise = this._initializeBrowserContext();
return this._browserContextPromise;
async ensureBrowserContext(isolatedContext?: string): Promise<playwrightTypes.BrowserContext> {
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<playwrightTypes.BrowserContext> {
return this._currentTab?.page.context() ?? await this.ensureBrowserContext();
}

private async _allBrowserContexts(): Promise<playwrightTypes.BrowserContext[]> {
await this.ensureBrowserContext();
return await Promise.all(this._browserContexts.values());
}

private async _initializeBrowserContext(isolatedContext: string | undefined): Promise<playwrightTypes.BrowserContext> {
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<playwrightTypes.BrowserContext> {
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;
}

Expand Down
10 changes: 5 additions & 5 deletions packages/playwright-core/src/tools/backend/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)} });`);
},
Expand All @@ -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();`);
},
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/tools/backend/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Loading
Loading