From 1d5286ca341d637a6bccaf7fe358fc8ca31ec0bb Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 22 Apr 2026 08:31:59 +0200 Subject: [PATCH 1/3] feat(browser): browser.on(context) --- docs/src/api/class-browser.md | 6 ++++ packages/playwright-client/types/types.d.ts | 30 +++++++++++++++++++ .../playwright-core/src/client/browser.ts | 1 + packages/playwright-core/src/client/events.ts | 1 + .../tools/dashboard/dashboardController.ts | 6 ++++ packages/playwright-core/types/types.d.ts | 30 +++++++++++++++++++ tests/library/browser.spec.ts | 7 +++++ tests/mcp/cli-fixtures.ts | 20 ++++++++++--- tests/mcp/dashboard.spec.ts | 11 +++---- 9 files changed, 103 insertions(+), 9 deletions(-) diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 0c181f926b4db..b676c64dc04ac 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -72,6 +72,12 @@ await page.GotoAsync("https://www.bing.com"); await browser.CloseAsync(); ``` +## event: Browser.context +* since: v1.60 +- argument: <[BrowserContext]> + +Emitted when a new browser context is created. + ## event: Browser.disconnected * since: v1.8 - argument: <[Browser]> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index b0a45b471e42a..ba593c8b90da8 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9974,6 +9974,11 @@ export interface Browser { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + /** + * Emitted when a new browser context is created. + */ + on(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9982,11 +9987,21 @@ export interface Browser { */ on(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ once(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Emitted when a new browser context is created. + */ + addListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9995,16 +10010,31 @@ export interface Browser { */ addListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ removeListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ off(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Emitted when a new browser context is created. + */ + prependListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index 1f19055489b37..d7643ce0745d0 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -105,6 +105,7 @@ export class Browser extends ChannelOwner implements ap private _didCreateContext(context: BrowserContext) { context._browser = this; this._contexts.add(context); + this.emit(Events.Browser.Context, context); // Note: when connecting to a browser, initial contexts arrive before `browserType` is set, // and will be configured later in `_connectToBrowserType`. if (this._browserType) diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index f244da4dd75d3..c988ee45d02e3 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -31,6 +31,7 @@ export const Events = { }, Browser: { + Context: 'context', Disconnected: 'disconnected' }, diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index 3460386245a5d..e13d70810b378 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -41,6 +41,7 @@ class BrowserTracker { readonly browser: api.Browser; private _callbacks: BrowserTrackerCallbacks; private _contextListeners = new Map(); + private _browserListeners: Disposable[] = []; static async create(descriptor: BrowserDescriptor, callbacks: BrowserTrackerCallbacks): Promise { try { @@ -48,6 +49,9 @@ class BrowserTracker { const slot = new BrowserTracker(descriptor, browser, callbacks); for (const context of browser.contexts()) slot._wireContext(context); + slot._browserListeners.push(eventsHelper.addEventListener(browser, 'context', (context: api.BrowserContext) => { + slot._wireContext(context); + })); return slot; } catch { return undefined; @@ -65,6 +69,8 @@ class BrowserTracker { } dispose() { + this._browserListeners.forEach(d => d.dispose()); + this._browserListeners = []; for (const listeners of this._contextListeners.values()) listeners.forEach(d => d.dispose()); this._contextListeners.clear(); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index b0a45b471e42a..ba593c8b90da8 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9974,6 +9974,11 @@ export interface Browser { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + /** + * Emitted when a new browser context is created. + */ + on(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9982,11 +9987,21 @@ export interface Browser { */ on(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ once(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Emitted when a new browser context is created. + */ + addListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9995,16 +10010,31 @@ export interface Browser { */ addListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ removeListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ off(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Emitted when a new browser context is created. + */ + prependListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: diff --git a/tests/library/browser.spec.ts b/tests/library/browser.spec.ts index 19d47f871a4ea..a3db493051925 100644 --- a/tests/library/browser.spec.ts +++ b/tests/library/browser.spec.ts @@ -63,6 +63,13 @@ test('should dispatch page.on(close) upon browser.close and reject evaluate', as expect(error.message).toContain(kTargetClosedErrorMessage); }); +test('should fire context event on newContext', async ({ browser }) => { + const events = []; + browser.on('context', ctx => events.push(ctx)); + const context = await browser.newContext(); + expect(events).toEqual([context]); +}); + test('newContext should not leave a context upon failure', async ({ browser, toImpl }) => { const error = await browser.newContext({ __testHookBeforeSetStorageState: () => Promise.reject(new Error('Oh my')), diff --git a/tests/mcp/cli-fixtures.ts b/tests/mcp/cli-fixtures.ts index c670c4891e28d..2b578149c1c75 100644 --- a/tests/mcp/cli-fixtures.ts +++ b/tests/mcp/cli-fixtures.ts @@ -91,10 +91,22 @@ export const test = baseTest.extend<{ for (const pid of allPids) killProcessGroup(pid); - const daemonDir = path.join(test.info().outputDir, 'daemon'); - const userDataDirs = await fs.promises.readdir(daemonDir).catch(() => []); - for (const dir of userDataDirs.filter(f => f.startsWith('ud-'))) - await fs.promises.rm(path.join(daemonDir, dir), { recursive: true, force: true }).catch(() => {}); + const daemonDir = test.info().outputPath('daemon'); + for (const dir of await fs.promises.readdir(daemonDir).catch(() => [])) { + if (dir.startsWith('ud-')) { + await fs.promises.rm(path.join(daemonDir, dir), { recursive: true, force: true }).catch(() => {}); + continue; + } + const workspacePath = path.join(daemonDir, dir); + for (const entry of await fs.promises.readdir(workspacePath).catch(() => [])) { + if (!entry.endsWith('.err')) + continue; + const errPath = path.join(workspacePath, entry); + if ((await fs.promises.stat(errPath)).size === 0) + continue; + await test.info().attach(entry, { path: errPath, contentType: 'text/plain' }); + } + } }, boundBrowser: async ({ mcpBrowser, playwright }, use) => { const browserName = (mcpBrowser === 'chrome' || mcpBrowser === 'msedge') ? 'chromium' : mcpBrowser; diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts index a8a5c7f39b92d..1e74957723634 100644 --- a/tests/mcp/dashboard.spec.ts +++ b/tests/mcp/dashboard.spec.ts @@ -55,12 +55,14 @@ test('should show one row per context for a single browser', async ({ boundBrows const contextA = await boundBrowser.newContext(); const pageA = await contextA.newPage(); await pageA.goto(server.EMPTY_PAGE); - const contextB = await boundBrowser.newContext(); - const pageB = await contextB.newPage(); - await pageB.goto(server.EMPTY_PAGE); const dashboard = await startDashboardServer(); const chips = dashboard.locator('.session-chip'); + await expect(chips).toHaveCount(1); + + const contextB = await boundBrowser.newContext(); + const pageB = await contextB.newPage(); + await pageB.goto(server.EMPTY_PAGE); await expect(chips).toHaveCount(2); }); @@ -97,8 +99,7 @@ test('should show current workspace sessions first', async ({ cli, server, start }); }); -test('should activate session when show is called with -s', async ({ cli, server, startDashboardServer, mcpBrowser }) => { - test.fixme(mcpBrowser === 'firefox', 'race condition gonna be fixed through https://github.com/microsoft/playwright/pull/40315'); +test('should activate session when show is called with -s', async ({ cli, server, startDashboardServer }) => { await cli('-s=sessA', 'open', server.EMPTY_PAGE); await cli('-s=sessB', 'open', server.EMPTY_PAGE); From 9a8e4e595da04e99221365b30bd848e6cb37c973 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 22 Apr 2026 09:43:36 +0200 Subject: [PATCH 2/3] chore: emit context event after context is fully initialized --- packages/playwright-core/src/client/browser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index d7643ce0745d0..010d16bb5f43e 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -105,11 +105,11 @@ export class Browser extends ChannelOwner implements ap private _didCreateContext(context: BrowserContext) { context._browser = this; this._contexts.add(context); - this.emit(Events.Browser.Context, context); // Note: when connecting to a browser, initial contexts arrive before `browserType` is set, // and will be configured later in `_connectToBrowserType`. if (this._browserType) this._setupBrowserContext(context); + this.emit(Events.Browser.Context, context); } private _setupBrowserContext(context: BrowserContext) { From f0b666fa432cdbba1d3883e48efb19e09f547077 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 22 Apr 2026 10:10:46 +0200 Subject: [PATCH 3/3] test(browser): cover browser.on(context) over multiclient connect --- tests/library/multiclient.spec.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/library/multiclient.spec.ts b/tests/library/multiclient.spec.ts index e5896693b2ef9..50e1582bc2bdd 100644 --- a/tests/library/multiclient.spec.ts +++ b/tests/library/multiclient.spec.ts @@ -16,7 +16,7 @@ import { kTargetClosedErrorMessage } from '../config/errors'; import { expect, playwrightTest } from '../config/browserTest'; -import type { Browser, BrowserServer, ConnectOptions, Page } from 'playwright-core'; +import type { Browser, BrowserContext, BrowserServer, ConnectOptions, Page } from 'playwright-core'; type ExtraFixtures = { remoteServer: BrowserServer; @@ -93,6 +93,19 @@ test('should connect two clients', async ({ connect, remoteServer, server }) => await expect(pageB2).toHaveURL(server.PREFIX + '/frames/frame.html'); }); +test('should fire context event on remote newContext', async ({ connect, remoteServer }) => { + const browserA = await connect(remoteServer.wsEndpoint()); + const events: BrowserContext[] = []; + browserA.on('context', ctx => events.push(ctx)); + + const browserB = await connect(remoteServer.wsEndpoint()); + const contextB = await browserB.newContext(); + + await expect.poll(() => events).toHaveLength(1); + expect(browserA.contexts()).toEqual([events[0]]); + expect(events[0]).not.toBe(contextB); +}); + test('should have separate default timeouts', async ({ twoPages }) => { const { pageA, pageB } = twoPages; pageA.setDefaultTimeout(500);