diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 12d91c0fc1bd4..52aab753da552 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -49,6 +49,22 @@ import type * as channels from '../channels'; export type WindowBounds = { top?: number, left?: number, width?: number, height?: number }; +// Browsers disallow these WebUI hosts in off-the-record profiles and redirect them to the original +// profile, which crashes when the profile was created over CDP. Edge allows most of them in InPrivate. +// See https://github.com/microsoft/playwright/issues/41935. +const kCrashingWebUIHosts = { + chromium: new Set(['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']), + edge: new Set(['history']), +}; + +// Chromium canonicalizes WebUI urls as standard ones, so "VIEW-SOURCE:Chrome:Settings" ends up +// being "chrome://settings/". +function webUIHost(url: string): string { + const match = /^(?:view-source:)?(?:chrome|edge):\/*([^/?#]+)/i.exec(url); + const authority = match ? `http://${match[1]}` : ''; + return URL.canParse(authority) ? new URL(authority).hostname : ''; +} + export class CRPage implements PageDelegate { readonly utilityWorldName: string; readonly _mainFrameSession: FrameSession; @@ -152,9 +168,18 @@ export class CRPage implements PageDelegate { } async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise { + this._assertNavigationDoesNotCrashBrowser(url); return this._sessionForFrame(frame)._navigate(frame, url, referrer); } + private _assertNavigationDoesNotCrashBrowser(url: string) { + if (this._browserContext.isPersistentContext()) + return; + const isEdge = this._browserContext._browser.userAgent().includes('Edg/'); + if ((isEdge ? kCrashingWebUIHosts.edge : kCrashingWebUIHosts.chromium).has(webUIHost(url))) + throw new Error(`Cannot navigate to "${url}": this page is not available in an isolated browser context, and opening it crashes the browser. Use browserType.launchPersistentContext() instead.`); + } + async updateExtraHTTPHeaders(): Promise { const headers = network.mergeHeaders([ this._browserContext._options.extraHTTPHeaders, diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 16732fc5fd531..3eccc7fc2991b 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -18,6 +18,8 @@ import { contextTest as test, expect } from '../../config/browserTest'; import { playwrightTest } from '../../config/browserTest'; +import type { Page } from 'playwright-core'; + test('should create a worker from a service worker', async ({ page, server }) => { const [worker] = await Promise.all([ page.context().waitForEvent('serviceworker'), @@ -747,6 +749,55 @@ test('should capture console.log from ServiceWorker start', async ({ context, pa expect(consoleMessage.type()).toBe('log'); }); +test.describe('WebUI navigation', () => { + const isEdge = (channel: string | undefined) => !!channel?.startsWith('msedge'); + const gotoError = (page: Page, url: string) => page.goto(url).then(() => '', e => e.message); + + test('should refuse WebUI pages that crash the browser in an isolated context', async ({ browser, page, channel }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41935' }); + test.skip(isEdge(channel), 'Edge only disallows chrome://history'); + + const hosts = ['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']; + for (const url of [...hosts.map(host => `chrome://${host}`), 'chrome://extensions/', 'chrome://settings/help', 'chrome://SETTINGS', 'chrome:settings', 'chrome:///settings', 'view-source:chrome://settings']) + expect(await gotoError(page, url)).toContain(`Cannot navigate to "${url}"`); + // The refusal only matters because it keeps the browser process alive. + expect(browser.isConnected()).toBe(true); + expect(await page.evaluate(() => 1 + 1)).toBe(2); + }); + + test('should refuse WebUI pages that crash Edge in an isolated context', async ({ browser, page, channel }) => { + test.skip(!isEdge(channel), 'Edge has its own list of pages disallowed in InPrivate'); + + for (const url of ['edge://history', 'chrome://history', 'view-source:edge://history']) + expect(await gotoError(page, url)).toContain(`Cannot navigate to "${url}"`); + // Edge does allow these in InPrivate, so they must not be refused. + for (const url of ['edge://settings', 'edge://extensions']) + expect(await gotoError(page, url)).not.toContain('Cannot navigate to'); + expect(browser.isConnected()).toBe(true); + }); + + test('should navigate to WebUI pages that work in an isolated context', async ({ page, headless }) => { + test.skip(headless, 'WebUI pages are not available in headless'); + + const response = await page.goto('chrome://version'); + expect(response.status()).toBe(200); + }); + + test('should navigate to any WebUI page in a persistent context', async ({ browserType, createUserDataDir, headless }) => { + test.skip(headless, 'WebUI pages are not available in headless'); + + const context = await browserType.launchPersistentContext(await createUserDataDir()); + try { + const page = await context.newPage(); + // Committing the navigation is all this asserts - waiting for the WebUI to load is slow and beside the point. + const response = await page.goto('chrome://extensions', { waitUntil: 'commit' }); + expect(response.status()).toBe(200); + } finally { + await context.close(); + } + }); +}); + test('should fire dialogclosed event when dialog is closed out of band', async ({ page }) => { // Establish the CDP session up front: creating one while a dialog is blocking the page hangs. const client = await page.context().newCDPSession(page);