Skip to content
Draft
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
3 changes: 3 additions & 0 deletions packages/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

4 changes: 3 additions & 1 deletion packages/playwright-core/src/client/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/playwright-core/src/client/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,10 @@ export class Page extends ChannelOwner<channels.PageChannel> 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);
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/server/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/server/chromium/crPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
};

this.addObjectListener(Page.Events.Close, () => {
this._dispatchEvent('close');
this._dispatchEvent('close', { reason: page.closeReason() });
this._dispose();
});
this.addObjectListener(Page.Events.Crash, () => this._dispatchEvent('crash'));
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright-core/src/tools/backend/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,11 @@ export class Tab extends EventEmitter<TabEventsInterface> {

async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
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<string> {
Expand Down
33 changes: 29 additions & 4 deletions packages/playwright-core/src/tools/mcp/browserModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
*/

import { logUnhandledError } from './log';
import { playwrightExtensionId } from '../utils/extension';

import type { DebuggerSession, Debuggee, Tab } from './protocol';

Expand All @@ -56,6 +57,7 @@ type TabSession = {
// Child CDP sessionIds (workers, oopifs, ...) belonging to this tab,
// tracked via Target.attachedToTarget / Target.detachedFromTarget events.
childSessions: Set<string>;
pendingExtensionFrames: Map<string, string>;
};

export class BrowserModel {
Expand Down Expand Up @@ -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
Expand All @@ -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 ──────────────────────────────────────
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/tools/mcp/cdpRelayV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
3 changes: 2 additions & 1 deletion packages/protocol/spec/page.yml
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,8 @@ Page:
binding: BindingCall

close:
parameters:
reason: string?

crash:

Expand Down Expand Up @@ -785,4 +787,3 @@ Page:
worker:
parameters:
worker: Worker

4 changes: 3 additions & 1 deletion packages/protocol/src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions tests/extension/extension-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type { StartClient } from '../mcp/fixtures';

export type BrowserWithExtension = {
userDataDir: string;
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
launch: (mode?: 'disable-extension', additionalExtensions?: string[]) => Promise<BrowserContext>;
};

export type CliResult = {
Expand Down Expand Up @@ -65,16 +65,17 @@ export const test = base.extend<TestFixtures>({
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.
headless: false,
// 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}`,
],
});

Expand Down
43 changes: 43 additions & 0 deletions tests/extension/scheme-detach.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: ['<all_urls>'] }],
}));
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'), '<!DOCTYPE html><title>Foreign frame</title>');

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', '<body>Recovered</body>', 'text/html');
expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX + '/recovered' } })).toHaveResponse({
snapshot: expect.stringContaining('Recovered'),
});
});
Loading