From 752b171a13cd0d368561668890c377fc2e6b9f49 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 5 Sep 2024 14:56:07 -0700 Subject: [PATCH] chore: support bidi connection to chromium (#32474) --- package-lock.json | 43 ++++ package.json | 1 + .../playwright-core/src/server/bidi/DEPS.list | 6 + .../src/server/bidi/bidiBrowser.ts | 3 - .../src/server/bidi/bidiBrowserType.ts | 206 ++++++++++++++++++ .../src/server/bidi/bidiFirefox.ts | 112 ---------- .../src/server/bidi/bidiOverCdp.ts | 100 +++++++++ .../src/server/bidi/bidiPage.ts | 13 +- .../playwright-core/src/server/browserType.ts | 19 +- .../src/server/chromium/chromium.ts | 15 +- .../src/server/firefox/firefox.ts | 20 +- .../playwright-core/src/server/playwright.ts | 4 +- .../src/server/registry/index.ts | 7 +- tests/bidi/playwright.config.ts | 53 ++--- 14 files changed, 415 insertions(+), 187 deletions(-) create mode 100644 packages/playwright-core/src/server/bidi/bidiBrowserType.ts delete mode 100644 packages/playwright-core/src/server/bidi/bidiFirefox.ts create mode 100644 packages/playwright-core/src/server/bidi/bidiOverCdp.ts diff --git a/package-lock.json b/package-lock.json index 6c65c8e247..4e24bcfa49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@vitejs/plugin-react": "^4.2.1", "@zip.js/zip.js": "^2.7.29", "chokidar": "^3.5.3", + "chromium-bidi": "^0.6.4", "colors": "^1.4.0", "concurrently": "^6.2.1", "cross-env": "^7.0.3", @@ -2828,6 +2829,20 @@ "fsevents": "~2.3.2" } }, + "node_modules/chromium-bidi": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz", + "integrity": "sha512-RuLrmzYrxSb0s9SgpB+QN5jJucPduZQ/9SIe76MDxYJuecPW5mxMdacJ1f4EtgiV+R0p3sCkznTMvH0MPGFqjA==", + "dev": true, + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "10.0.0", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -3258,6 +3273,13 @@ "dev": true, "optional": true }, + "node_modules/devtools-protocol": { + "version": "0.0.1349977", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1349977.tgz", + "integrity": "sha512-5JcwlDKinshGSm+4AVLFCkokJUAKTgjmiorNmrGgYYKix1h8Ts9/fplQeK1xg/rACYw1JlEM2PwIEvny5QswKQ==", + "dev": true, + "peer": true + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -5454,6 +5476,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -7120,6 +7148,12 @@ "punycode": "^2.1.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", + "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", + "dev": true + }, "node_modules/util-extend": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/util-extend/-/util-extend-1.0.3.tgz", @@ -7905,6 +7939,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/html-reporter": { "version": "0.0.0", "dependencies": { diff --git a/package.json b/package.json index 84634dcb64..44d259d7a4 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@vitejs/plugin-react": "^4.2.1", "@zip.js/zip.js": "^2.7.29", "chokidar": "^3.5.3", + "chromium-bidi": "^0.6.4", "colors": "^1.4.0", "concurrently": "^6.2.1", "cross-env": "^7.0.3", diff --git a/packages/playwright-core/src/server/bidi/DEPS.list b/packages/playwright-core/src/server/bidi/DEPS.list index 5f9ffe919d..a97787788d 100644 --- a/packages/playwright-core/src/server/bidi/DEPS.list +++ b/packages/playwright-core/src/server/bidi/DEPS.list @@ -3,3 +3,9 @@ ../ ../isomorphic/ ./third_party/ + +[bidiOverCdp.ts] +*** + +[bidiBrowserType.ts] +../chromium/chromiumSwitches.ts diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index 878d01b0d7..cc3fbc0562 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -43,9 +43,6 @@ export class BidiBrowser extends Browser { const browser = new BidiBrowser(parent, transport, options); if ((options as any).__testHookOnConnectToBrowser) await (options as any).__testHookOnConnectToBrowser(); - const sessionStatus = await browser._browserSession.send('session.status', {}); - if (!sessionStatus.ready) - throw new Error('Bidi session is not ready. ' + sessionStatus.message); let proxy: bidi.Session.ManualProxyConfiguration | undefined; if (options.proxy) { diff --git a/packages/playwright-core/src/server/bidi/bidiBrowserType.ts b/packages/playwright-core/src/server/bidi/bidiBrowserType.ts new file mode 100644 index 0000000000..841beb71ad --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiBrowserType.ts @@ -0,0 +1,206 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import os from 'os'; +import path from 'path'; +import { assert, wrapInASCIIBox } from '../../utils'; +import type { Env } from '../../utils/processLauncher'; +import type { BrowserOptions } from '../browser'; +import { BrowserReadyState } from '../browserType'; +import { BrowserType, kNoXServerRunningError } from '../browserType'; +import type { SdkObject } from '../instrumentation'; +import type { ProtocolError } from '../protocolError'; +import type { ConnectionTransport } from '../transport'; +import type * as types from '../types'; +import { BidiBrowser } from './bidiBrowser'; +import { kBrowserCloseMessageId } from './bidiConnection'; +import { chromiumSwitches } from '../chromium/chromiumSwitches'; + +export class BidiBrowserType extends BrowserType { + constructor(parent: SdkObject) { + super(parent, 'bidi'); + this._useBidi = true; + } + + override async connectToTransport(transport: ConnectionTransport, options: BrowserOptions): Promise { + if (options.channel?.includes('chrome')) { + // Chrome doesn't support Bidi, we create Bidi over CDP which is used by Chrome driver. + // bidiOverCdp depends on chromium-bidi which we only have in devDependencies, so + // we load bidiOverCdp dynamically. + const bidiTransport = await require('./bidiOverCdp').connectBidiOverCdp(transport); + (transport as any)[kBidiOverCdpWrapper] = bidiTransport; + transport = bidiTransport; + } + return BidiBrowser.connect(this.attribution.playwright, transport, options); + } + + override doRewriteStartupLog(error: ProtocolError): ProtocolError { + if (!error.logs) + return error; + // https://github.com/microsoft/playwright/issues/6500 + if (error.logs.includes(`as root in a regular user's session is not supported.`)) + error.logs = '\n' + wrapInASCIIBox(`Firefox is unable to launch if the $HOME folder isn't owned by the current user.\nWorkaround: Set the HOME=/root environment variable${process.env.GITHUB_ACTION ? ' in your GitHub Actions workflow file' : ''} when running Playwright.`, 1); + if (error.logs.includes('no DISPLAY environment variable specified')) + error.logs = '\n' + wrapInASCIIBox(kNoXServerRunningError, 1); + return error; + } + + override amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env { + if (!path.isAbsolute(os.homedir())) + throw new Error(`Cannot launch Firefox with relative home directory. Did you set ${os.platform() === 'win32' ? 'USERPROFILE' : 'HOME'} to a relative path?`); + if (os.platform() === 'linux') { + // Always remove SNAP_NAME and SNAP_INSTANCE_NAME env variables since they + // confuse Firefox: in our case, builds never come from SNAP. + // See https://github.com/microsoft/playwright/issues/20555 + return { ...env, SNAP_NAME: undefined, SNAP_INSTANCE_NAME: undefined }; + } + return env; + } + + override attemptToGracefullyCloseBrowser(transport: ConnectionTransport): void { + const bidiTransport = (transport as any)[kBidiOverCdpWrapper]; + if (bidiTransport) + transport = bidiTransport; + transport.send({ method: 'browser.close', params: {}, id: kBrowserCloseMessageId }); + } + + override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { + if (options.channel === 'bidi-firefox-stable') + return this._defaultFirefoxArgs(options, isPersistent, userDataDir); + else if (options.channel === 'bidi-chrome-canary') + return this._defaultChromiumArgs(options, isPersistent, userDataDir); + throw new Error(`Unknown Bidi channel "${options.channel}"`); + } + + override readyState(options: types.LaunchOptions): BrowserReadyState | undefined { + assert(options.useWebSocket); + if (options.channel?.includes('firefox')) + return new FirefoxReadyState(); + if (options.channel?.includes('chrome')) + return new ChromiumReadyState(); + return undefined; + } + + private _defaultFirefoxArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { + const { args = [], headless } = options; + const userDataDirArg = args.find(arg => arg.startsWith('-profile') || arg.startsWith('--profile')); + if (userDataDirArg) + throw this._createUserDataDirArgMisuseError('--profile'); + const firefoxArguments = ['--remote-debugging-port=0']; + if (headless) + firefoxArguments.push('--headless'); + else + firefoxArguments.push('--foreground'); + firefoxArguments.push(`--profile`, userDataDir); + firefoxArguments.push(...args); + // TODO: make ephemeral context work without this argument. + firefoxArguments.push('about:blank'); + // if (isPersistent) + // firefoxArguments.push('about:blank'); + // else + // firefoxArguments.push('-silent'); + return firefoxArguments; + } + + private _defaultChromiumArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { + const chromeArguments = this._innerDefaultArgs(options); + chromeArguments.push(`--user-data-dir=${userDataDir}`); + chromeArguments.push('--remote-debugging-port=0'); + if (isPersistent) + chromeArguments.push('about:blank'); + else + chromeArguments.push('--no-startup-window'); + return chromeArguments; + } + + private _innerDefaultArgs(options: types.LaunchOptions): string[] { + const { args = [], proxy } = options; + const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); + if (userDataDirArg) + throw this._createUserDataDirArgMisuseError('--user-data-dir'); + if (args.find(arg => arg.startsWith('--remote-debugging-pipe'))) + throw new Error('Playwright manages remote debugging connection itself.'); + if (args.find(arg => !arg.startsWith('-'))) + throw new Error('Arguments can not specify page to be opened'); + const chromeArguments = [...chromiumSwitches]; + + if (os.platform() === 'darwin') { + // See https://github.com/microsoft/playwright/issues/7362 + chromeArguments.push('--enable-use-zoom-for-dsf=false'); + // See https://bugs.chromium.org/p/chromium/issues/detail?id=1407025. + if (options.headless) + chromeArguments.push('--use-angle'); + } + + if (options.devtools) + chromeArguments.push('--auto-open-devtools-for-tabs'); + if (options.headless) { + if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW) + chromeArguments.push('--headless=new'); + else + chromeArguments.push('--headless=old'); + + chromeArguments.push( + '--hide-scrollbars', + '--mute-audio', + '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4', + ); + } + if (options.chromiumSandbox !== true) + chromeArguments.push('--no-sandbox'); + if (proxy) { + const proxyURL = new URL(proxy.server); + const isSocks = proxyURL.protocol === 'socks5:'; + // https://www.chromium.org/developers/design-documents/network-settings + if (isSocks && !this.attribution.playwright.options.socksProxyPort) { + // https://www.chromium.org/developers/design-documents/network-stack/socks-proxy + chromeArguments.push(`--host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE ${proxyURL.hostname}"`); + } + chromeArguments.push(`--proxy-server=${proxy.server}`); + const proxyBypassRules = []; + // https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578 + if (this.attribution.playwright.options.socksProxyPort) + proxyBypassRules.push('<-loopback>'); + if (proxy.bypass) + proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); + if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) + proxyBypassRules.push('<-loopback>'); + if (proxyBypassRules.length > 0) + chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`); + } + chromeArguments.push(...args); + return chromeArguments; + } +} + +class FirefoxReadyState extends BrowserReadyState { + override onBrowserOutput(message: string): void { + // Bidi WebSocket in Firefox. + const match = message.match(/WebDriver BiDi listening on (ws:\/\/.*)$/); + if (match) + this._wsEndpoint.resolve(match[1] + '/session'); + } +} + +class ChromiumReadyState extends BrowserReadyState { + override onBrowserOutput(message: string): void { + const match = message.match(/DevTools listening on (.*)/); + if (match) + this._wsEndpoint.resolve(match[1]); + } +} + +const kBidiOverCdpWrapper = Symbol('kBidiConnectionWrapper'); diff --git a/packages/playwright-core/src/server/bidi/bidiFirefox.ts b/packages/playwright-core/src/server/bidi/bidiFirefox.ts deleted file mode 100644 index 499ab7f4c7..0000000000 --- a/packages/playwright-core/src/server/bidi/bidiFirefox.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the 'License'); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an 'AS IS' BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import os from 'os'; -import path from 'path'; -import { assert, ManualPromise, wrapInASCIIBox } from '../../utils'; -import type { Env } from '../../utils/processLauncher'; -import type { BrowserOptions } from '../browser'; -import type { BrowserReadyState } from '../browserType'; -import { BrowserType, kNoXServerRunningError } from '../browserType'; -import type { SdkObject } from '../instrumentation'; -import type { ProtocolError } from '../protocolError'; -import type { ConnectionTransport } from '../transport'; -import type * as types from '../types'; -import { BidiBrowser } from './bidiBrowser'; -import { kBrowserCloseMessageId } from './bidiConnection'; - -export class BidiFirefox extends BrowserType { - constructor(parent: SdkObject) { - super(parent, 'bidi'); - this._useBidi = true; - } - - override async connectToTransport(transport: ConnectionTransport, options: BrowserOptions): Promise { - return BidiBrowser.connect(this.attribution.playwright, transport, options); - } - - override doRewriteStartupLog(error: ProtocolError): ProtocolError { - if (!error.logs) - return error; - // https://github.com/microsoft/playwright/issues/6500 - if (error.logs.includes(`as root in a regular user's session is not supported.`)) - error.logs = '\n' + wrapInASCIIBox(`Firefox is unable to launch if the $HOME folder isn't owned by the current user.\nWorkaround: Set the HOME=/root environment variable${process.env.GITHUB_ACTION ? ' in your GitHub Actions workflow file' : ''} when running Playwright.`, 1); - if (error.logs.includes('no DISPLAY environment variable specified')) - error.logs = '\n' + wrapInASCIIBox(kNoXServerRunningError, 1); - return error; - } - - override amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env { - if (!path.isAbsolute(os.homedir())) - throw new Error(`Cannot launch Firefox with relative home directory. Did you set ${os.platform() === 'win32' ? 'USERPROFILE' : 'HOME'} to a relative path?`); - if (os.platform() === 'linux') { - // Always remove SNAP_NAME and SNAP_INSTANCE_NAME env variables since they - // confuse Firefox: in our case, builds never come from SNAP. - // See https://github.com/microsoft/playwright/issues/20555 - return { ...env, SNAP_NAME: undefined, SNAP_INSTANCE_NAME: undefined }; - } - return env; - } - - override attemptToGracefullyCloseBrowser(transport: ConnectionTransport): void { - transport.send({ method: 'browser.close', params: {}, id: kBrowserCloseMessageId }); - } - - override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { - const { args = [], headless } = options; - const userDataDirArg = args.find(arg => arg.startsWith('-profile') || arg.startsWith('--profile')); - if (userDataDirArg) - throw this._createUserDataDirArgMisuseError('--profile'); - const firefoxArguments = ['--remote-debugging-port=0']; - if (headless) - firefoxArguments.push('--headless'); - else - firefoxArguments.push('--foreground'); - firefoxArguments.push(`--profile`, userDataDir); - firefoxArguments.push(...args); - // TODO: make ephemeral context work without this argument. - firefoxArguments.push('about:blank'); - // if (isPersistent) - // firefoxArguments.push('about:blank'); - // else - // firefoxArguments.push('-silent'); - return firefoxArguments; - } - - override readyState(options: types.LaunchOptions): BrowserReadyState | undefined { - assert(options.useWebSocket); - return new BidiReadyState(); - } -} - -class BidiReadyState implements BrowserReadyState { - private readonly _wsEndpoint = new ManualPromise(); - - onBrowserOutput(message: string): void { - // Bidi WebSocket in Firefox. - const match = message.match(/WebDriver BiDi listening on (ws:\/\/.*)$/); - if (match) - this._wsEndpoint.resolve(match[1] + '/session'); - } - onBrowserExit(): void { - // Unblock launch when browser prematurely exits. - this._wsEndpoint.resolve(undefined); - } - async waitUntilReady(): Promise<{ wsEndpoint?: string }> { - const wsEndpoint = await this._wsEndpoint; - return { wsEndpoint }; - } -} diff --git a/packages/playwright-core/src/server/bidi/bidiOverCdp.ts b/packages/playwright-core/src/server/bidi/bidiOverCdp.ts new file mode 100644 index 0000000000..1d01317c1e --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiOverCdp.ts @@ -0,0 +1,100 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as bidiMapper from 'chromium-bidi/lib/cjs/bidiMapper/BidiMapper'; +import * as bidiCdpConnection from 'chromium-bidi/lib/cjs/cdp/CdpConnection'; +import type * as bidiTransport from 'chromium-bidi/lib/cjs/utils/transport'; +import type { ChromiumBidi } from 'chromium-bidi/lib/cjs/protocol/protocol'; +import type { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport'; +import { debugLogger } from '../../utils/debugLogger'; + +const bidiServerLogger = (prefix: string, ...args: unknown[]): void => { + debugLogger.log(prefix as any, args); +}; + +export async function connectBidiOverCdp(cdp: ConnectionTransport): Promise { + let server: bidiMapper.BidiServer | undefined = undefined; + const bidiTransport = new BidiTransportImpl(); + const bidiConnection = new BidiConnection(bidiTransport, () => server?.close()); + const cdpTransportImpl = new CdpTransportImpl(cdp); + const cdpConnection = new bidiCdpConnection.MapperCdpConnection(cdpTransportImpl, bidiServerLogger); + // Make sure onclose event is propagated. + cdp.onclose = () => bidiConnection.onclose?.(); + server = await bidiMapper.BidiServer.createAndStart( + bidiTransport, + cdpConnection, + await cdpConnection.createBrowserSession(), + /* selfTargetId= */ '', + undefined, + bidiServerLogger); + return bidiConnection; +} + +class BidiTransportImpl implements bidiMapper.BidiTransport { + _handler?: (message: ChromiumBidi.Command) => Promise | void; + _bidiConnection!: BidiConnection; + + setOnMessage(handler: (message: ChromiumBidi.Command) => Promise | void) { + this._handler = handler; + } + sendMessage(message: ChromiumBidi.Message): Promise | void { + return this._bidiConnection.onmessage?.(message as any); + } + close() { + this._bidiConnection.onclose?.(); + } +} + +class BidiConnection implements ConnectionTransport { + private _bidiTransport: BidiTransportImpl; + private _closeCallback: () => void; + + constructor(bidiTransport: BidiTransportImpl, closeCallback: () => void) { + this._bidiTransport = bidiTransport; + this._bidiTransport._bidiConnection = this; + this._closeCallback = closeCallback; + } + send(s: ProtocolRequest): void { + this._bidiTransport._handler?.(s as any); + } + close(): void { + this._closeCallback(); + } + onmessage?: ((message: ProtocolResponse) => void) | undefined; + onclose?: ((reason?: string) => void) | undefined; +} + +class CdpTransportImpl implements bidiTransport.Transport { + private _connection: ConnectionTransport; + private _handler?: (message: string) => Promise | void; + _bidiConnection!: BidiConnection; + + constructor(connection: ConnectionTransport) { + this._connection = connection; + this._connection.onmessage = message => { + this._handler?.(JSON.stringify(message)); + }; + } + setOnMessage(handler: (message: string) => Promise | void) { + this._handler = handler; + } + sendMessage(message: string): Promise | void { + return this._connection.send(JSON.parse(message)); + } + close(): void { + this._connection.close(); + } +} diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 0028b9fc95..2802aa1452 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -86,8 +86,8 @@ export class BidiPage implements PageDelegate { } private async _initialize() { - const { contexts } = await this._session.send('browsingContext.getTree', { root: this._session.sessionId }); - this._handleFrameTree(contexts[0]); + // Initialize main frame. + this._onFrameAttached(this._session.sessionId, null); await Promise.all([ this.updateHttpCredentials(), this.updateRequestInterception(), @@ -95,15 +95,6 @@ export class BidiPage implements PageDelegate { ]); } - private _handleFrameTree(frameTree: bidi.BrowsingContext.Info) { - this._onFrameAttached(frameTree.context, frameTree.parent || null); - if (!frameTree.children) - return; - - for (const child of frameTree.children) - this._handleFrameTree(child); - } - potentiallyUninitializedPage(): Page { return this._page; } diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 19cee87cb3..d0c3174a59 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -32,7 +32,7 @@ import { ProgressController } from './progress'; import type * as types from './types'; import type * as channels from '@protocol/channels'; import { DEFAULT_TIMEOUT, TimeoutSettings } from '../common/timeoutSettings'; -import { debugMode } from '../utils'; +import { debugMode, ManualPromise } from '../utils'; import { existsAsync } from '../utils/fileUtils'; import { helper } from './helper'; import { RecentLogsCollector } from '../utils/debugLogger'; @@ -44,10 +44,19 @@ export const kNoXServerRunningError = 'Looks like you launched a headed browser 'Set either \'headless: true\' or use \'xvfb-run \' before running Playwright.\n\n<3 Playwright Team'; -export interface BrowserReadyState { - onBrowserOutput(message: string): void; - onBrowserExit(): void; - waitUntilReady(): Promise<{ wsEndpoint?: string }>; +export abstract class BrowserReadyState { + protected readonly _wsEndpoint = new ManualPromise(); + + onBrowserExit(): void { + // Unblock launch when browser prematurely exits. + this._wsEndpoint.resolve(undefined); + } + async waitUntilReady(): Promise<{ wsEndpoint?: string }> { + const wsEndpoint = await this._wsEndpoint; + return { wsEndpoint }; + } + + abstract onBrowserOutput(message: string): void; } export abstract class BrowserType extends SdkObject { diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index 04177b4690..d84ae3952e 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -24,7 +24,7 @@ import type { Env } from '../../utils/processLauncher'; import { gracefullyCloseSet } from '../../utils/processLauncher'; import { kBrowserCloseMessageId } from './crConnection'; import { BrowserType, kNoXServerRunningError } from '../browserType'; -import type { BrowserReadyState } from '../browserType'; +import { BrowserReadyState } from '../browserType'; import type { ConnectionTransport, ProtocolRequest } from '../transport'; import { WebSocketTransport } from '../transport'; import { CRDevTools } from './crDevTools'; @@ -352,21 +352,12 @@ export class Chromium extends BrowserType { } } -class ChromiumReadyState implements BrowserReadyState { - private readonly _wsEndpoint = new ManualPromise(); - - onBrowserOutput(message: string): void { +class ChromiumReadyState extends BrowserReadyState { + override onBrowserOutput(message: string): void { const match = message.match(/DevTools listening on (.*)/); if (match) this._wsEndpoint.resolve(match[1]); } - onBrowserExit(): void { - this._wsEndpoint.resolve(undefined); - } - async waitUntilReady(): Promise<{ wsEndpoint?: string }> { - const wsEndpoint = await this._wsEndpoint; - return { wsEndpoint }; - } } async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: { [key: string]: string; }) { diff --git a/packages/playwright-core/src/server/firefox/firefox.ts b/packages/playwright-core/src/server/firefox/firefox.ts index ed2709b685..9fbc409a56 100644 --- a/packages/playwright-core/src/server/firefox/firefox.ts +++ b/packages/playwright-core/src/server/firefox/firefox.ts @@ -20,12 +20,12 @@ import path from 'path'; import { FFBrowser } from './ffBrowser'; import { kBrowserCloseMessageId } from './ffConnection'; import { BrowserType, kNoXServerRunningError } from '../browserType'; -import type { BrowserReadyState } from '../browserType'; +import { BrowserReadyState } from '../browserType'; import type { Env } from '../../utils/processLauncher'; import type { ConnectionTransport } from '../transport'; import type { BrowserOptions } from '../browser'; import type * as types from '../types'; -import { ManualPromise, wrapInASCIIBox } from '../../utils'; +import { wrapInASCIIBox } from '../../utils'; import type { SdkObject } from '../instrumentation'; import type { ProtocolError } from '../protocolError'; @@ -95,20 +95,10 @@ export class Firefox extends BrowserType { } } -class JugglerReadyState implements BrowserReadyState { - private readonly _jugglerPromise = new ManualPromise(); - - onBrowserOutput(message: string): void { +class JugglerReadyState extends BrowserReadyState { + override onBrowserOutput(message: string): void { if (message.includes('Juggler listening to the pipe')) - this._jugglerPromise.resolve(); - } - onBrowserExit(): void { - // Unblock launch when browser prematurely exits. - this._jugglerPromise.resolve(); - } - async waitUntilReady(): Promise<{ wsEndpoint?: string }> { - await this._jugglerPromise; - return { }; + this._wsEndpoint.resolve(undefined); } } diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index b4ebbed098..10d1942e34 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -28,7 +28,7 @@ import { debugLogger, type Language } from '../utils'; import type { Page } from './page'; import { DebugController } from './debugController'; import type { BrowserType } from './browserType'; -import { BidiFirefox } from './bidi/bidiFirefox'; +import { BidiBrowserType } from './bidi/bidiBrowserType'; type PlaywrightOptions = { socksProxyPort?: number; @@ -64,7 +64,7 @@ export class Playwright extends SdkObject { } }, null); this.chromium = new Chromium(this); - this.bidi = new BidiFirefox(this); + this.bidi = new BidiBrowserType(this); this.firefox = new Firefox(this); this.webkit = new WebKit(this); this.electron = new Electron(this); diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 6069b765f6..cafef726fb 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -354,7 +354,7 @@ function readDescriptors(browsersJSON: BrowsersJSON) { export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi'; type InternalTool = 'ffmpeg' | 'firefox-beta' | 'chromium-tip-of-tree' | 'android'; -type BidiChannel = 'bidi-firefox-stable'; +type BidiChannel = 'bidi-firefox-stable' | 'bidi-chrome-canary'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; const allDownloadable = ['chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree']; @@ -530,6 +530,11 @@ export class Registry { 'darwin': '/Applications/Firefox.app/Contents/MacOS/firefox', 'win32': '\\Mozilla Firefox\\firefox.exe', })); + this._executables.push(this._createBidiChannel('bidi-chrome-canary', { + 'linux': '', + 'darwin': '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + 'win32': `\\Google\\Chrome SxS\\Application\\chrome.exe`, + })); const firefox = descriptors.find(d => d.name === 'firefox')!; const firefoxExecutable = findExecutablePath(firefox.dir, 'firefox'); diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index a0c7becf19..3bde84756a 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -26,7 +26,6 @@ const getExecutablePath = () => { }; const headed = process.argv.includes('--headed'); -const channel = process.env.PWTEST_CHANNEL as any; const trace = !!process.env.PWTEST_TRACE; const outputDir = path.join(__dirname, '..', '..', 'test-results'); @@ -64,32 +63,34 @@ const executablePath = getExecutablePath(); if (executablePath && !process.env.TEST_WORKER_INDEX) console.error(`Using executable at ${executablePath}`); const testIgnore: RegExp[] = []; -for (const folder of ['library', 'page']) { - config.projects.push({ - name: `${browserName}-${folder}`, - testDir: path.join(testDir, folder), - testIgnore, - snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${browserName}{ext}`, - use: { - browserName, - headless: !headed, - channel, - video: 'off', - launchOptions: { - channel: 'bidi-firefox-stable', - executablePath, +for (const channel of ['bidi-chrome-canary', 'bidi-firefox-stable']) { + for (const folder of ['library', 'page']) { + config.projects.push({ + name: `${channel}-${folder}`, + testDir: path.join(testDir, folder), + testIgnore, + snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${channel}{ext}`, + use: { + browserName, + headless: !headed, + channel, + video: 'off', + launchOptions: { + channel: 'bidi-chrome-canary', + executablePath, + }, + trace: trace ? 'on' : undefined, }, - trace: trace ? 'on' : undefined, - }, - metadata: { - platform: process.platform, - docker: !!process.env.INSIDE_DOCKER, - headless: !headed, - browserName, - channel, - trace: !!trace, - }, - }); + metadata: { + platform: process.platform, + docker: !!process.env.INSIDE_DOCKER, + headless: !headed, + browserName, + channel, + trace: !!trace, + }, + }); + } } export default config;