From 75748000ea481256d319faae844053d63a914d48 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:21:37 +0000 Subject: [PATCH] fix(extension): explain debugger detach caused by foreign frames Fixes: https://github.com/microsoft/playwright/issues/43230 --- packages/extension/README.md | 3 ++ .../playwright-core/src/client/channels.d.ts | 4 +- packages/playwright-core/src/client/page.ts | 5 ++- .../playwright-core/src/server/channels.d.ts | 4 +- .../src/server/chromium/crPage.ts | 4 ++ .../src/server/dispatchers/pageDispatcher.ts | 2 +- .../src/tools/backend/response.ts | 6 ++- .../playwright-core/src/tools/backend/tab.ts | 6 ++- .../src/tools/mcp/browserModel.ts | 33 ++++++++++++-- .../src/tools/mcp/cdpRelayV2.ts | 4 +- packages/protocol/spec/page.yml | 3 +- packages/protocol/src/validator.ts | 4 +- tests/extension/extension-fixtures.ts | 9 ++-- tests/extension/scheme-detach.spec.ts | 43 +++++++++++++++++++ 14 files changed, 112 insertions(+), 18 deletions(-) diff --git a/packages/extension/README.md b/packages/extension/README.md index 9fece1a317427..d2aef9ead3932 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -93,4 +93,7 @@ If the extension is installed in several Chrome profiles, the connection is made The `PLAYWRIGHT_MCP_PROFILE_DIR_NAME` environment variable can be used instead of the option. The authentication token is specific to the profile, so use the one displayed in that profile. +## Troubleshooting + +Chrome does not allow one extension to debug a page that contains a frame from another extension. If another extension injects a `chrome-extension://` frame, Chrome detaches the Playwright debugger and the tab is no longer available to the client. Disable the other extension on the sites you automate, set its **Site access** to **On click**, or use a profile without it. After changing site access, navigate again to reconnect. diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index ceac209befb2e..31514b0eaf220 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -4049,7 +4049,9 @@ export interface PageChannel extends PageEventTarget, Channel { export type PageBindingCallEvent = { binding: BindingCallChannel, }; -export type PageCloseEvent = {}; +export type PageCloseEvent = { + reason?: string, +}; export type PageCrashEvent = {}; export type PageDownloadEvent = { url: string, diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 85e9e1870a5b1..fed8d34c59ba2 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -156,7 +156,10 @@ export class Page extends ChannelOwner implements api.Page this.screencast = new Screencast(this); this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding))); - this._channel.on('close', () => this._onClose()); + this._channel.on('close', ({ reason }) => { + this._closeReason ??= reason; + this._onClose(); + }); this._channel.on('crash', () => this._onCrash()); this._channel.on('download', ({ url, suggestedFilename, artifact }) => { const artifactObject = Artifact.from(artifact); diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index dc066c5af32d7..900fc69506049 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -4050,7 +4050,9 @@ export interface PageChannel extends PageEventTarget, Channel { export type PageBindingCallEvent = { binding: BindingCallChannel, }; -export type PageCloseEvent = {}; +export type PageCloseEvent = { + reason?: string, +}; export type PageCrashEvent = {}; export type PageDownloadEvent = { url: string, diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 18a459b8a4066..2ace6d26df243 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -441,6 +441,10 @@ class FrameSession { private _addRendererListeners() { this._eventListeners.push(...[ + eventsHelper.addEventListener(this._client, 'Inspector.detached', event => { + if (this._isMainFrame()) + this._page._closeReason = event.reason; + }), eventsHelper.addEventListener(this._client, 'Log.entryAdded', event => this._onLogEntryAdded(event)), eventsHelper.addEventListener(this._client, 'Page.fileChooserOpened', event => this._onFileChooserOpened(event)), eventsHelper.addEventListener(this._client, 'Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)), diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 790ffdca7fa62..74b80c8729a42 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -109,7 +109,7 @@ export class PageDispatcher extends Dispatcher { - this._dispatchEvent('close'); + this._dispatchEvent('close', { reason: page.closeReason() }); this._dispose(); }); this.addObjectListener(Page.Events.Crash, () => this._dispatchEvent('crash')); diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index a54e6c2fb467c..3f1e7c71a9bae 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -295,7 +295,11 @@ export class Response { if (this._includeSnapshot !== 'none' || tabHeaders.some(header => header.changed)) { if (tabHeaders.length !== 1) addSection('Open tabs', renderTabsMarkdown(tabHeaders)); - addSection('Page', renderTabMarkdown(tabHeaders.find(h => h.current) ?? tabHeaders[0])); + const currentTab = tabHeaders.find(h => h.current) ?? tabHeaders[0]; + if (currentTab) + addSection('Page', renderTabMarkdown(currentTab)); + else if (this._includeSnapshot !== 'none' && !this._errors.length) + addSection('Error', ['No open tabs. The page was closed before a snapshot could be captured.']); } // Handle modal states. diff --git a/packages/playwright-core/src/tools/backend/tab.ts b/packages/playwright-core/src/tools/backend/tab.ts index b79116851b5dc..7e6f9ab06a61c 100644 --- a/packages/playwright-core/src/tools/backend/tab.ts +++ b/packages/playwright-core/src/tools/backend/tab.ts @@ -361,7 +361,11 @@ export class Tab extends EventEmitter { async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise { await this._initializedPromise; - await this.page.waitForLoadState(state, options).catch(e => debug('pw:tools:error')(e)); + await this.page.waitForLoadState(state, options).catch(e => { + if (this.page.isClosed()) + throw e; + debug('pw:tools:error')(e); + }); } async checkUrlAndNavigate(url: string): Promise { diff --git a/packages/playwright-core/src/tools/mcp/browserModel.ts b/packages/playwright-core/src/tools/mcp/browserModel.ts index 13269c8884a54..0390718c18982 100644 --- a/packages/playwright-core/src/tools/mcp/browserModel.ts +++ b/packages/playwright-core/src/tools/mcp/browserModel.ts @@ -34,6 +34,7 @@ */ import { logUnhandledError } from './log'; +import { playwrightExtensionId } from '../utils/extension'; import type { DebuggerSession, Debuggee, Tab } from './protocol'; @@ -56,6 +57,7 @@ type TabSession = { // Child CDP sessionIds (workers, oopifs, ...) belonging to this tab, // tracked via Target.attachedToTarget / Target.detachedFromTarget events. childSessions: Set; + pendingExtensionFrames: Map; }; export class BrowserModel { @@ -108,6 +110,16 @@ export class BrowserModel { const tabSession = this._tabSessions.get(source.tabId); if (!tabSession) return; + if (method === 'Page.frameRequestedNavigation') { + if (params.url?.startsWith('chrome-extension://') && !params.url.startsWith(`chrome-extension://${playwrightExtensionId}/`)) + tabSession.pendingExtensionFrames.set(params.frameId, params.url); + else + tabSession.pendingExtensionFrames.delete(params.frameId); + } + if (method === 'Page.frameNavigated') + tabSession.pendingExtensionFrames.delete(params.frame.id); + if (method === 'Page.frameDetached') + tabSession.pendingExtensionFrames.delete(params.frameId); // Track child CDP sessions so we can route subsequent commands for // them to the correct tab. Target.attachedToTarget introduces a new // sessionId belonging to the same tab; Target.detachedFromTarget @@ -123,9 +135,22 @@ export class BrowserModel { this._emit({ sessionId, method, params }); } - onDebuggerDetach(source: Debuggee): void { - if (source.tabId !== undefined) - this._detachTab(source.tabId); + onDebuggerDetach(source: Debuggee, reason: string): void { + if (source.tabId === undefined) + return; + const tabSession = this._tabSessions.get(source.tabId); + const extensionFrame = tabSession?.pendingExtensionFrames.values().next().value; + if (tabSession && reason === 'target_closed' && extensionFrame) { + // Preserve the cause before Target.detachedFromTarget closes the Playwright page. + this._emit({ + sessionId: tabSession.sessionId, + method: 'Inspector.detached', + params: { + reason: `Chrome detached the debugger while a frame was navigating to "${extensionFrame}". Chrome does not allow debugging pages containing frames from another extension. Disable that extension on this site or set its site access to "On click".`, + }, + }); + } + this._detachTab(source.tabId); } // ─── Playwright → model commands ────────────────────────────────────── @@ -210,7 +235,7 @@ export class BrowserModel { ]); const targetInfo = result?.targetInfo; const sessionId = `pw-tab-${this._nextSessionId++}`; - const tabSession: TabSession = { tabId, sessionId, targetInfo, childSessions: new Set() }; + const tabSession: TabSession = { tabId, sessionId, targetInfo, childSessions: new Set(), pendingExtensionFrames: new Map() }; this._tabSessions.set(tabId, tabSession); this._emit({ method: 'Target.attachedToTarget', diff --git a/packages/playwright-core/src/tools/mcp/cdpRelayV2.ts b/packages/playwright-core/src/tools/mcp/cdpRelayV2.ts index 013e4eea888ac..05dce5d2f7873 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelayV2.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelayV2.ts @@ -71,8 +71,8 @@ export class ExtensionProtocolV2 { break; } case 'chrome.debugger.onDetach': { - const [source] = params as ExtensionEventsV2['chrome.debugger.onDetach']['params']; - this._model.onDebuggerDetach(source); + const [source, reason] = params as ExtensionEventsV2['chrome.debugger.onDetach']['params']; + this._model.onDebuggerDetach(source, reason); break; } case 'chrome.tabs.onCreated': { diff --git a/packages/protocol/spec/page.yml b/packages/protocol/spec/page.yml index be58094ebe7df..f5e7ebf22593a 100644 --- a/packages/protocol/spec/page.yml +++ b/packages/protocol/spec/page.yml @@ -728,6 +728,8 @@ Page: binding: BindingCall close: + parameters: + reason: string? crash: @@ -785,4 +787,3 @@ Page: worker: parameters: worker: Worker - diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 3af381cf7899e..e4ec61ffc7f62 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -2301,7 +2301,9 @@ scheme.PageInitializer = tObject({ scheme.PageBindingCallEvent = tObject({ binding: tChannel(['BindingCall']), }); -scheme.PageCloseEvent = tOptional(tObject({})); +scheme.PageCloseEvent = tObject({ + reason: tOptional(tString), +}); scheme.PageCrashEvent = tOptional(tObject({})); scheme.PageDownloadEvent = tObject({ url: tString, diff --git a/tests/extension/extension-fixtures.ts b/tests/extension/extension-fixtures.ts index 760c78fdaf8f3..76787c2977074 100644 --- a/tests/extension/extension-fixtures.ts +++ b/tests/extension/extension-fixtures.ts @@ -28,7 +28,7 @@ import type { StartClient } from '../mcp/fixtures'; export type BrowserWithExtension = { userDataDir: string; - launch: (mode?: 'disable-extension') => Promise; + launch: (mode?: 'disable-extension', additionalExtensions?: string[]) => Promise; }; export type CliResult = { @@ -65,7 +65,8 @@ export const test = base.extend({ await fs.mkdir(path.join(userDataDir, 'Default', 'Extensions', extensionId), { recursive: true }); await use({ userDataDir, - launch: async (mode?: 'disable-extension') => { + launch: async (mode?: 'disable-extension', additionalExtensions: string[] = []) => { + const extensions = [pathToExtension, ...additionalExtensions].join(','); browserContext = await chromium.launchPersistentContext(userDataDir, { channel: mcpBrowser, // Opening the browser singleton only works in headed. @@ -73,8 +74,8 @@ export const test = base.extend({ // Automation disables singleton browser process behavior, which is necessary for the extension. ignoreDefaultArgs: ['--enable-automation'], args: mode === 'disable-extension' ? [] : [ - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}`, + `--disable-extensions-except=${extensions}`, + `--load-extension=${extensions}`, ], }); diff --git a/tests/extension/scheme-detach.spec.ts b/tests/extension/scheme-detach.spec.ts index 5099a5d4587d4..8efe0e4a3f126 100644 --- a/tests/extension/scheme-detach.spec.ts +++ b/tests/extension/scheme-detach.spec.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import fs from 'fs/promises'; +import path from 'path'; import { test, expect, extensionId, connectWithToken } from './extension-fixtures'; import type { BrowserContext } from 'playwright'; @@ -98,3 +100,44 @@ test('recovers and can screenshot after the debugger is detached', { code: expect.stringContaining('page.screenshot'), }); }); + +test('reports a foreign extension frame and recovers after the tab is lost', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/43230' }, +}, async ({ browserWithExtension, startClient, server }, testInfo) => { + const injector = testInfo.outputPath('frame-injector'); + await fs.mkdir(injector); + await fs.writeFile(path.join(injector, 'manifest.json'), JSON.stringify({ + manifest_version: 3, + name: 'Frame injector', + version: '1.0', + content_scripts: [{ matches: ['http://*/*'], js: ['content.js'], run_at: 'document_idle' }], + web_accessible_resources: [{ resources: ['frame.html'], matches: [''] }], + })); + await fs.writeFile(path.join(injector, 'content.js'), ` + if (location.pathname === '/hello-world') { + const frame = document.createElement('iframe'); + frame.src = chrome.runtime.getURL('frame.html'); + document.documentElement.appendChild(frame); + } + `); + await fs.writeFile(path.join(injector, 'frame.html'), 'Foreign frame'); + + const browserContext = await browserWithExtension.launch(undefined, [injector]); + const { client } = await connectWithToken(browserContext, startClient, browserWithExtension.userDataDir); + const getDetach = await watchDetach(browserContext); + + const response = await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }); + expect(response).toHaveResponse({ + isError: true, + error: expect.stringMatching(/Chrome detached the debugger.*chrome-extension:\/\/.*\/frame\.html/), + }); + expect(JSON.stringify(response)).not.toContain('TypeError'); + await expect.poll(getDetach).toEqual(expect.arrayContaining([ + expect.objectContaining({ reason: 'target_closed' }), + ])); + + server.setContent('/recovered', 'Recovered', 'text/html'); + expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX + '/recovered' } })).toHaveResponse({ + snapshot: expect.stringContaining('Recovered'), + }); +});