From 9a2c60a77cdde80e4357246401af60db71fb3a4a Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 4 Sep 2024 11:36:52 -0700 Subject: [PATCH] chore: identify largest gaps in Bidi API (#32434) This pull request introduces initial support for the WebDriver BiDi protocol in Playwright. The primary goal of this PR is not to fully implement BiDi but to experiment with the current state of the specification and its implementation. We aim to identify the biggest gaps and challenges that need to be addressed before considering BiDi as the main protocol for Playwright. --- package.json | 1 + .../playwright-core/src/client/playwright.ts | 3 + .../playwright-core/src/protocol/validator.ts | 1 + packages/playwright-core/src/server/DEPS.list | 1 + .../playwright-core/src/server/bidi/DEPS.list | 5 + .../src/server/bidi/bidiBrowser.ts | 332 +++ .../src/server/bidi/bidiConnection.ts | 232 ++ .../src/server/bidi/bidiExecutionContext.ts | 167 ++ .../src/server/bidi/bidiFirefox.ts | 112 + .../src/server/bidi/bidiInput.ts | 149 ++ .../src/server/bidi/bidiNetworkManager.ts | 316 +++ .../src/server/bidi/bidiPage.ts | 527 ++++ .../src/server/bidi/third_party/LICENSE | 202 ++ .../server/bidi/third_party/bidiCommands.d.ts | 176 ++ .../bidi/third_party/bidiDeserializer.ts | 91 + .../server/bidi/third_party/bidiKeyboard.ts | 231 ++ .../server/bidi/third_party/bidiProtocol.ts | 2204 +++++++++++++++++ .../server/bidi/third_party/bidiSerializer.ts | 148 ++ .../playwright-core/src/server/browserType.ts | 5 + .../dispatchers/playwrightDispatcher.ts | 1 + packages/playwright-core/src/server/dom.ts | 4 +- packages/playwright-core/src/server/frames.ts | 2 +- packages/playwright-core/src/server/input.ts | 3 + .../playwright-core/src/server/network.ts | 8 +- packages/playwright-core/src/server/page.ts | 4 +- .../playwright-core/src/server/playwright.ts | 3 + .../src/server/registry/index.ts | 67 +- packages/playwright-core/types/types.d.ts | 1 + packages/playwright/src/index.ts | 6 +- packages/protocol/src/channels.ts | 1 + packages/protocol/src/protocol.yml | 1 + tests/bidi/playwright.config.ts | 95 + tests/library/channels.spec.ts | 11 + utils/generate_types/overrides.d.ts | 1 + 34 files changed, 5102 insertions(+), 9 deletions(-) create mode 100644 packages/playwright-core/src/server/bidi/DEPS.list create mode 100644 packages/playwright-core/src/server/bidi/bidiBrowser.ts create mode 100644 packages/playwright-core/src/server/bidi/bidiConnection.ts create mode 100644 packages/playwright-core/src/server/bidi/bidiExecutionContext.ts create mode 100644 packages/playwright-core/src/server/bidi/bidiFirefox.ts create mode 100644 packages/playwright-core/src/server/bidi/bidiInput.ts create mode 100644 packages/playwright-core/src/server/bidi/bidiNetworkManager.ts create mode 100644 packages/playwright-core/src/server/bidi/bidiPage.ts create mode 100644 packages/playwright-core/src/server/bidi/third_party/LICENSE create mode 100644 packages/playwright-core/src/server/bidi/third_party/bidiCommands.d.ts create mode 100644 packages/playwright-core/src/server/bidi/third_party/bidiDeserializer.ts create mode 100644 packages/playwright-core/src/server/bidi/third_party/bidiKeyboard.ts create mode 100644 packages/playwright-core/src/server/bidi/third_party/bidiProtocol.ts create mode 100644 packages/playwright-core/src/server/bidi/third_party/bidiSerializer.ts create mode 100644 tests/bidi/playwright.config.ts diff --git a/package.json b/package.json index 6b2e043765..e01aae1896 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "webview2test": "playwright test --config=tests/webview2/playwright.config.ts", "itest": "playwright test --config=tests/installation/playwright.config.ts", "stest": "playwright test --config=tests/stress/playwright.config.ts", + "biditest": "playwright test --config=tests/bidi/playwright.config.ts", "test-html-reporter": "playwright test --config=packages/html-reporter", "test-web": "playwright test --config=packages/web", "ttest": "node ./tests/playwright-test/stable-test-runner/node_modules/@playwright/test/cli test --config=tests/playwright-test/playwright.config.ts", diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index 593e0bd49f..9415125be4 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -26,6 +26,7 @@ import { Selectors, SelectorsOwner } from './selectors'; export class Playwright extends ChannelOwner { readonly _android: Android; readonly _electron: Electron; + readonly _experimentalBidi: BrowserType; readonly chromium: BrowserType; readonly firefox: BrowserType; readonly webkit: BrowserType; @@ -45,6 +46,8 @@ export class Playwright extends ChannelOwner { this.webkit._playwright = this; this._android = Android.from(initializer.android); this._electron = Electron.from(initializer.electron); + this._experimentalBidi = BrowserType.from(initializer.bidi); + this._experimentalBidi._playwright = this; this.devices = this._connection.localUtils()?.devices ?? {}; this.selectors = new Selectors(); this.errors = { TimeoutError }; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 1768380d30..0ec06e23b3 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -321,6 +321,7 @@ scheme.RootInitializeResult = tObject({ }); scheme.PlaywrightInitializer = tObject({ chromium: tChannel(['BrowserType']), + bidi: tChannel(['BrowserType']), firefox: tChannel(['BrowserType']), webkit: tChannel(['BrowserType']), android: tChannel(['Android']), diff --git a/packages/playwright-core/src/server/DEPS.list b/packages/playwright-core/src/server/DEPS.list index bc32bb8486..4446d36a24 100644 --- a/packages/playwright-core/src/server/DEPS.list +++ b/packages/playwright-core/src/server/DEPS.list @@ -16,6 +16,7 @@ [playwright.ts] ./android/ +./bidi/ ./chromium/ ./electron/ ./firefox/ diff --git a/packages/playwright-core/src/server/bidi/DEPS.list b/packages/playwright-core/src/server/bidi/DEPS.list new file mode 100644 index 0000000000..5f9ffe919d --- /dev/null +++ b/packages/playwright-core/src/server/bidi/DEPS.list @@ -0,0 +1,5 @@ +[*] +../../utils/ +../ +../isomorphic/ +./third_party/ diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts new file mode 100644 index 0000000000..878d01b0d7 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -0,0 +1,332 @@ +/** + * 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 type * as channels from '@protocol/channels'; +import type { RegisteredListener } from '../../utils/eventsHelper'; +import { eventsHelper } from '../../utils/eventsHelper'; +import type { BrowserOptions } from '../browser'; +import { Browser } from '../browser'; +import { assertBrowserContextIsNotOwned, BrowserContext } from '../browserContext'; +import type { SdkObject } from '../instrumentation'; +import * as network from '../network'; +import type { InitScript, Page, PageDelegate } from '../page'; +import type { ConnectionTransport } from '../transport'; +import type * as types from '../types'; +import type { BidiSession } from './bidiConnection'; +import { BidiConnection } from './bidiConnection'; +import { bidiBytesValueToString } from './bidiNetworkManager'; +import { BidiPage } from './bidiPage'; +import * as bidi from './third_party/bidiProtocol'; + +export class BidiBrowser extends Browser { + private readonly _connection: BidiConnection; + readonly _browserSession: BidiSession; + private _bidiSessionInfo!: bidi.Session.NewResult; + readonly _contexts = new Map(); + readonly _bidiPages = new Map(); + private readonly _eventListeners: RegisteredListener[]; + + static async connect(parent: SdkObject, transport: ConnectionTransport, options: BrowserOptions): Promise { + 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) { + proxy = { + proxyType: 'manual', + }; + const url = new URL(options.proxy.server); // Validate proxy server. + switch (url.protocol) { + case 'http:': + proxy.httpProxy = url.host; + break; + case 'https:': + proxy.httpsProxy = url.host; + break; + case 'socks4:': + proxy.socksProxy = url.host; + proxy.socksVersion = 4; + break; + case 'socks5:': + proxy.socksProxy = url.host; + proxy.socksVersion = 5; + break; + default: + throw new Error('Invalid proxy server protocol: ' + options.proxy.server); + } + if (options.proxy.bypass) + proxy.noProxy = options.proxy.bypass.split(','); + // TODO: support authentication. + } + + browser._bidiSessionInfo = await browser._browserSession.send('session.new', { + capabilities: { + alwaysMatch: { + acceptInsecureCerts: false, + proxy, + unhandledPromptBehavior: { + default: bidi.Session.UserPromptHandlerType.Ignore, + }, + webSocketUrl: true + }, + } + }); + + await browser._browserSession.send('session.subscribe', { + events: [ + 'browsingContext', + 'network', + 'log', + 'script', + ], + }); + return browser; + } + + constructor(parent: SdkObject, transport: ConnectionTransport, options: BrowserOptions) { + super(parent, options); + this._connection = new BidiConnection(transport, this._onDisconnect.bind(this), options.protocolLogger, options.browserLogsCollector); + this._browserSession = this._connection.browserSession; + this._eventListeners = [ + eventsHelper.addEventListener(this._browserSession, 'browsingContext.contextCreated', this._onBrowsingContextCreated.bind(this)), + eventsHelper.addEventListener(this._browserSession, 'script.realmDestroyed', this._onScriptRealmDestroyed.bind(this)), + ]; + } + + _onDisconnect() { + this._didClose(); + } + + async doCreateNewContext(options: channels.BrowserNewContextParams): Promise { + const { userContext } = await this._browserSession.send('browser.createUserContext', {}); + const context = new BidiBrowserContext(this, userContext, options); + await context._initialize(); + this._contexts.set(userContext, context); + return context; + } + + contexts(): BrowserContext[] { + return Array.from(this._contexts.values()); + } + + version(): string { + return this._bidiSessionInfo.capabilities.browserVersion; + } + + userAgent(): string { + return this._bidiSessionInfo.capabilities.userAgent; + } + + isConnected(): boolean { + return !this._connection.isClosed(); + } + + private _onBrowsingContextCreated(event: bidi.BrowsingContext.Info) { + if (event.parent) { + const parentFrameId = event.parent; + for (const page of this._bidiPages.values()) { + const parentFrame = page._page._frameManager.frame(parentFrameId); + if (!parentFrame) + continue; + page._session.addFrameBrowsingContext(event.context); + page._page._frameManager.frameAttached(event.context, parentFrameId); + return; + } + return; + } + let context = this._contexts.get(event.userContext); + if (!context) + context = this._defaultContext as BidiBrowserContext; + if (!context) + return; + const session = this._connection.createMainFrameBrowsingContextSession(event.context); + const opener = event.originalOpener && this._bidiPages.get(event.originalOpener); + const page = new BidiPage(context, session, opener || null); + this._bidiPages.set(event.context, page); + } + + _onBrowsingContextDestroyed(event: bidi.BrowsingContext.Info) { + if (event.parent) { + this._browserSession.removeFrameBrowsingContext(event.context); + const parentFrameId = event.parent; + for (const page of this._bidiPages.values()) { + const parentFrame = page._page._frameManager.frame(parentFrameId); + if (!parentFrame) + continue; + page._page._frameManager.frameDetached(event.context); + return; + } + return; + } + const bidiPage = this._bidiPages.get(event.context); + if (!bidiPage) + return; + bidiPage.didClose(); + this._bidiPages.delete(event.context); + } + + private _onScriptRealmDestroyed(event: bidi.Script.RealmDestroyedParameters) { + for (const page of this._bidiPages.values()) { + if (page._onRealmDestroyed(event)) + return; + } + } +} + +export class BidiBrowserContext extends BrowserContext { + declare readonly _browser: BidiBrowser; + + constructor(browser: BidiBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { + super(browser, options, browserContextId); + this._authenticateProxyViaHeader(); + } + + pages(): Page[] { + return []; + } + + async newPageDelegate(): Promise { + assertBrowserContextIsNotOwned(this); + const { context } = await this._browser._browserSession.send('browsingContext.create', { + type: bidi.BrowsingContext.CreateType.Window, + userContext: this._browserContextId, + }); + return this._browser._bidiPages.get(context)!; + } + + async doGetCookies(urls: string[]): Promise { + const { cookies } = await this._browser._browserSession.send('storage.getCookies', + { partition: { type: 'storageKey', userContext: this._browserContextId } }); + return network.filterCookies(cookies.map((c: bidi.Network.Cookie) => { + const copy: channels.NetworkCookie = { + name: c.name, + value: bidiBytesValueToString(c.value), + domain: c.domain, + path: c.path, + httpOnly: c.httpOnly, + secure: c.secure, + expires: c.expiry ?? -1, + sameSite: c.sameSite ? fromBidiSameSite(c.sameSite) : 'None', + }; + return copy; + }), urls); + } + + async addCookies(cookies: channels.SetNetworkCookie[]) { + cookies = network.rewriteCookies(cookies); + const promises = cookies.map((c: channels.SetNetworkCookie) => { + const cookie: bidi.Storage.PartialCookie = { + name: c.name, + value: { type: 'string', value: c.value }, + domain: c.domain!, + path: c.path, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite && toBidiSameSite(c.sameSite), + expiry: (c.expires === -1 || c.expires === undefined) ? undefined : Math.round(c.expires), + }; + return this._browser._browserSession.send('storage.setCookie', + { cookie, partition: { type: 'storageKey', userContext: this._browserContextId } }); + }); + await Promise.all(promises); + } + + async doClearCookies() { + await this._browser._browserSession.send('storage.deleteCookies', + { partition: { type: 'storageKey', userContext: this._browserContextId } }); + } + + async doGrantPermissions(origin: string, permissions: string[]) { + } + + async doClearPermissions() { + } + + async setGeolocation(geolocation?: types.Geolocation): Promise { + } + + async setExtraHTTPHeaders(headers: types.HeadersArray): Promise { + } + + async setUserAgent(userAgent: string | undefined): Promise { + } + + async setOffline(offline: boolean): Promise { + } + + async doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise { + } + + async doAddInitScript(initScript: InitScript) { + // for (const page of this.pages()) + // await (page._delegate as WKPage)._updateBootstrapScript(); + } + + async doRemoveNonInternalInitScripts() { + } + + async doUpdateRequestInterception(): Promise { + } + + onClosePersistent() {} + + override async clearCache(): Promise { + } + + async doClose(reason: string | undefined) { + // TODO: implement for persistent context + if (!this._browserContextId) + return; + + await this._browser._browserSession.send('browser.removeUserContext', { + userContext: this._browserContextId + }); + this._browser._contexts.delete(this._browserContextId); + } + + async cancelDownload(uuid: string) { + } +} + +function fromBidiSameSite(sameSite: bidi.Network.SameSite): channels.NetworkCookie['sameSite'] { + switch (sameSite) { + case 'strict': return 'Strict'; + case 'lax': return 'Lax'; + case 'none': return 'None'; + } + return 'None'; +} + +function toBidiSameSite(sameSite: channels.SetNetworkCookie['sameSite']): bidi.Network.SameSite { + switch (sameSite) { + case 'Strict': return bidi.Network.SameSite.Strict; + case 'Lax': return bidi.Network.SameSite.Lax; + case 'None': return bidi.Network.SameSite.None; + } + return bidi.Network.SameSite.None; +} + +export namespace Network { + export const enum SameSite { + Strict = 'strict', + Lax = 'lax', + None = 'none', + } +} diff --git a/packages/playwright-core/src/server/bidi/bidiConnection.ts b/packages/playwright-core/src/server/bidi/bidiConnection.ts new file mode 100644 index 0000000000..7138f2e06a --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiConnection.ts @@ -0,0 +1,232 @@ +/** + * 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 { EventEmitter } from 'events'; +import { assert } from '../../utils'; +import type { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport'; +import type { RecentLogsCollector } from '../../utils/debugLogger'; +import { debugLogger } from '../../utils/debugLogger'; +import type { ProtocolLogger } from '../types'; +import { helper } from '../helper'; +import { ProtocolError } from '../protocolError'; +import type * as bidi from './third_party/bidiProtocol'; +import type * as bidiCommands from './third_party/bidiCommands'; + +// BidiPlaywright uses this special id to issue Browser.close command which we +// should ignore. +export const kBrowserCloseMessageId = 0; + +export class BidiConnection { + private readonly _transport: ConnectionTransport; + private readonly _onDisconnect: () => void; + private readonly _protocolLogger: ProtocolLogger; + private readonly _browserLogsCollector: RecentLogsCollector; + _browserDisconnectedLogs: string | undefined; + private _lastId = 0; + private _closed = false; + readonly browserSession: BidiSession; + readonly _browsingContextToSession = new Map(); + + constructor(transport: ConnectionTransport, onDisconnect: () => void, protocolLogger: ProtocolLogger, browserLogsCollector: RecentLogsCollector) { + this._transport = transport; + this._onDisconnect = onDisconnect; + this._protocolLogger = protocolLogger; + this._browserLogsCollector = browserLogsCollector; + this.browserSession = new BidiSession(this, '', (message: any) => { + this.rawSend(message); + }); + this._transport.onmessage = this._dispatchMessage.bind(this); + // onclose should be set last, since it can be immediately called. + this._transport.onclose = this._onClose.bind(this); + } + + nextMessageId(): number { + return ++this._lastId; + } + + rawSend(message: ProtocolRequest) { + this._protocolLogger('send', message); + this._transport.send(message); + } + + private _dispatchMessage(message: ProtocolResponse) { + this._protocolLogger('receive', message); + const object = message as bidi.Message; + // Bidi messages do not have a common session identifier, so we + // route them based on BrowsingContext. + if (object.type === 'event') { + // Route page events to the right session. + let context; + if ('context' in object.params) + context = object.params.context; + else if (object.method === 'log.entryAdded') + context = object.params.source?.context; + if (context) { + const session = this._browsingContextToSession.get(context); + if (session) { + session.dispatchMessage(message); + return; + } + } + } else if (message.id) { + // Find caller session. + for (const session of this._browsingContextToSession.values()) { + if (session.hasCallback(message.id)) { + session.dispatchMessage(message); + return; + } + } + } + this.browserSession.dispatchMessage(message); + } + + _onClose(reason?: string) { + this._closed = true; + this._transport.onmessage = undefined; + this._transport.onclose = undefined; + this._browserDisconnectedLogs = helper.formatBrowserLogs(this._browserLogsCollector.recentLogs(), reason); + this.browserSession.dispose(); + this._onDisconnect(); + } + + isClosed() { + return this._closed; + } + + close() { + if (!this._closed) + this._transport.close(); + } + + createMainFrameBrowsingContextSession(bowsingContextId: bidi.BrowsingContext.BrowsingContext): BidiSession { + const result = new BidiSession(this, bowsingContextId, message => this.rawSend(message)); + this._browsingContextToSession.set(bowsingContextId, result); + return result; + } +} + +type BidiEvents = { + [K in bidi.Event['method']]: Extract; +}; + +export class BidiSession extends EventEmitter { + readonly connection: BidiConnection; + readonly sessionId: string; + + private _disposed = false; + private readonly _rawSend: (message: any) => void; + private readonly _callbacks = new Map void, reject: (e: ProtocolError) => void, error: ProtocolError }>(); + private _crashed: boolean = false; + private readonly _browsingContexts = new Set(); + + override on: (event: T, listener: (payload: T extends symbol ? any : BidiEvents[T extends keyof BidiEvents ? T : never]['params']) => void) => this; + override addListener: (event: T, listener: (payload: T extends symbol ? any : BidiEvents[T extends keyof BidiEvents ? T : never]['params']) => void) => this; + override off: (event: T, listener: (payload: T extends symbol ? any : BidiEvents[T extends keyof BidiEvents ? T : never]['params']) => void) => this; + override removeListener: (event: T, listener: (payload: T extends symbol ? any : BidiEvents[T extends keyof BidiEvents ? T : never]['params']) => void) => this; + override once: (event: T, listener: (payload: T extends symbol ? any : BidiEvents[T extends keyof BidiEvents ? T : never]['params']) => void) => this; + + constructor(connection: BidiConnection, sessionId: string, rawSend: (message: any) => void) { + super(); + this.setMaxListeners(0); + this.connection = connection; + this.sessionId = sessionId; + this._rawSend = rawSend; + + this.on = super.on; + this.off = super.removeListener; + this.addListener = super.addListener; + this.removeListener = super.removeListener; + this.once = super.once; + } + + addFrameBrowsingContext(context: string) { + this._browsingContexts.add(context); + this.connection._browsingContextToSession.set(context, this); + } + + removeFrameBrowsingContext(context: string) { + this._browsingContexts.delete(context); + this.connection._browsingContextToSession.delete(context); + } + + async send( + method: T, + params?: bidiCommands.Commands[T]['params'] + ): Promise { + if (this._crashed || this._disposed || this.connection._browserDisconnectedLogs) + throw new ProtocolError(this._crashed ? 'crashed' : 'closed', undefined, this.connection._browserDisconnectedLogs); + const id = this.connection.nextMessageId(); + const messageObj = { id, method, params }; + this._rawSend(messageObj); + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject, error: new ProtocolError('error', method) }); + }); + } + + sendMayFail(method: T, params?: bidiCommands.Commands[T]['params']): Promise { + return this.send(method, params).catch(error => debugLogger.log('error', error)); + } + + markAsCrashed() { + this._crashed = true; + } + + isDisposed(): boolean { + return this._disposed; + } + + dispose() { + this._disposed = true; + this.connection._browsingContextToSession.delete(this.sessionId); + for (const context of this._browsingContexts) + this.connection._browsingContextToSession.delete(context); + this._browsingContexts.clear(); + for (const callback of this._callbacks.values()) { + callback.error.type = this._crashed ? 'crashed' : 'closed'; + callback.error.logs = this.connection._browserDisconnectedLogs; + callback.reject(callback.error); + } + this._callbacks.clear(); + } + + hasCallback(id: number): boolean { + return this._callbacks.has(id); + } + + dispatchMessage(message: any) { + const object = message as bidi.Message; + if (object.id === kBrowserCloseMessageId) + return; + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id)!; + this._callbacks.delete(object.id); + if (object.type === 'error') { + callback.error.setMessage(object.error + '\nMessage: ' + object.message); + callback.reject(callback.error); + } else if (object.type === 'success') { + callback.resolve(object.result); + } else { + callback.error.setMessage('Internal error, unexpected response type: ' + JSON.stringify(object)); + callback.reject(callback.error); + } + } else if (object.id) { + // Response might come after session has been disposed and rejected all callbacks. + assert(this.isDisposed()); + } else { + Promise.resolve().then(() => this.emit(object.method, object.params)); + } + } +} diff --git a/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts new file mode 100644 index 0000000000..eaacb629e6 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts @@ -0,0 +1,167 @@ +/** + * 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 { parseEvaluationResultValue } from '../isomorphic/utilityScriptSerializers'; +import * as js from '../javascript'; +import type { BidiSession } from './bidiConnection'; +import { BidiDeserializer } from './third_party/bidiDeserializer'; +import * as bidi from './third_party/bidiProtocol'; +import { BidiSerializer } from './third_party/bidiSerializer'; + +export class BidiExecutionContext implements js.ExecutionContextDelegate { + private readonly _session: BidiSession; + private readonly _target: bidi.Script.Target; + + constructor(session: BidiSession, realmInfo: bidi.Script.RealmInfo) { + this._session = session; + if (realmInfo.type === 'window') { + // Simple realm does not seem to work for Window contexts. + this._target = { + context: realmInfo.context, + sandbox: realmInfo.sandbox, + }; + } else { + this._target = { + realm: realmInfo.realm + }; + } + } + + async rawEvaluateJSON(expression: string): Promise { + const response = await this._session.send('script.evaluate', { + expression, + target: this._target, + serializationOptions: { + maxObjectDepth: 10, + maxDomDepth: 10, + }, + awaitPromise: true, + userActivation: true, + }); + if (response.type === 'success') + return BidiDeserializer.deserialize(response.result); + if (response.type === 'exception') + throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails)); + throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); + } + + async rawEvaluateHandle(expression: string): Promise { + const response = await this._session.send('script.evaluate', { + expression, + target: this._target, + resultOwnership: bidi.Script.ResultOwnership.Root, // Necessary for the handle to be returned. + serializationOptions: { maxObjectDepth: 0, maxDomDepth: 0 }, + awaitPromise: true, + userActivation: true, + }); + if (response.type === 'success') { + if ('handle' in response.result) + return response.result.handle!; + throw new js.JavaScriptErrorInEvaluate('Cannot get handle: ' + JSON.stringify(response.result)); + } + if (response.type === 'exception') + throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails)); + throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); + } + + rawCallFunctionNoReply(func: Function, ...args: any[]) { + throw new Error('Method not implemented.'); + } + + async evaluateWithArguments(functionDeclaration: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { + const response = await this._session.send('script.callFunction', { + functionDeclaration, + target: this._target, + arguments: [ + { handle: utilityScript._objectId! }, + ...values.map(BidiSerializer.serialize), + ...objectIds.map(handle => ({ handle })), + ], + resultOwnership: returnByValue ? undefined : bidi.Script.ResultOwnership.Root, // Necessary for the handle to be returned. + serializationOptions: returnByValue ? {} : { maxObjectDepth: 0, maxDomDepth: 0 }, + awaitPromise: true, + userActivation: true, + }); + if (response.type === 'exception') + throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails)); + if (response.type === 'success') { + if (returnByValue) + return parseEvaluationResultValue(BidiDeserializer.deserialize(response.result)); + const objectId = 'handle' in response.result ? response.result.handle : undefined ; + return utilityScript._context.createHandle({ objectId, ...response.result }); + } + throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); + } + + async getProperties(context: js.ExecutionContext, objectId: js.ObjectId): Promise> { + throw new Error('Method not implemented.'); + } + + createHandle(context: js.ExecutionContext, jsRemoteObject: js.RemoteObject): js.JSHandle { + const remoteObject: bidi.Script.RemoteValue = jsRemoteObject as bidi.Script.RemoteValue; + return new js.JSHandle(context, remoteObject.type, renderPreview(remoteObject), jsRemoteObject.objectId, remoteObjectValue(remoteObject)); + } + + async releaseHandle(objectId: js.ObjectId): Promise { + await this._session.send('script.disown', { + target: this._target, + handles: [objectId], + }); + } + + objectCount(objectId: js.ObjectId): Promise { + throw new Error('Method not implemented.'); + } + + async rawCallFunction(functionDeclaration: string, arg: bidi.Script.LocalValue): Promise { + const response = await this._session.send('script.callFunction', { + functionDeclaration, + target: this._target, + arguments: [arg], + resultOwnership: bidi.Script.ResultOwnership.Root, // Necessary for the handle to be returned. + serializationOptions: { maxObjectDepth: 0, maxDomDepth: 0 }, + awaitPromise: true, + userActivation: true, + }); + if (response.type === 'exception') + throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails)); + if (response.type === 'success') + return response.result; + throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); + } +} + +function renderPreview(remoteObject: bidi.Script.RemoteValue): string | undefined { + if (remoteObject.type === 'undefined') + return 'undefined'; + if (remoteObject.type === 'null') + return 'null'; + if ('value' in remoteObject) + return String(remoteObject.value); + return `<${remoteObject.type}>`; +} + +function remoteObjectValue(remoteObject: bidi.Script.RemoteValue): any { + if (remoteObject.type === 'undefined') + return undefined; + if (remoteObject.type === 'null') + return null; + if (remoteObject.type === 'number' && typeof remoteObject.value === 'string') + return js.parseUnserializableValue(remoteObject.value); + if ('value' in remoteObject) + return remoteObject.value; + return undefined; +} diff --git a/packages/playwright-core/src/server/bidi/bidiFirefox.ts b/packages/playwright-core/src/server/bidi/bidiFirefox.ts new file mode 100644 index 0000000000..499ab7f4c7 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiFirefox.ts @@ -0,0 +1,112 @@ +/** + * 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/bidiInput.ts b/packages/playwright-core/src/server/bidi/bidiInput.ts new file mode 100644 index 0000000000..29d1dd48db --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiInput.ts @@ -0,0 +1,149 @@ +/** + * 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 type * as input from '../input'; +import type * as types from '../types'; +import type { BidiSession } from './bidiConnection'; +import * as bidi from './third_party/bidiProtocol'; +import { getBidiKeyValue } from './third_party/bidiKeyboard'; + +export class RawKeyboardImpl implements input.RawKeyboard { + private _session: BidiSession; + + constructor(session: BidiSession) { + this._session = session; + } + + setSession(session: BidiSession) { + this._session = session; + } + + async keydown(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise { + const actions: bidi.Input.KeySourceAction[] = []; + actions.push({ type: 'keyDown', value: getBidiKeyValue(key) }); + // TODO: add modifiers? + await this._performActions(actions); + } + + async keyup(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise { + const actions: bidi.Input.KeySourceAction[] = []; + actions.push({ type: 'keyUp', value: getBidiKeyValue(key) }); + await this._performActions(actions); + } + + async sendText(text: string): Promise { + const actions: bidi.Input.KeySourceAction[] = []; + for (const char of text) { + const value = getBidiKeyValue(char); + actions.push({ type: 'keyDown', value }); + actions.push({ type: 'keyUp', value }); + } + await this._performActions(actions); + } + + private async _performActions(actions: bidi.Input.KeySourceAction[]) { + await this._session.send('input.performActions', { + context: this._session.sessionId, + actions: [ + { + type: 'key', + id: 'pw_keyboard', + actions, + } + ] + }); + } +} + +export class RawMouseImpl implements input.RawMouse { + private readonly _session: BidiSession; + + constructor(session: BidiSession) { + this._session = session; + } + + async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { + // TODO: bidi throws when x/y are not integers. + x = Math.round(x); + y = Math.round(y); + await this._performActions([{ type: 'pointerMove', x, y }]); + } + + async down(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._performActions([{ type: 'pointerDown', button: toBidiButton(button) }]); + } + + async up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._performActions([{ type: 'pointerUp', button: toBidiButton(button) }]); + } + + async click(x: number, y: number, options: { delay?: number, button?: types.MouseButton, clickCount?: number } = {}) { + x = Math.round(x); + y = Math.round(y); + const button = toBidiButton(options.button || 'left'); + const { delay = null, clickCount = 1 } = options; + const actions: bidi.Input.PointerSourceAction[] = []; + actions.push({ type: 'pointerMove', x, y }); + for (let cc = 1; cc <= clickCount; ++cc) { + actions.push({ type: 'pointerDown', button }); + if (delay) + actions.push({ type: 'pause', duration: delay }); + actions.push({ type: 'pointerUp', button }); + if (delay && cc < clickCount) + actions.push({ type: 'pause', duration: delay }); + } + await this._performActions(actions); + } + + async wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { + } + + private async _performActions(actions: bidi.Input.PointerSourceAction[]) { + await this._session.send('input.performActions', { + context: this._session.sessionId, + actions: [ + { + type: 'pointer', + id: 'pw_mouse', + parameters: { + pointerType: bidi.Input.PointerType.Mouse, + }, + actions, + } + ] + }); + } +} + +export class RawTouchscreenImpl implements input.RawTouchscreen { + private readonly _session: BidiSession; + + constructor(session: BidiSession) { + this._session = session; + } + + async tap(x: number, y: number, modifiers: Set) { + } +} + +function toBidiButton(button: string): number { + switch (button) { + case 'left': return 0; + case 'right': return 2; + case 'middle': return 1; + } + throw new Error('Unknown button: ' + button); +} diff --git a/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts new file mode 100644 index 0000000000..00846b124a --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts @@ -0,0 +1,316 @@ +/** + * 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 type { RegisteredListener } from '../../utils/eventsHelper'; +import { eventsHelper } from '../../utils/eventsHelper'; +import type { Page } from '../page'; +import * as network from '../network'; +import type * as frames from '../frames'; +import type * as types from '../types'; +import * as bidi from './third_party/bidiProtocol'; +import type { BidiSession } from './bidiConnection'; + + +export class BidiNetworkManager { + private readonly _session: BidiSession; + private readonly _requests: Map; + private readonly _page: Page; + private readonly _eventListeners: RegisteredListener[]; + private readonly _onNavigationResponseStarted: (params: bidi.Network.ResponseStartedParameters) => void; + private _userRequestInterceptionEnabled: boolean = false; + private _protocolRequestInterceptionEnabled: boolean = false; + private _credentials: types.Credentials | undefined; + private _intercepId: bidi.Network.Intercept | undefined; + + constructor(bidiSession: BidiSession, page: Page, onNavigationResponseStarted: (params: bidi.Network.ResponseStartedParameters) => void) { + this._session = bidiSession; + this._requests = new Map(); + this._page = page; + this._onNavigationResponseStarted = onNavigationResponseStarted; + this._eventListeners = [ + eventsHelper.addEventListener(bidiSession, 'network.beforeRequestSent', this._onBeforeRequestSent.bind(this)), + eventsHelper.addEventListener(bidiSession, 'network.responseStarted', this._onResponseStarted.bind(this)), + eventsHelper.addEventListener(bidiSession, 'network.responseCompleted', this._onResponseCompleted.bind(this)), + eventsHelper.addEventListener(bidiSession, 'network.fetchError', this._onFetchError.bind(this)), + eventsHelper.addEventListener(bidiSession, 'network.authRequired', this._onAuthRequired.bind(this)), + ]; + } + + dispose() { + eventsHelper.removeEventListeners(this._eventListeners); + } + + private _onBeforeRequestSent(param: bidi.Network.BeforeRequestSentParameters) { + if (param.request.url.startsWith('data:')) + return; + const redirectedFrom = param.redirectCount ? (this._requests.get(param.request.request) || null) : null; + const frame = redirectedFrom ? redirectedFrom.request.frame() : (param.context ? this._page._frameManager.frame(param.context) : null); + if (!frame) + return; + if (redirectedFrom) + this._requests.delete(redirectedFrom._id); + let route; + if (param.intercepts) { + // We do not support intercepting redirects. + if (redirectedFrom) { + this._session.sendMayFail('network.continueRequest', { + request: param.request.request, + headers: redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders, + }); + } else { + route = new BidiRouteImpl(this._session, param.request.request); + } + } + const request = new BidiRequest(frame, redirectedFrom, param, route); + this._requests.set(request._id, request); + this._page._frameManager.requestStarted(request.request, route); + } + + private _onResponseStarted(params: bidi.Network.ResponseStartedParameters) { + const request = this._requests.get(params.request.request); + if (!request) + return; + const getResponseBody = async () => { + throw new Error(`Response body is not available for requests in Bidi`); + }; + const timings = params.request.timings; + const startTime = timings.requestTime; + function relativeToStart(time: number): number { + if (!time) + return -1; + return (time - startTime) / 1000; + } + const timing: network.ResourceTiming = { + startTime: startTime / 1000, + requestStart: relativeToStart(timings.requestStart), + responseStart: relativeToStart(timings.responseStart), + domainLookupStart: relativeToStart(timings.dnsStart), + domainLookupEnd: relativeToStart(timings.dnsEnd), + connectStart: relativeToStart(timings.connectStart), + secureConnectionStart: relativeToStart(timings.tlsStart), + connectEnd: relativeToStart(timings.connectEnd), + }; + const response = new network.Response(request.request, params.response.status, params.response.statusText, fromBidiHeaders(params.response.headers), timing, getResponseBody, false); + response._serverAddrFinished(); + response._securityDetailsFinished(); + // "raw" headers are the same as "provisional" headers in Bidi. + response.setRawResponseHeaders(null); + response.setResponseHeadersSize(params.response.headersSize); + this._page._frameManager.requestReceivedResponse(response); + if (params.navigation) + this._onNavigationResponseStarted(params); + } + + private _onResponseCompleted(params: bidi.Network.ResponseCompletedParameters) { + const request = this._requests.get(params.request.request); + if (!request) + return; + const response = request.request._existingResponse()!; + // TODO: body size is the encoded size + response.setTransferSize(params.response.bodySize); + response.setEncodedBodySize(params.response.bodySize); + + // Keep redirected requests in the map for future reference as redirectedFrom. + const isRedirected = response.status() >= 300 && response.status() <= 399; + const responseEndTime = params.request.timings.responseEnd / 1000 - response.timing().startTime; + if (isRedirected) { + response._requestFinished(responseEndTime); + } else { + this._requests.delete(request._id); + response._requestFinished(responseEndTime); + } + response._setHttpVersion(params.response.protocol); + this._page._frameManager.reportRequestFinished(request.request, response); + + } + + private _onFetchError(params: bidi.Network.FetchErrorParameters) { + const request = this._requests.get(params.request.request); + if (!request) + return; + this._requests.delete(request._id); + const response = request.request._existingResponse(); + if (response) { + response.setTransferSize(null); + response.setEncodedBodySize(null); + response._requestFinished(-1); + } + request.request._setFailureText(params.errorText); + // TODO: support canceled flag + this._page._frameManager.requestFailed(request.request, params.errorText === 'NS_BINDING_ABORTED'); + } + + private _onAuthRequired(params: bidi.Network.AuthRequiredParameters) { + const isBasic = params.response.authChallenges?.some(challenge => challenge.scheme.startsWith('Basic')); + const credentials = this._page._browserContext._options.httpCredentials; + if (isBasic && credentials) { + this._session.sendMayFail('network.continueWithAuth', { + request: params.request.request, + action: 'provideCredentials', + credentials: { + type: 'password', + username: credentials.username, + password: credentials.password, + } + }); + } else { + this._session.sendMayFail('network.continueWithAuth', { + request: params.request.request, + action: 'default', + }); + } + } + + async setRequestInterception(value: boolean) { + this._userRequestInterceptionEnabled = value; + await this._updateProtocolRequestInterception(); + } + + async setCredentials(credentials: types.Credentials | undefined) { + this._credentials = credentials; + await this._updateProtocolRequestInterception(); + } + + async _updateProtocolRequestInterception(initial?: boolean) { + const enabled = this._userRequestInterceptionEnabled || !!this._credentials; + if (enabled === this._protocolRequestInterceptionEnabled) + return; + this._protocolRequestInterceptionEnabled = enabled; + if (initial && !enabled) + return; + const cachePromise = this._session.send('network.setCacheBehavior', { cacheBehavior: enabled ? 'bypass' : 'default' }); + let interceptPromise = Promise.resolve(undefined); + if (enabled) { + interceptPromise = this._session.send('network.addIntercept', { + phases: [bidi.Network.InterceptPhase.AuthRequired, bidi.Network.InterceptPhase.BeforeRequestSent], + urlPatterns: [{ type: 'pattern' }], + // urlPatterns: [{ type: 'string', pattern: '*' }], + }).then(r => { + this._intercepId = r.intercept; + }); + } else if (this._intercepId) { + interceptPromise = this._session.send('network.removeIntercept', { intercept: this._intercepId }); + this._intercepId = undefined; + } + await Promise.all([cachePromise, interceptPromise]); + } +} + + +class BidiRequest { + readonly request: network.Request; + readonly _id: string; + private _redirectedTo: BidiRequest | undefined; + // Only first request in the chain can be intercepted, so this will + // store the first and only Route in the chain (if any). + _originalRequestRoute: BidiRouteImpl | undefined; + + constructor(frame: frames.Frame, redirectedFrom: BidiRequest | null, payload: bidi.Network.BeforeRequestSentParameters, route: BidiRouteImpl | undefined) { + this._id = payload.request.request; + if (redirectedFrom) + redirectedFrom._redirectedTo = this; + // TODO: missing in the spec? + const postDataBuffer = null; + this.request = new network.Request(frame._page._browserContext, frame, null, redirectedFrom ? redirectedFrom.request : null, payload.navigation ?? undefined, + payload.request.url, 'other', payload.request.method, postDataBuffer, fromBidiHeaders(payload.request.headers)); + // "raw" headers are the same as "provisional" headers in Bidi. + this.request.setRawRequestHeaders(null); + this.request._setBodySize(payload.request.bodySize || 0); + this._originalRequestRoute = route ?? redirectedFrom?._originalRequestRoute; + route?._setRequest(this.request); + } + + _finalRequest(): BidiRequest { + let request: BidiRequest = this; + while (request._redirectedTo) + request = request._redirectedTo; + return request; + } +} + +class BidiRouteImpl implements network.RouteDelegate { + private _requestId: bidi.Network.Request; + private _session: BidiSession; + private _request!: network.Request; + _alreadyContinuedHeaders: bidi.Network.Header[] | undefined; + + constructor(session: BidiSession, requestId: bidi.Network.Request) { + this._session = session; + this._requestId = requestId; + } + + _setRequest(request: network.Request) { + this._request = request; + } + + async continue(overrides: types.NormalizedContinueOverrides) { + // Firefox does not update content-length header. + let headers = overrides.headers || this._request.headers(); + if (overrides.postData && headers) { + headers = headers.map(header => { + if (header.name.toLowerCase() === 'content-length') + return { name: header.name, value: overrides.postData!.byteLength.toString() }; + return header; + }); + } + this._alreadyContinuedHeaders = toBidiHeaders(headers); + await this._session.sendMayFail('network.continueRequest', { + request: this._requestId, + url: overrides.url, + method: overrides.method, + // TODO: cookies! + headers: this._alreadyContinuedHeaders, + body: overrides.postData ? { type: 'base64', value: Buffer.from(overrides.postData).toString('base64') } : undefined, + }); + } + + async fulfill(response: types.NormalizedFulfillResponse) { + const base64body = response.isBase64 ? response.body : Buffer.from(response.body).toString('base64'); + await this._session.sendMayFail('network.provideResponse', { + request: this._requestId, + statusCode: response.status, + reasonPhrase: network.statusText(response.status), + headers: toBidiHeaders(response.headers), + body: { type: 'base64', value: base64body }, + }); + } + + async abort(errorCode: string) { + await this._session.sendMayFail('network.failRequest', { + request: this._requestId + }); + } +} + +function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray { + const result: types.HeadersArray = []; + for (const { name, value } of bidiHeaders) + result.push({ name, value: bidiBytesValueToString(value) }); + return result; +} + +function toBidiHeaders(headers: types.HeadersArray): bidi.Network.Header[] { + return headers.map(({ name, value }) => ({ name, value: { type: 'string', value } })); +} + +export function bidiBytesValueToString(value: bidi.Network.BytesValue): string { + if (value.type === 'string') + return value.value; + if (value.type === 'base64') + return Buffer.from(value.type, 'base64').toString('binary'); + return 'unknown value type: ' + (value as any).type; + +} diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts new file mode 100644 index 0000000000..0028b9fc95 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -0,0 +1,527 @@ +/** + * 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 type { RegisteredListener } from '../../utils/eventsHelper'; +import { eventsHelper } from '../../utils/eventsHelper'; +import { assert } from '../../utils'; +import type * as accessibility from '../accessibility'; +import * as dom from '../dom'; +import * as dialog from '../dialog'; +import type * as frames from '../frames'; +import { type InitScript, Page, type PageDelegate } from '../page'; +import type { Progress } from '../progress'; +import type * as types from '../types'; +import type { BidiBrowserContext } from './bidiBrowser'; +import type { BidiSession } from './bidiConnection'; +import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './bidiInput'; +import * as bidi from './third_party/bidiProtocol'; +import { BidiExecutionContext } from './bidiExecutionContext'; +import { BidiNetworkManager } from './bidiNetworkManager'; +import { BrowserContext } from '../browserContext'; + +const UTILITY_WORLD_NAME = '__playwright_utility_world__'; + +export class BidiPage implements PageDelegate { + readonly rawMouse: RawMouseImpl; + readonly rawKeyboard: RawKeyboardImpl; + readonly rawTouchscreen: RawTouchscreenImpl; + readonly _page: Page; + private readonly _pagePromise: Promise; + readonly _session: BidiSession; + readonly _opener: BidiPage | null; + private readonly _realmToContext: Map; + private _sessionListeners: RegisteredListener[] = []; + readonly _browserContext: BidiBrowserContext; + readonly _networkManager: BidiNetworkManager; + _initializedPage: Page | null = null; + + constructor(browserContext: BidiBrowserContext, bidiSession: BidiSession, opener: BidiPage | null) { + this._session = bidiSession; + this._opener = opener; + this.rawKeyboard = new RawKeyboardImpl(bidiSession); + this.rawMouse = new RawMouseImpl(bidiSession); + this.rawTouchscreen = new RawTouchscreenImpl(bidiSession); + this._realmToContext = new Map(); + this._page = new Page(this, browserContext); + this._browserContext = browserContext; + this._networkManager = new BidiNetworkManager(this._session, this._page, this._onNavigationResponseStarted.bind(this)); + this._page.on(Page.Events.FrameDetached, (frame: frames.Frame) => this._removeContextsForFrame(frame, false)); + this._sessionListeners = [ + eventsHelper.addEventListener(bidiSession, 'script.realmCreated', this._onRealmCreated.bind(this)), + eventsHelper.addEventListener(bidiSession, 'browsingContext.contextDestroyed', this._onBrowsingContextDestroyed.bind(this)), + eventsHelper.addEventListener(bidiSession, 'browsingContext.navigationStarted', this._onNavigationStarted.bind(this)), + eventsHelper.addEventListener(bidiSession, 'browsingContext.navigationAborted', this._onNavigationAborted.bind(this)), + eventsHelper.addEventListener(bidiSession, 'browsingContext.navigationFailed', this._onNavigationFailed.bind(this)), + eventsHelper.addEventListener(bidiSession, 'browsingContext.fragmentNavigated', this._onFragmentNavigated.bind(this)), + eventsHelper.addEventListener(bidiSession, 'browsingContext.domContentLoaded', this._onDomContentLoaded.bind(this)), + eventsHelper.addEventListener(bidiSession, 'browsingContext.load', this._onLoad.bind(this)), + eventsHelper.addEventListener(bidiSession, 'browsingContext.userPromptOpened', this._onUserPromptOpened.bind(this)), + eventsHelper.addEventListener(bidiSession, 'log.entryAdded', this._onLogEntryAdded.bind(this)), + ]; + + // Initialize main frame. + this._pagePromise = this._initialize().finally(async () => { + await this._page.initOpener(this._opener); + }).then(() => { + this._initializedPage = this._page; + this._page.reportAsNew(); + return this._page; + }).catch(e => { + this._page.reportAsNew(e); + return e; + }); + } + + private async _initialize() { + const { contexts } = await this._session.send('browsingContext.getTree', { root: this._session.sessionId }); + this._handleFrameTree(contexts[0]); + await Promise.all([ + this.updateHttpCredentials(), + this.updateRequestInterception(), + this._updateViewport(), + ]); + } + + 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; + } + + didClose() { + this._session.dispose(); + eventsHelper.removeEventListeners(this._sessionListeners); + this._page._didClose(); + } + + async pageOrError(): Promise { + // TODO: Wait for first execution context to be created and maybe about:blank navigated. + return this._pagePromise; + } + + private _onFrameAttached(frameId: string, parentFrameId: string | null): frames.Frame { + return this._page._frameManager.frameAttached(frameId, parentFrameId); + } + + private _removeContextsForFrame(frame: frames.Frame, notifyFrame: boolean) { + for (const [contextId, context] of this._realmToContext) { + if (context.frame === frame) { + this._realmToContext.delete(contextId); + if (notifyFrame) + frame._contextDestroyed(context); + } + } + } + + private _onRealmCreated(realmInfo: bidi.Script.RealmInfo) { + if (this._realmToContext.has(realmInfo.realm)) + return; + if (realmInfo.type !== 'window') + return; + const frame = this._page._frameManager.frame(realmInfo.context); + if (!frame) + return; + const delegate = new BidiExecutionContext(this._session, realmInfo); + let worldName: types.World; + if (!realmInfo.sandbox) { + worldName = 'main'; + // Force creating utility world every time the main world is created (e.g. due to navigation). + this._touchUtilityWorld(realmInfo.context); + } else if (realmInfo.sandbox === UTILITY_WORLD_NAME) { + worldName = 'utility'; + } else { + return; + } + const context = new dom.FrameExecutionContext(delegate, frame, worldName); + (context as any)[contextDelegateSymbol] = delegate; + frame._contextCreated(worldName, context); + this._realmToContext.set(realmInfo.realm, context); + } + + private async _touchUtilityWorld(context: bidi.BrowsingContext.BrowsingContext) { + await this._session.sendMayFail('script.evaluate', { + expression: '1 + 1', + target: { + context, + sandbox: UTILITY_WORLD_NAME, + }, + serializationOptions: { + maxObjectDepth: 10, + maxDomDepth: 10, + }, + awaitPromise: true, + userActivation: true, + }); + } + + _onRealmDestroyed(params: bidi.Script.RealmDestroyedParameters): boolean { + const context = this._realmToContext.get(params.realm); + if (!context) + return false; + this._realmToContext.delete(params.realm); + context.frame._contextDestroyed(context); + return true; + } + + // TODO: route the message directly to the browser + private _onBrowsingContextDestroyed(params: bidi.BrowsingContext.Info) { + this._browserContext._browser._onBrowsingContextDestroyed(params); + } + + private _onNavigationStarted(params: bidi.BrowsingContext.NavigationInfo) { + const frameId = params.context; + this._page._frameManager.frameRequestedNavigation(frameId, params.navigation!); + + const url = params.url.toLowerCase(); + if (url.startsWith('file:') || url.startsWith('data:') || url === 'about:blank') { + // Navigation to file urls doesn't emit network events, so we fire 'commit' event right when navigation is started. + // Doing it in domcontentload would be too late as we'd clear frame tree. + const frame = this._page._frameManager.frame(frameId)!; + if (frame) + this._page._frameManager.frameCommittedNewDocumentNavigation(frameId, params.url, '', params.navigation!, /* initial */ false); + } + } + + // TODO: there is no separate event for committed navigation, so we approximate it with responseStarted. + private _onNavigationResponseStarted(params: bidi.Network.ResponseStartedParameters) { + const frameId = params.context!; + const frame = this._page._frameManager.frame(frameId); + assert(frame); + this._page._frameManager.frameCommittedNewDocumentNavigation(frameId, params.response.url, '', params.navigation!, /* initial */ false); + // if (!initial) + // this._firstNonInitialNavigationCommittedFulfill(); + } + + private _onDomContentLoaded(params: bidi.BrowsingContext.NavigationInfo) { + const frameId = params.context; + this._page._frameManager.frameLifecycleEvent(frameId, 'domcontentloaded'); + } + + private _onLoad(params: bidi.BrowsingContext.NavigationInfo) { + this._page._frameManager.frameLifecycleEvent(params.context, 'load'); + } + + private _onNavigationAborted(params: bidi.BrowsingContext.NavigationInfo) { + this._page._frameManager.frameAbortedNavigation(params.context, 'Navigation aborted', params.navigation || undefined); + } + + private _onNavigationFailed(params: bidi.BrowsingContext.NavigationInfo) { + this._page._frameManager.frameAbortedNavigation(params.context, 'Navigation failed', params.navigation || undefined); + } + + private _onFragmentNavigated(params: bidi.BrowsingContext.NavigationInfo) { + this._page._frameManager.frameCommittedSameDocumentNavigation(params.context, params.url); + } + + private _onUserPromptOpened(event: bidi.BrowsingContext.UserPromptOpenedParameters) { + this._page.emitOnContext(BrowserContext.Events.Dialog, new dialog.Dialog( + this._page, + event.type as dialog.DialogType, + event.message, + async (accept: boolean, userText?: string) => { + await this._session.send('browsingContext.handleUserPrompt', { context: event.context, accept, userText }); + }, + event.defaultValue)); + } + + private _onLogEntryAdded(params: bidi.Log.Entry) { + if (params.type !== 'console') + return; + const entry: bidi.Log.ConsoleLogEntry = params as bidi.Log.ConsoleLogEntry; + const context = this._realmToContext.get(params.source.realm); + if (!context) + return; + const callFrame = params.stackTrace?.callFrames[0]; + const location = callFrame ?? { url: '', lineNumber: 1, columnNumber: 1 }; + this._page._addConsoleMessage(entry.method, entry.args.map(arg => context.createHandle({ objectId: (arg as any).handle, ...arg })), location, params.text || undefined); + } + + async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise { + const { navigation } = await this._session.send('browsingContext.navigate', { + context: frame._id, + url, + }); + return { newDocumentId: navigation || undefined }; + } + + async updateExtraHTTPHeaders(): Promise { + } + + async updateEmulateMedia(): Promise { + } + + async updateEmulatedViewportSize(): Promise { + await this._updateViewport(); + } + + async updateUserAgent(): Promise { + } + + async bringToFront(): Promise { + } + + private async _updateViewport(): Promise { + const options = this._browserContext._options; + const deviceSize = this._page.emulatedSize(); + if (deviceSize === null) + return; + const viewportSize = deviceSize.viewport; + await this._session.send('browsingContext.setViewport', { + context: this._session.sessionId, + viewport: { + width: viewportSize.width, + height: viewportSize.height, + }, + devicePixelRatio: options.deviceScaleFactor || 1 + }); + } + + async updateRequestInterception(): Promise { + await this._networkManager.setRequestInterception(this._page.needsRequestInterception()); + } + + async updateOffline() { + } + + async updateHttpCredentials() { + await this._networkManager.setCredentials(this._browserContext._options.httpCredentials); + } + + async updateFileChooserInterception() { + } + + async reload(): Promise { + await this._session.send('browsingContext.reload', { + context: this._session.sessionId, + // ignoreCache: true, + wait: bidi.BrowsingContext.ReadinessState.Interactive, + }); + } + + goBack(): Promise { + throw new Error('Method not implemented.'); + } + + goForward(): Promise { + throw new Error('Method not implemented.'); + } + + async addInitScript(initScript: InitScript): Promise { + await this._updateBootstrapScript(); + } + + async removeNonInternalInitScripts() { + await this._updateBootstrapScript(); + } + + async _updateBootstrapScript(): Promise { + throw new Error('Method not implemented.'); + } + + async closePage(runBeforeUnload: boolean): Promise { + await this._session.send('browsingContext.close', { + context: this._session.sessionId, + promptUnload: runBeforeUnload, + }); + } + + async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise { + } + + async takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { + throw new Error('Method not implemented.'); + } + + async getContentFrame(handle: dom.ElementHandle): Promise { + const executionContext = toBidiExecutionContext(handle._context); + const contentWindow = await executionContext.rawCallFunction('e => e.contentWindow', { handle: handle._objectId }); + if (contentWindow.type === 'window') { + const frameId = contentWindow.value.context; + const result = this._page._frameManager.frame(frameId); + return result; + } + return null; + } + + async getOwnerFrame(handle: dom.ElementHandle): Promise { + throw new Error('Method not implemented.'); + } + + isElementHandle(remoteObject: bidi.Script.RemoteValue): boolean { + return remoteObject.type === 'node'; + } + + async getBoundingBox(handle: dom.ElementHandle): Promise { + const box = await handle.evaluate(element => { + if (!(element instanceof Element)) + return null; + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }); + if (!box) + return null; + const position = await this._framePosition(handle._frame); + if (!position) + return null; + box.x += position.x; + box.y += position.y; + return box; + } + + // TODO: move to Frame. + private async _framePosition(frame: frames.Frame): Promise { + if (frame === this._page.mainFrame()) + return { x: 0, y: 0 }; + const element = await frame.frameElement(); + const box = await element.boundingBox(); + if (!box) + return null; + const style = await element.evaluateInUtility(([injected, iframe]) => injected.describeIFrameStyle(iframe as Element), {}).catch(e => 'error:notconnected' as const); + if (style === 'error:notconnected' || style === 'transformed') + return null; + // Content box is offset by border and padding widths. + box.x += style.left; + box.y += style.top; + return box; + } + + async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'> { + return await handle.evaluateInUtility(([injected, node]) => { + node.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'instant', + }); + }, null).then(() => 'done' as const).catch(e => { + if (e instanceof Error && e.message.includes('Node is detached from document')) + return 'error:notconnected'; + if (e instanceof Error && e.message.includes('Node does not have a layout object')) + return 'error:notvisible'; + throw e; + }); + } + + async setScreencastOptions(options: { width: number, height: number, quality: number } | null): Promise { + } + + rafCountForStablePosition(): number { + return 1; + } + + async getContentQuads(handle: dom.ElementHandle): Promise { + const quads = await handle.evaluateInUtility(([injected, node]) => { + if (!node.isConnected) + return 'error:notconnected'; + const rects = node.getClientRects(); + if (!rects) + return null; + return [...rects].map(rect => [ + { x: rect.left, y: rect.top }, + { x: rect.right, y: rect.top }, + { x: rect.right, y: rect.bottom }, + { x: rect.left, y: rect.bottom }, + ]); + }, null); + if (!quads || quads === 'error:notconnected') + return quads; + // TODO: consider transforming quads to support clicks in iframes. + const position = await this._framePosition(handle._frame); + if (!position) + return null; + quads.forEach(quad => quad.forEach(point => { + point.x += position.x; + point.y += position.y; + })); + return quads as types.Quad[]; + } + + async setInputFiles(handle: dom.ElementHandle, files: types.FilePayload[]): Promise { + throw new Error('Method not implemented.'); + } + + async setInputFilePaths(handle: dom.ElementHandle, paths: string[]): Promise { + throw new Error('Method not implemented.'); + } + + async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { + const fromContext = toBidiExecutionContext(handle._context); + const shared = await fromContext.rawCallFunction('x => x', { handle: handle._objectId }); + // TODO: store sharedId in the handle. + if (!('sharedId' in shared)) + throw new Error('Element is not a node'); + const sharedId = shared.sharedId!; + const executionContext = toBidiExecutionContext(to); + const result = await executionContext.rawCallFunction('x => x', { sharedId }); + if ('handle' in result) + return to.createHandle({ objectId: result.handle!, ...result }) as dom.ElementHandle; + throw new Error('Failed to adopt element handle.'); + } + + async getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}> { + throw new Error('Method not implemented.'); + } + + async inputActionEpilogue(): Promise { + } + + async resetForReuse(): Promise { + } + + async getFrameElement(frame: frames.Frame): Promise { + const parent = frame.parentFrame(); + if (!parent) + throw new Error('Frame has been detached.'); + const parentContext = await parent._mainContext(); + const list = await parentContext.evaluateHandle(() => { return [...document.querySelectorAll('iframe,frame')]; }); + const length = await list.evaluate(list => list.length); + let foundElement = null; + for (let i = 0; i < length; i++) { + const element = await list.evaluateHandle((list, i) => list[i], i); + const candidate = await element.contentFrame(); + if (frame === candidate) { + foundElement = element; + break; + } else { + element.dispose(); + } + } + list.dispose(); + if (!foundElement) + throw new Error('Frame has been detached.'); + return foundElement; + } + + shouldToggleStyleSheetToSyncAnimations(): boolean { + return true; + } + + useMainWorldForSetContent(): boolean { + return true; + } +} + +function toBidiExecutionContext(executionContext: dom.FrameExecutionContext): BidiExecutionContext { + return (executionContext as any)[contextDelegateSymbol] as BidiExecutionContext; +} + +const contextDelegateSymbol = Symbol('delegate'); diff --git a/packages/playwright-core/src/server/bidi/third_party/LICENSE b/packages/playwright-core/src/server/bidi/third_party/LICENSE new file mode 100644 index 0000000000..d2c171df74 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/third_party/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Google Inc. + + 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 + + https://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. diff --git a/packages/playwright-core/src/server/bidi/third_party/bidiCommands.d.ts b/packages/playwright-core/src/server/bidi/third_party/bidiCommands.d.ts new file mode 100644 index 0000000000..9242cc061d --- /dev/null +++ b/packages/playwright-core/src/server/bidi/third_party/bidiCommands.d.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Modifications copyright (c) Microsoft Corporation. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from './bidiProtocol'; + +export interface Commands { + 'script.evaluate': { + params: Bidi.Script.EvaluateParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.callFunction': { + params: Bidi.Script.CallFunctionParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.disown': { + params: Bidi.Script.DisownParameters; + returnType: Bidi.EmptyResult; + }; + 'script.addPreloadScript': { + params: Bidi.Script.AddPreloadScriptParameters; + returnType: Bidi.Script.AddPreloadScriptResult; + }; + 'script.removePreloadScript': { + params: Bidi.Script.RemovePreloadScriptParameters; + returnType: Bidi.EmptyResult; + }; + + 'browser.close': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + + 'browser.createUserContext': { + params: Bidi.EmptyParams; + returnType: Bidi.Browser.CreateUserContextResult; + }; + 'browser.getUserContexts': { + params: Bidi.EmptyParams; + returnType: Bidi.Browser.GetUserContextsResult; + }; + 'browser.removeUserContext': { + params: { + userContext: Bidi.Browser.UserContext; + }; + returnType: Bidi.Browser.RemoveUserContext; + }; + + 'browsingContext.activate': { + params: Bidi.BrowsingContext.ActivateParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.create': { + params: Bidi.BrowsingContext.CreateParameters; + returnType: Bidi.BrowsingContext.CreateResult; + }; + 'browsingContext.close': { + params: Bidi.BrowsingContext.CloseParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.getTree': { + params: Bidi.BrowsingContext.GetTreeParameters; + returnType: Bidi.BrowsingContext.GetTreeResult; + }; + 'browsingContext.locateNodes': { + params: Bidi.BrowsingContext.LocateNodesParameters; + returnType: Bidi.BrowsingContext.LocateNodesResult; + }; + 'browsingContext.navigate': { + params: Bidi.BrowsingContext.NavigateParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.reload': { + params: Bidi.BrowsingContext.ReloadParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.print': { + params: Bidi.BrowsingContext.PrintParameters; + returnType: Bidi.BrowsingContext.PrintResult; + }; + 'browsingContext.captureScreenshot': { + params: Bidi.BrowsingContext.CaptureScreenshotParameters; + returnType: Bidi.BrowsingContext.CaptureScreenshotResult; + }; + 'browsingContext.handleUserPrompt': { + params: Bidi.BrowsingContext.HandleUserPromptParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.setViewport': { + params: Bidi.BrowsingContext.SetViewportParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.traverseHistory': { + params: Bidi.BrowsingContext.TraverseHistoryParameters; + returnType: Bidi.EmptyResult; + }; + + 'input.performActions': { + params: Bidi.Input.PerformActionsParameters; + returnType: Bidi.EmptyResult; + }; + 'input.releaseActions': { + params: Bidi.Input.ReleaseActionsParameters; + returnType: Bidi.EmptyResult; + }; + 'input.setFiles': { + params: Bidi.Input.SetFilesParameters; + returnType: Bidi.EmptyResult; + }; + + 'session.end': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + 'session.new': { + params: Bidi.Session.NewParameters; + returnType: Bidi.Session.NewResult; + }; + 'session.status': { + params: object; + returnType: Bidi.Session.StatusResult; + }; + 'session.subscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; + 'session.unsubscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; + + 'storage.deleteCookies': { + params: Bidi.Storage.DeleteCookiesParameters; + returnType: Bidi.Storage.DeleteCookiesResult; + }; + 'storage.getCookies': { + params: Bidi.Storage.GetCookiesParameters; + returnType: Bidi.Storage.GetCookiesResult; + }; + 'network.setCacheBehavior': { + params: Bidi.Network.SetCacheBehaviorParameters; + returnType: Bidi.EmptyResult; + }; + 'storage.setCookie': { + params: Bidi.Storage.SetCookieParameters; + returnType: Bidi.Storage.SetCookieParameters; + }; + + 'network.addIntercept': { + params: Bidi.Network.AddInterceptParameters; + returnType: Bidi.Network.AddInterceptResult; + }; + 'network.removeIntercept': { + params: Bidi.Network.RemoveInterceptParameters; + returnType: Bidi.EmptyResult; + }; + 'network.continueRequest': { + params: Bidi.Network.ContinueRequestParameters; + returnType: Bidi.EmptyResult; + }; + 'network.continueWithAuth': { + params: Bidi.Network.ContinueWithAuthParameters; + returnType: Bidi.EmptyResult; + }; + 'network.failRequest': { + params: Bidi.Network.FailRequestParameters; + returnType: Bidi.EmptyResult; + }; + 'network.provideResponse': { + params: Bidi.Network.ProvideResponseParameters; + returnType: Bidi.EmptyResult; + }; +} diff --git a/packages/playwright-core/src/server/bidi/third_party/bidiDeserializer.ts b/packages/playwright-core/src/server/bidi/third_party/bidiDeserializer.ts new file mode 100644 index 0000000000..3637b4af36 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/third_party/bidiDeserializer.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Modifications copyright (c) Microsoft Corporation. + * SPDX-License-Identifier: Apache-2.0 + */ + + +import type * as Bidi from './bidiProtocol'; + +/* eslint-disable object-curly-spacing */ + +/** + * @internal + */ +export class BidiDeserializer { + static deserialize(result: Bidi.Script.RemoteValue): any { + if (!result) + return undefined; + + switch (result.type) { + case 'array': + return result.value?.map(value => { + return BidiDeserializer.deserialize(value); + }); + case 'set': + return result.value?.reduce((acc: Set, value) => { + return acc.add(BidiDeserializer.deserialize(value)); + }, new Set()); + case 'object': + return result.value?.reduce((acc: Record, tuple) => { + const {key, value} = BidiDeserializer._deserializeTuple(tuple); + acc[key as any] = value; + return acc; + }, {}); + case 'map': + return result.value?.reduce((acc: Map, tuple) => { + const {key, value} = BidiDeserializer._deserializeTuple(tuple); + return acc.set(key, value); + }, new Map()); + case 'promise': + return {}; + case 'regexp': + return new RegExp(result.value.pattern, result.value.flags); + case 'date': + return new Date(result.value); + case 'undefined': + return undefined; + case 'null': + return null; + case 'number': + return BidiDeserializer._deserializeNumber(result.value); + case 'bigint': + return BigInt(result.value); + case 'boolean': + return Boolean(result.value); + case 'string': + return result.value; + } + + throw new Error(`Deserialization of type ${result.type} not supported.`); + } + + static _deserializeNumber(value: Bidi.Script.SpecialNumber | number): number { + switch (value) { + case '-0': + return -0; + case 'NaN': + return NaN; + case 'Infinity': + return Infinity; + case '-Infinity': + return -Infinity; + default: + return value; + } + } + + static _deserializeTuple([serializedKey, serializedValue]: [ + Bidi.Script.RemoteValue | string, + Bidi.Script.RemoteValue, + ]): {key: unknown; value: unknown} { + const key = + typeof serializedKey === 'string' + ? serializedKey + : BidiDeserializer.deserialize(serializedKey); + const value = BidiDeserializer.deserialize(serializedValue); + + return {key, value}; + } +} diff --git a/packages/playwright-core/src/server/bidi/third_party/bidiKeyboard.ts b/packages/playwright-core/src/server/bidi/third_party/bidiKeyboard.ts new file mode 100644 index 0000000000..307d83fb87 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/third_party/bidiKeyboard.ts @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Modifications copyright (c) Microsoft Corporation. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable curly */ + +export const getBidiKeyValue = (key: string) => { + switch (key) { + case '\r': + case '\n': + key = 'Enter'; + break; + } + // Measures the number of code points rather than UTF-16 code units. + if ([...key].length === 1) { + return key; + } + switch (key) { + case 'Cancel': + return '\uE001'; + case 'Help': + return '\uE002'; + case 'Backspace': + return '\uE003'; + case 'Tab': + return '\uE004'; + case 'Clear': + return '\uE005'; + case 'Enter': + return '\uE007'; + case 'Shift': + case 'ShiftLeft': + return '\uE008'; + case 'Control': + case 'ControlLeft': + return '\uE009'; + case 'Alt': + case 'AltLeft': + return '\uE00A'; + case 'Pause': + return '\uE00B'; + case 'Escape': + return '\uE00C'; + case 'PageUp': + return '\uE00E'; + case 'PageDown': + return '\uE00F'; + case 'End': + return '\uE010'; + case 'Home': + return '\uE011'; + case 'ArrowLeft': + return '\uE012'; + case 'ArrowUp': + return '\uE013'; + case 'ArrowRight': + return '\uE014'; + case 'ArrowDown': + return '\uE015'; + case 'Insert': + return '\uE016'; + case 'Delete': + return '\uE017'; + case 'NumpadEqual': + return '\uE019'; + case 'Numpad0': + return '\uE01A'; + case 'Numpad1': + return '\uE01B'; + case 'Numpad2': + return '\uE01C'; + case 'Numpad3': + return '\uE01D'; + case 'Numpad4': + return '\uE01E'; + case 'Numpad5': + return '\uE01F'; + case 'Numpad6': + return '\uE020'; + case 'Numpad7': + return '\uE021'; + case 'Numpad8': + return '\uE022'; + case 'Numpad9': + return '\uE023'; + case 'NumpadMultiply': + return '\uE024'; + case 'NumpadAdd': + return '\uE025'; + case 'NumpadSubtract': + return '\uE027'; + case 'NumpadDecimal': + return '\uE028'; + case 'NumpadDivide': + return '\uE029'; + case 'F1': + return '\uE031'; + case 'F2': + return '\uE032'; + case 'F3': + return '\uE033'; + case 'F4': + return '\uE034'; + case 'F5': + return '\uE035'; + case 'F6': + return '\uE036'; + case 'F7': + return '\uE037'; + case 'F8': + return '\uE038'; + case 'F9': + return '\uE039'; + case 'F10': + return '\uE03A'; + case 'F11': + return '\uE03B'; + case 'F12': + return '\uE03C'; + case 'Meta': + case 'MetaLeft': + return '\uE03D'; + case 'ShiftRight': + return '\uE050'; + case 'ControlRight': + return '\uE051'; + case 'AltRight': + return '\uE052'; + case 'MetaRight': + return '\uE053'; + case 'Digit0': + return '0'; + case 'Digit1': + return '1'; + case 'Digit2': + return '2'; + case 'Digit3': + return '3'; + case 'Digit4': + return '4'; + case 'Digit5': + return '5'; + case 'Digit6': + return '6'; + case 'Digit7': + return '7'; + case 'Digit8': + return '8'; + case 'Digit9': + return '9'; + case 'KeyA': + return 'a'; + case 'KeyB': + return 'b'; + case 'KeyC': + return 'c'; + case 'KeyD': + return 'd'; + case 'KeyE': + return 'e'; + case 'KeyF': + return 'f'; + case 'KeyG': + return 'g'; + case 'KeyH': + return 'h'; + case 'KeyI': + return 'i'; + case 'KeyJ': + return 'j'; + case 'KeyK': + return 'k'; + case 'KeyL': + return 'l'; + case 'KeyM': + return 'm'; + case 'KeyN': + return 'n'; + case 'KeyO': + return 'o'; + case 'KeyP': + return 'p'; + case 'KeyQ': + return 'q'; + case 'KeyR': + return 'r'; + case 'KeyS': + return 's'; + case 'KeyT': + return 't'; + case 'KeyU': + return 'u'; + case 'KeyV': + return 'v'; + case 'KeyW': + return 'w'; + case 'KeyX': + return 'x'; + case 'KeyY': + return 'y'; + case 'KeyZ': + return 'z'; + case 'Semicolon': + return ';'; + case 'Equal': + return '='; + case 'Comma': + return ','; + case 'Minus': + return '-'; + case 'Period': + return '.'; + case 'Slash': + return '/'; + case 'Backquote': + return '`'; + case 'BracketLeft': + return '['; + case 'Backslash': + return '\\'; + case 'BracketRight': + return ']'; + case 'Quote': + return '"'; + default: + throw new Error(`Unknown key: "${key}"`); + } +}; diff --git a/packages/playwright-core/src/server/bidi/third_party/bidiProtocol.ts b/packages/playwright-core/src/server/bidi/third_party/bidiProtocol.ts new file mode 100644 index 0000000000..c349e58337 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/third_party/bidiProtocol.ts @@ -0,0 +1,2204 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Modifications copyright (c) Microsoft Corporation. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * THIS FILE IS AUTOGENERATED by cddlconv 0.1.5. + * Run `node tools/generate-bidi-types.mjs` to regenerate. + * @see https://github.com/w3c/webdriver-bidi/blob/master/index.bs + */ + +export type Event = { + type: 'event'; +} & EventData & + Extensible; +export type Command = { + id: JsUint; +} & CommandData & + Extensible; +export type CommandResponse = { + type: 'success'; + id: JsUint; + result: ResultData; +} & Extensible; +export type EventData = + | BrowsingContextEvent + | LogEvent + | NetworkEvent + | ScriptEvent; +export type CommandData = + | BrowserCommand + | BrowsingContextCommand + | InputCommand + | NetworkCommand + | ScriptCommand + | SessionCommand + | StorageCommand; +export type ResultData = + | BrowsingContextResult + | EmptyResult + | NetworkResult + | ScriptResult + | SessionResult + | StorageResult; +export type EmptyParams = Extensible; +export type Message = CommandResponse | ErrorResponse | Event; +export type ErrorResponse = { + type: 'error'; + id: JsUint | null; + error: ErrorCode; + message: string; + stacktrace?: string; +} & Extensible; +export type EmptyResult = Extensible; +export type Extensible = { + [key: string]: any; +}; + +/** + * Must be between `-9007199254740991` and `9007199254740991`, inclusive. + */ +export type JsInt = number; + +/** + * Must be between `0` and `9007199254740991`, inclusive. + */ +export type JsUint = number; +export const enum ErrorCode { + InvalidArgument = 'invalid argument', + InvalidSelector = 'invalid selector', + InvalidSessionId = 'invalid session id', + MoveTargetOutOfBounds = 'move target out of bounds', + NoSuchAlert = 'no such alert', + NoSuchElement = 'no such element', + NoSuchFrame = 'no such frame', + NoSuchHandle = 'no such handle', + NoSuchHistoryEntry = 'no such history entry', + NoSuchIntercept = 'no such intercept', + NoSuchNode = 'no such node', + NoSuchRequest = 'no such request', + NoSuchScript = 'no such script', + NoSuchStoragePartition = 'no such storage partition', + NoSuchUserContext = 'no such user context', + SessionNotCreated = 'session not created', + UnableToCaptureScreen = 'unable to capture screen', + UnableToCloseBrowser = 'unable to close browser', + UnableToSetCookie = 'unable to set cookie', + UnableToSetFileInput = 'unable to set file input', + UnderspecifiedStoragePartition = 'underspecified storage partition', + UnknownCommand = 'unknown command', + UnknownError = 'unknown error', + UnsupportedOperation = 'unsupported operation', +} +export type SessionCommand = + | Session.End + | Session.New + | Session.Status + | Session.Subscribe + | Session.Unsubscribe; +export namespace Session { + export type ProxyConfiguration = + | Session.AutodetectProxyConfiguration + | Session.DirectProxyConfiguration + | Session.ManualProxyConfiguration + | Session.PacProxyConfiguration + | Session.SystemProxyConfiguration + | Record; +} +export type SessionResult = Session.NewResult | Session.StatusResult; +export namespace Session { + export type CapabilitiesRequest = { + alwaysMatch?: Session.CapabilityRequest; + firstMatch?: [...Session.CapabilityRequest[]]; + }; +} +export namespace Session { + export type CapabilityRequest = { + acceptInsecureCerts?: boolean; + browserName?: string; + browserVersion?: string; + platformName?: string; + proxy?: Session.ProxyConfiguration; + unhandledPromptBehavior?: Session.UserPromptHandler; + } & Extensible; +} +export namespace Session { + export type AutodetectProxyConfiguration = { + proxyType: 'autodetect'; + } & Extensible; +} +export namespace Session { + export type DirectProxyConfiguration = { + proxyType: 'direct'; + } & Extensible; +} +export namespace Session { + export type ManualProxyConfiguration = { + proxyType: 'manual'; + ftpProxy?: string; + httpProxy?: string; + sslProxy?: string; + } & ({} | Session.SocksProxyConfiguration) & { + noProxy?: [...string[]]; + } & Extensible; +} +export namespace Session { + export type SocksProxyConfiguration = { + socksProxy: string; + /** + * Must be between `0` and `255`, inclusive. + */ + socksVersion: number; + }; +} +export namespace Session { + export type PacProxyConfiguration = { + proxyType: 'pac'; + proxyAutoconfigUrl: string; + } & Extensible; +} +export namespace Session { + export type SystemProxyConfiguration = { + proxyType: 'system'; + } & Extensible; +} +export namespace Session { + export type UserPromptHandler = { + alert?: Session.UserPromptHandlerType; + beforeUnload?: Session.UserPromptHandlerType; + confirm?: Session.UserPromptHandlerType; + default?: Session.UserPromptHandlerType; + prompt?: Session.UserPromptHandlerType; + }; +} +export namespace Session { + export const enum UserPromptHandlerType { + Accept = 'accept', + Dismiss = 'dismiss', + Ignore = 'ignore', + } +} +export namespace Session { + export type SubscriptionRequest = { + events: [string, ...string[]]; + contexts?: [ + BrowsingContext.BrowsingContext, + ...BrowsingContext.BrowsingContext[], + ]; + }; +} +export namespace Session { + export type Status = { + method: 'session.status'; + params: EmptyParams; + }; +} +export namespace Session { + export type StatusResult = { + ready: boolean; + message: string; + }; +} +export namespace Session { + export type New = { + method: 'session.new'; + params: Session.NewParameters; + }; +} +export namespace Session { + export type NewParameters = { + capabilities: Session.CapabilitiesRequest; + }; +} +export namespace Session { + export type NewResult = { + sessionId: string; + capabilities: { + acceptInsecureCerts: boolean; + browserName: string; + browserVersion: string; + platformName: string; + setWindowRect: boolean; + userAgent: string; + proxy?: Session.ProxyConfiguration; + unhandledPromptBehavior?: Session.UserPromptHandler; + webSocketUrl?: string; + } & Extensible; + }; +} +export namespace Session { + export type End = { + method: 'session.end'; + params: EmptyParams; + }; +} +export namespace Session { + export type Subscribe = { + method: 'session.subscribe'; + params: Session.SubscriptionRequest; + }; +} +export namespace Session { + export type Unsubscribe = { + method: 'session.unsubscribe'; + params: Session.SubscriptionRequest; + }; +} +export type BrowserCommand = + | Browser.Close + | Browser.CreateUserContext + | Browser.GetUserContexts + | Browser.RemoveUserContext; +export type BrowserResult = + | Browser.CreateUserContextResult + | Browser.GetUserContextsResult; +export namespace Browser { + export type UserContext = string; +} +export namespace Browser { + export type UserContextInfo = { + userContext: Browser.UserContext; + }; +} +export namespace Browser { + export type Close = { + method: 'browser.close'; + params: EmptyParams; + }; +} +export namespace Browser { + export type CreateUserContext = { + method: 'browser.createUserContext'; + params: EmptyParams; + }; +} +export namespace Browser { + export type CreateUserContextResult = Browser.UserContextInfo; +} +export namespace Browser { + export type GetUserContexts = { + method: 'browser.getUserContexts'; + params: EmptyParams; + }; +} +export namespace Browser { + export type GetUserContextsResult = { + userContexts: [Browser.UserContextInfo, ...Browser.UserContextInfo[]]; + }; +} +export namespace Browser { + export type RemoveUserContext = { + method: 'browser.removeUserContext'; + params: Browser.RemoveUserContextParameters; + }; +} +export namespace Browser { + export type RemoveUserContextParameters = { + userContext: Browser.UserContext; + }; +} +export type BrowsingContextCommand = + | BrowsingContext.Activate + | BrowsingContext.CaptureScreenshot + | BrowsingContext.Close + | BrowsingContext.Create + | BrowsingContext.GetTree + | BrowsingContext.HandleUserPrompt + | BrowsingContext.LocateNodes + | BrowsingContext.Navigate + | BrowsingContext.Print + | BrowsingContext.Reload + | BrowsingContext.SetViewport + | BrowsingContext.TraverseHistory; +export type BrowsingContextEvent = + | BrowsingContext.ContextCreated + | BrowsingContext.ContextDestroyed + | BrowsingContext.DomContentLoaded + | BrowsingContext.DownloadWillBegin + | BrowsingContext.FragmentNavigated + | BrowsingContext.Load + | BrowsingContext.NavigationAborted + | BrowsingContext.NavigationFailed + | BrowsingContext.NavigationStarted + | BrowsingContext.UserPromptClosed + | BrowsingContext.UserPromptOpened; +export type BrowsingContextResult = + | BrowsingContext.CaptureScreenshotResult + | BrowsingContext.CreateResult + | BrowsingContext.GetTreeResult + | BrowsingContext.LocateNodesResult + | BrowsingContext.NavigateResult + | BrowsingContext.PrintResult + | BrowsingContext.TraverseHistoryResult; +export namespace BrowsingContext { + export type BrowsingContext = string; +} +export namespace BrowsingContext { + export type InfoList = [...BrowsingContext.Info[]]; +} +export namespace BrowsingContext { + export type Info = { + children: BrowsingContext.InfoList | null; + context: BrowsingContext.BrowsingContext; + originalOpener: BrowsingContext.BrowsingContext | null; + url: string; + userContext: Browser.UserContext; + parent?: BrowsingContext.BrowsingContext | null; + }; +} +export namespace BrowsingContext { + export type Locator = + | BrowsingContext.AccessibilityLocator + | BrowsingContext.CssLocator + | BrowsingContext.InnerTextLocator + | BrowsingContext.XPathLocator; +} +export namespace BrowsingContext { + export type AccessibilityLocator = { + type: 'accessibility'; + value: { + name?: string; + role?: string; + }; + }; +} +export namespace BrowsingContext { + export type CssLocator = { + type: 'css'; + value: string; + }; +} +export namespace BrowsingContext { + export type InnerTextLocator = { + type: 'innerText'; + value: string; + ignoreCase?: boolean; + matchType?: 'full' | 'partial'; + maxDepth?: JsUint; + }; +} +export namespace BrowsingContext { + export type XPathLocator = { + type: 'xpath'; + value: string; + }; +} +export namespace BrowsingContext { + export type Navigation = string; +} +export namespace BrowsingContext { + export type NavigationInfo = { + context: BrowsingContext.BrowsingContext; + navigation: BrowsingContext.Navigation | null; + timestamp: JsUint; + url: string; + }; +} +export namespace BrowsingContext { + export const enum ReadinessState { + None = 'none', + Interactive = 'interactive', + Complete = 'complete', + } +} +export namespace BrowsingContext { + export const enum UserPromptType { + Alert = 'alert', + Beforeunload = 'beforeunload', + Confirm = 'confirm', + Prompt = 'prompt', + } +} +export namespace BrowsingContext { + export type Activate = { + method: 'browsingContext.activate'; + params: BrowsingContext.ActivateParameters; + }; +} +export namespace BrowsingContext { + export type ActivateParameters = { + context: BrowsingContext.BrowsingContext; + }; +} +export namespace BrowsingContext { + export type CaptureScreenshotParameters = { + context: BrowsingContext.BrowsingContext; + /** + * @defaultValue `"viewport"` + */ + origin?: 'viewport' | 'document'; + format?: BrowsingContext.ImageFormat; + clip?: BrowsingContext.ClipRectangle; + }; +} +export namespace BrowsingContext { + export type CaptureScreenshot = { + method: 'browsingContext.captureScreenshot'; + params: BrowsingContext.CaptureScreenshotParameters; + }; +} +export namespace BrowsingContext { + export type ImageFormat = { + type: string; + /** + * Must be between `0` and `1`, inclusive. + */ + quality?: number; + }; +} +export namespace BrowsingContext { + export type ClipRectangle = + | BrowsingContext.BoxClipRectangle + | BrowsingContext.ElementClipRectangle; +} +export namespace BrowsingContext { + export type ElementClipRectangle = { + type: 'element'; + element: Script.SharedReference; + }; +} +export namespace BrowsingContext { + export type BoxClipRectangle = { + type: 'box'; + x: number; + y: number; + width: number; + height: number; + }; +} +export namespace BrowsingContext { + export type CaptureScreenshotResult = { + data: string; + }; +} +export namespace BrowsingContext { + export type Close = { + method: 'browsingContext.close'; + params: BrowsingContext.CloseParameters; + }; +} +export namespace BrowsingContext { + export type CloseParameters = { + context: BrowsingContext.BrowsingContext; + /** + * @defaultValue `false` + */ + promptUnload?: boolean; + }; +} +export namespace BrowsingContext { + export type Create = { + method: 'browsingContext.create'; + params: BrowsingContext.CreateParameters; + }; +} +export namespace BrowsingContext { + export const enum CreateType { + Tab = 'tab', + Window = 'window', + } +} +export namespace BrowsingContext { + export type CreateParameters = { + type: BrowsingContext.CreateType; + referenceContext?: BrowsingContext.BrowsingContext; + /** + * @defaultValue `false` + */ + background?: boolean; + userContext?: Browser.UserContext; + }; +} +export namespace BrowsingContext { + export type CreateResult = { + context: BrowsingContext.BrowsingContext; + }; +} +export namespace BrowsingContext { + export type GetTree = { + method: 'browsingContext.getTree'; + params: BrowsingContext.GetTreeParameters; + }; +} +export namespace BrowsingContext { + export type GetTreeParameters = { + maxDepth?: JsUint; + root?: BrowsingContext.BrowsingContext; + }; +} +export namespace BrowsingContext { + export type GetTreeResult = { + contexts: BrowsingContext.InfoList; + }; +} +export namespace BrowsingContext { + export type HandleUserPrompt = { + method: 'browsingContext.handleUserPrompt'; + params: BrowsingContext.HandleUserPromptParameters; + }; +} +export namespace BrowsingContext { + export type HandleUserPromptParameters = { + context: BrowsingContext.BrowsingContext; + accept?: boolean; + userText?: string; + }; +} +export namespace BrowsingContext { + export type LocateNodesParameters = { + context: BrowsingContext.BrowsingContext; + locator: BrowsingContext.Locator; + /** + * Must be greater than or equal to `1`. + */ + maxNodeCount?: JsUint; + serializationOptions?: Script.SerializationOptions; + startNodes?: [Script.SharedReference, ...Script.SharedReference[]]; + }; +} +export namespace BrowsingContext { + export type LocateNodes = { + method: 'browsingContext.locateNodes'; + params: BrowsingContext.LocateNodesParameters; + }; +} +export namespace BrowsingContext { + export type LocateNodesResult = { + nodes: [...Script.NodeRemoteValue[]]; + }; +} +export namespace BrowsingContext { + export type Navigate = { + method: 'browsingContext.navigate'; + params: BrowsingContext.NavigateParameters; + }; +} +export namespace BrowsingContext { + export type NavigateParameters = { + context: BrowsingContext.BrowsingContext; + url: string; + wait?: BrowsingContext.ReadinessState; + }; +} +export namespace BrowsingContext { + export type NavigateResult = { + navigation: BrowsingContext.Navigation | null; + url: string; + }; +} +export namespace BrowsingContext { + export type Print = { + method: 'browsingContext.print'; + params: BrowsingContext.PrintParameters; + }; +} +export namespace BrowsingContext { + export type PrintParameters = { + context: BrowsingContext.BrowsingContext; + /** + * @defaultValue `false` + */ + background?: boolean; + margin?: BrowsingContext.PrintMarginParameters; + /** + * @defaultValue `"portrait"` + */ + orientation?: 'portrait' | 'landscape'; + page?: BrowsingContext.PrintPageParameters; + pageRanges?: [...(JsUint | string)[]]; + /** + * Must be between `0.1` and `2`, inclusive. + * + * @defaultValue `1` + */ + scale?: number; + /** + * @defaultValue `true` + */ + shrinkToFit?: boolean; + }; +} +export namespace BrowsingContext { + export type PrintMarginParameters = { + /** + * Must be greater than or equal to `0`. + * + * @defaultValue `1` + */ + bottom?: number; + /** + * Must be greater than or equal to `0`. + * + * @defaultValue `1` + */ + left?: number; + /** + * Must be greater than or equal to `0`. + * + * @defaultValue `1` + */ + right?: number; + /** + * Must be greater than or equal to `0`. + * + * @defaultValue `1` + */ + top?: number; + }; +} +export namespace BrowsingContext { + export type PrintPageParameters = { + /** + * Must be greater than or equal to `0.0352`. + * + * @defaultValue `27.94` + */ + height?: number; + /** + * Must be greater than or equal to `0.0352`. + * + * @defaultValue `21.59` + */ + width?: number; + }; +} +export namespace BrowsingContext { + export type PrintResult = { + data: string; + }; +} +export namespace BrowsingContext { + export type Reload = { + method: 'browsingContext.reload'; + params: BrowsingContext.ReloadParameters; + }; +} +export namespace BrowsingContext { + export type ReloadParameters = { + context: BrowsingContext.BrowsingContext; + ignoreCache?: boolean; + wait?: BrowsingContext.ReadinessState; + }; +} +export namespace BrowsingContext { + export type SetViewport = { + method: 'browsingContext.setViewport'; + params: BrowsingContext.SetViewportParameters; + }; +} +export namespace BrowsingContext { + export type SetViewportParameters = { + context: BrowsingContext.BrowsingContext; + viewport?: BrowsingContext.Viewport | null; + /** + * Must be greater than `0`. + */ + devicePixelRatio?: number | null; + }; +} +export namespace BrowsingContext { + export type Viewport = { + width: JsUint; + height: JsUint; + }; +} +export namespace BrowsingContext { + export type TraverseHistory = { + method: 'browsingContext.traverseHistory'; + params: BrowsingContext.TraverseHistoryParameters; + }; +} +export namespace BrowsingContext { + export type TraverseHistoryParameters = { + context: BrowsingContext.BrowsingContext; + delta: JsInt; + }; +} +export namespace BrowsingContext { + export type TraverseHistoryResult = Record; +} +export namespace BrowsingContext { + export type ContextCreated = { + method: 'browsingContext.contextCreated'; + params: BrowsingContext.Info; + }; +} +export namespace BrowsingContext { + export type ContextDestroyed = { + method: 'browsingContext.contextDestroyed'; + params: BrowsingContext.Info; + }; +} +export namespace BrowsingContext { + export type NavigationStarted = { + method: 'browsingContext.navigationStarted'; + params: BrowsingContext.NavigationInfo; + }; +} +export namespace BrowsingContext { + export type FragmentNavigated = { + method: 'browsingContext.fragmentNavigated'; + params: BrowsingContext.NavigationInfo; + }; +} +export namespace BrowsingContext { + export type DomContentLoaded = { + method: 'browsingContext.domContentLoaded'; + params: BrowsingContext.NavigationInfo; + }; +} +export namespace BrowsingContext { + export type Load = { + method: 'browsingContext.load'; + params: BrowsingContext.NavigationInfo; + }; +} +export namespace BrowsingContext { + export type DownloadWillBegin = { + method: 'browsingContext.downloadWillBegin'; + params: BrowsingContext.NavigationInfo; + }; +} +export namespace BrowsingContext { + export type NavigationAborted = { + method: 'browsingContext.navigationAborted'; + params: BrowsingContext.NavigationInfo; + }; +} +export namespace BrowsingContext { + export type NavigationFailed = { + method: 'browsingContext.navigationFailed'; + params: BrowsingContext.NavigationInfo; + }; +} +export namespace BrowsingContext { + export type UserPromptClosed = { + method: 'browsingContext.userPromptClosed'; + params: BrowsingContext.UserPromptClosedParameters; + }; +} +export namespace BrowsingContext { + export type UserPromptClosedParameters = { + context: BrowsingContext.BrowsingContext; + accepted: boolean; + type: BrowsingContext.UserPromptType; + userText?: string; + }; +} +export namespace BrowsingContext { + export type UserPromptOpened = { + method: 'browsingContext.userPromptOpened'; + params: BrowsingContext.UserPromptOpenedParameters; + }; +} +export namespace BrowsingContext { + export type UserPromptOpenedParameters = { + context: BrowsingContext.BrowsingContext; + handler: Session.UserPromptHandlerType; + message: string; + type: BrowsingContext.UserPromptType; + defaultValue?: string; + }; +} +export type NetworkCommand = + | Network.AddIntercept + | Network.ContinueRequest + | Network.ContinueResponse + | Network.ContinueWithAuth + | Network.FailRequest + | Network.ProvideResponse + | Network.RemoveIntercept + | Network.SetCacheBehavior; +export type NetworkEvent = + | Network.AuthRequired + | Network.BeforeRequestSent + | Network.FetchError + | Network.ResponseCompleted + | Network.ResponseStarted; +export type NetworkResult = Network.AddInterceptResult; +export namespace Network { + export type AuthChallenge = { + scheme: string; + realm: string; + }; +} +export namespace Network { + export type AuthCredentials = { + type: 'password'; + username: string; + password: string; + }; +} +export namespace Network { + export type BaseParameters = { + context: BrowsingContext.BrowsingContext | null; + isBlocked: boolean; + navigation: BrowsingContext.Navigation | null; + redirectCount: JsUint; + request: Network.RequestData; + timestamp: JsUint; + intercepts?: [Network.Intercept, ...Network.Intercept[]]; + }; +} +export namespace Network { + export type BytesValue = Network.StringValue | Network.Base64Value; +} +export namespace Network { + export type StringValue = { + type: 'string'; + value: string; + }; +} +export namespace Network { + export type Base64Value = { + type: 'base64'; + value: string; + }; +} +export namespace Network { + export const enum SameSite { + Strict = 'strict', + Lax = 'lax', + None = 'none', + } +} +export namespace Network { + export type Cookie = { + name: string; + value: Network.BytesValue; + domain: string; + path: string; + size: JsUint; + httpOnly: boolean; + secure: boolean; + sameSite: Network.SameSite; + expiry?: JsUint; + } & Extensible; +} +export namespace Network { + export type CookieHeader = { + name: string; + value: Network.BytesValue; + }; +} +export namespace Network { + export type FetchTimingInfo = { + timeOrigin: number; + requestTime: number; + redirectStart: number; + redirectEnd: number; + fetchStart: number; + dnsStart: number; + dnsEnd: number; + connectStart: number; + connectEnd: number; + tlsStart: number; + requestStart: number; + responseStart: number; + responseEnd: number; + }; +} +export namespace Network { + export type Header = { + name: string; + value: Network.BytesValue; + }; +} +export namespace Network { + export type Initiator = { + type: 'parser' | 'script' | 'preflight' | 'other'; + columnNumber?: JsUint; + lineNumber?: JsUint; + stackTrace?: Script.StackTrace; + request?: Network.Request; + }; +} +export namespace Network { + export type Intercept = string; +} +export namespace Network { + export type Request = string; +} +export namespace Network { + export type RequestData = { + request: Network.Request; + url: string; + method: string; + headers: [...Network.Header[]]; + cookies: [...Network.Cookie[]]; + headersSize: JsUint; + bodySize: JsUint | null; + timings: Network.FetchTimingInfo; + }; +} +export namespace Network { + export type ResponseContent = { + size: JsUint; + }; +} +export namespace Network { + export type ResponseData = { + url: string; + protocol: string; + status: JsUint; + statusText: string; + fromCache: boolean; + headers: [...Network.Header[]]; + mimeType: string; + bytesReceived: JsUint; + headersSize: JsUint | null; + bodySize: JsUint | null; + content: Network.ResponseContent; + authChallenges?: [...Network.AuthChallenge[]]; + }; +} +export namespace Network { + export type SetCookieHeader = { + name: string; + value: Network.BytesValue; + domain?: string; + httpOnly?: boolean; + expiry?: string; + maxAge?: JsInt; + path?: string; + sameSite?: Network.SameSite; + secure?: boolean; + }; +} +export namespace Network { + export type UrlPattern = Network.UrlPatternPattern | Network.UrlPatternString; +} +export namespace Network { + export type UrlPatternPattern = { + type: 'pattern'; + protocol?: string; + hostname?: string; + port?: string; + pathname?: string; + search?: string; + }; +} +export namespace Network { + export type UrlPatternString = { + type: 'string'; + pattern: string; + }; +} +export namespace Network { + export type AddInterceptParameters = { + phases: [Network.InterceptPhase, ...Network.InterceptPhase[]]; + contexts?: [ + BrowsingContext.BrowsingContext, + ...BrowsingContext.BrowsingContext[], + ]; + urlPatterns?: [...Network.UrlPattern[]]; + }; +} +export namespace Network { + export type AddIntercept = { + method: 'network.addIntercept'; + params: Network.AddInterceptParameters; + }; +} +export namespace Network { + export const enum InterceptPhase { + BeforeRequestSent = 'beforeRequestSent', + ResponseStarted = 'responseStarted', + AuthRequired = 'authRequired', + } +} +export namespace Network { + export type AddInterceptResult = { + intercept: Network.Intercept; + }; +} +export namespace Network { + export type ContinueRequest = { + method: 'network.continueRequest'; + params: Network.ContinueRequestParameters; + }; +} +export namespace Network { + export type ContinueRequestParameters = { + request: Network.Request; + body?: Network.BytesValue; + cookies?: [...Network.CookieHeader[]]; + headers?: [...Network.Header[]]; + method?: string; + url?: string; + }; +} +export namespace Network { + export type ContinueResponse = { + method: 'network.continueResponse'; + params: Network.ContinueResponseParameters; + }; +} +export namespace Network { + export type ContinueResponseParameters = { + request: Network.Request; + cookies?: [...Network.SetCookieHeader[]]; + credentials?: Network.AuthCredentials; + headers?: [...Network.Header[]]; + reasonPhrase?: string; + statusCode?: JsUint; + }; +} +export namespace Network { + export type ContinueWithAuth = { + method: 'network.continueWithAuth'; + params: Network.ContinueWithAuthParameters; + }; +} +export namespace Network { + export type ContinueWithAuthParameters = { + request: Network.Request; + } & ( + | Network.ContinueWithAuthCredentials + | Network.ContinueWithAuthNoCredentials + ); +} +export namespace Network { + export type ContinueWithAuthCredentials = { + action: 'provideCredentials'; + credentials: Network.AuthCredentials; + }; +} +export namespace Network { + export type ContinueWithAuthNoCredentials = { + action: 'default' | 'cancel'; + }; +} +export namespace Network { + export type FailRequest = { + method: 'network.failRequest'; + params: Network.FailRequestParameters; + }; +} +export namespace Network { + export type FailRequestParameters = { + request: Network.Request; + }; +} +export namespace Network { + export type ProvideResponse = { + method: 'network.provideResponse'; + params: Network.ProvideResponseParameters; + }; +} +export namespace Network { + export type ProvideResponseParameters = { + request: Network.Request; + body?: Network.BytesValue; + cookies?: [...Network.SetCookieHeader[]]; + headers?: [...Network.Header[]]; + reasonPhrase?: string; + statusCode?: JsUint; + }; +} +export namespace Network { + export type RemoveIntercept = { + method: 'network.removeIntercept'; + params: Network.RemoveInterceptParameters; + }; +} +export namespace Network { + export type RemoveInterceptParameters = { + intercept: Network.Intercept; + }; +} +export namespace Network { + export type SetCacheBehavior = { + method: 'network.setCacheBehavior'; + params: Network.SetCacheBehaviorParameters; + }; +} +export namespace Network { + export type SetCacheBehaviorParameters = { + cacheBehavior: 'default' | 'bypass'; + contexts?: [ + BrowsingContext.BrowsingContext, + ...BrowsingContext.BrowsingContext[], + ]; + }; +} +export type ScriptEvent = + | Script.Message + | Script.RealmCreated + | Script.RealmDestroyed; +export namespace Network { + export type AuthRequiredParameters = Network.BaseParameters & { + response: Network.ResponseData; + }; +} +export namespace Network { + export type BeforeRequestSentParameters = Network.BaseParameters & { + initiator: Network.Initiator; + }; +} +export namespace Network { + export type FetchErrorParameters = Network.BaseParameters & { + errorText: string; + }; +} +export namespace Network { + export type ResponseCompletedParameters = Network.BaseParameters & { + response: Network.ResponseData; + }; +} +export namespace Network { + export type ResponseStartedParameters = Network.BaseParameters & { + response: Network.ResponseData; + }; +} +export type ScriptCommand = + | Script.AddPreloadScript + | Script.CallFunction + | Script.Disown + | Script.Evaluate + | Script.GetRealms + | Script.RemovePreloadScript; +export type ScriptResult = + | Script.AddPreloadScriptResult + | Script.EvaluateResult + | Script.GetRealmsResult; +export namespace Network { + export type AuthRequired = { + method: 'network.authRequired'; + params: Network.AuthRequiredParameters; + }; +} +export namespace Network { + export type BeforeRequestSent = { + method: 'network.beforeRequestSent'; + params: Network.BeforeRequestSentParameters; + }; +} +export namespace Network { + export type FetchError = { + method: 'network.fetchError'; + params: Network.FetchErrorParameters; + }; +} +export namespace Network { + export type ResponseCompleted = { + method: 'network.responseCompleted'; + params: Network.ResponseCompletedParameters; + }; +} +export namespace Network { + export type ResponseStarted = { + method: 'network.responseStarted'; + params: Network.ResponseStartedParameters; + }; +} +export namespace Script { + export type Channel = string; +} +export namespace Script { + export type EvaluateResultSuccess = { + type: 'success'; + result: Script.RemoteValue; + realm: Script.Realm; + }; +} +export namespace Script { + export type ExceptionDetails = { + columnNumber: JsUint; + exception: Script.RemoteValue; + lineNumber: JsUint; + stackTrace: Script.StackTrace; + text: string; + }; +} +export namespace Script { + export type ChannelValue = { + type: 'channel'; + value: Script.ChannelProperties; + }; +} +export namespace Script { + export type ChannelProperties = { + channel: Script.Channel; + serializationOptions?: Script.SerializationOptions; + ownership?: Script.ResultOwnership; + }; +} +export namespace Script { + export type EvaluateResult = + | Script.EvaluateResultSuccess + | Script.EvaluateResultException; +} +export namespace Script { + export type EvaluateResultException = { + type: 'exception'; + exceptionDetails: Script.ExceptionDetails; + realm: Script.Realm; + }; +} +export namespace Script { + export type Handle = string; +} +export namespace Script { + export type InternalId = string; +} +export namespace Script { + export type ListLocalValue = [...Script.LocalValue[]]; +} +export namespace Script { + export type LocalValue = + | Script.RemoteReference + | Script.PrimitiveProtocolValue + | Script.ChannelValue + | Script.ArrayLocalValue + | Script.DateLocalValue + | Script.MapLocalValue + | Script.ObjectLocalValue + | Script.RegExpLocalValue + | Script.SetLocalValue; +} +export namespace Script { + export type ArrayLocalValue = { + type: 'array'; + value: Script.ListLocalValue; + }; +} +export namespace Script { + export type DateLocalValue = { + type: 'date'; + value: string; + }; +} +export namespace Script { + export type MappingLocalValue = [ + ...[Script.LocalValue | string, Script.LocalValue][], + ]; +} +export namespace Script { + export type MapLocalValue = { + type: 'map'; + value: Script.MappingLocalValue; + }; +} +export namespace Script { + export type ObjectLocalValue = { + type: 'object'; + value: Script.MappingLocalValue; + }; +} +export namespace Script { + export type RegExpValue = { + pattern: string; + flags?: string; + }; +} +export namespace Script { + export type RegExpLocalValue = { + type: 'regexp'; + value: Script.RegExpValue; + }; +} +export namespace Script { + export type SetLocalValue = { + type: 'set'; + value: Script.ListLocalValue; + }; +} +export namespace Script { + export type PreloadScript = string; +} +export namespace Script { + export type Realm = string; +} +export namespace Script { + export type PrimitiveProtocolValue = + | Script.UndefinedValue + | Script.NullValue + | Script.StringValue + | Script.NumberValue + | Script.BooleanValue + | Script.BigIntValue; +} +export namespace Script { + export type UndefinedValue = { + type: 'undefined'; + }; +} +export namespace Script { + export type NullValue = { + type: 'null'; + }; +} +export namespace Script { + export type StringValue = { + type: 'string'; + value: string; + }; +} +export namespace Script { + export type SpecialNumber = 'NaN' | '-0' | 'Infinity' | '-Infinity'; +} +export namespace Script { + export type NumberValue = { + type: 'number'; + value: number | Script.SpecialNumber; + }; +} +export namespace Script { + export type BooleanValue = { + type: 'boolean'; + value: boolean; + }; +} +export namespace Script { + export type BigIntValue = { + type: 'bigint'; + value: string; + }; +} +export namespace Script { + export type RealmInfo = + | Script.WindowRealmInfo + | Script.DedicatedWorkerRealmInfo + | Script.SharedWorkerRealmInfo + | Script.ServiceWorkerRealmInfo + | Script.WorkerRealmInfo + | Script.PaintWorkletRealmInfo + | Script.AudioWorkletRealmInfo + | Script.WorkletRealmInfo; +} +export namespace Script { + export type BaseRealmInfo = { + realm: Script.Realm; + origin: string; + }; +} +export namespace Script { + export type WindowRealmInfo = Script.BaseRealmInfo & { + type: 'window'; + context: BrowsingContext.BrowsingContext; + sandbox?: string; + }; +} +export namespace Script { + export type DedicatedWorkerRealmInfo = Script.BaseRealmInfo & { + type: 'dedicated-worker'; + owners: [Script.Realm]; + }; +} +export namespace Script { + export type SharedWorkerRealmInfo = Script.BaseRealmInfo & { + type: 'shared-worker'; + }; +} +export namespace Script { + export type ServiceWorkerRealmInfo = Script.BaseRealmInfo & { + type: 'service-worker'; + }; +} +export namespace Script { + export type WorkerRealmInfo = Script.BaseRealmInfo & { + type: 'worker'; + }; +} +export namespace Script { + export type PaintWorkletRealmInfo = Script.BaseRealmInfo & { + type: 'paint-worklet'; + }; +} +export namespace Script { + export type AudioWorkletRealmInfo = Script.BaseRealmInfo & { + type: 'audio-worklet'; + }; +} +export namespace Script { + export type WorkletRealmInfo = Script.BaseRealmInfo & { + type: 'worklet'; + }; +} +export namespace Script { + export type RealmType = + | 'window' + | 'dedicated-worker' + | 'shared-worker' + | 'service-worker' + | 'worker' + | 'paint-worklet' + | 'audio-worklet' + | 'worklet'; +} +export namespace Script { + export type ListRemoteValue = [...Script.RemoteValue[]]; +} +export namespace Script { + export type MappingRemoteValue = [ + ...[Script.RemoteValue | string, Script.RemoteValue][], + ]; +} +export namespace Script { + export type RemoteValue = + | Script.PrimitiveProtocolValue + | Script.SymbolRemoteValue + | Script.ArrayRemoteValue + | Script.ObjectRemoteValue + | Script.FunctionRemoteValue + | Script.RegExpRemoteValue + | Script.DateRemoteValue + | Script.MapRemoteValue + | Script.SetRemoteValue + | Script.WeakMapRemoteValue + | Script.WeakSetRemoteValue + | Script.GeneratorRemoteValue + | Script.ErrorRemoteValue + | Script.ProxyRemoteValue + | Script.PromiseRemoteValue + | Script.TypedArrayRemoteValue + | Script.ArrayBufferRemoteValue + | Script.NodeListRemoteValue + | Script.HtmlCollectionRemoteValue + | Script.NodeRemoteValue + | Script.WindowProxyRemoteValue; +} +export namespace Script { + export type RemoteReference = + | Script.SharedReference + | Script.RemoteObjectReference; +} +export namespace Script { + export type SharedReference = { + sharedId: Script.SharedId; + handle?: Script.Handle; + } & Extensible; +} +export namespace Script { + export type RemoteObjectReference = { + handle: Script.Handle; + sharedId?: Script.SharedId; + } & Extensible; +} +export namespace Script { + export type SymbolRemoteValue = { + type: 'symbol'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type ArrayRemoteValue = { + type: 'array'; + handle?: Script.Handle; + internalId?: Script.InternalId; + value?: Script.ListRemoteValue; + }; +} +export namespace Script { + export type ObjectRemoteValue = { + type: 'object'; + handle?: Script.Handle; + internalId?: Script.InternalId; + value?: Script.MappingRemoteValue; + }; +} +export namespace Script { + export type FunctionRemoteValue = { + type: 'function'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type RegExpRemoteValue = { + handle?: Script.Handle; + internalId?: Script.InternalId; + } & Script.RegExpLocalValue; +} +export namespace Script { + export type DateRemoteValue = { + handle?: Script.Handle; + internalId?: Script.InternalId; + } & Script.DateLocalValue; +} +export namespace Script { + export type MapRemoteValue = { + type: 'map'; + handle?: Script.Handle; + internalId?: Script.InternalId; + value?: Script.MappingRemoteValue; + }; +} +export namespace Script { + export type SetRemoteValue = { + type: 'set'; + handle?: Script.Handle; + internalId?: Script.InternalId; + value?: Script.ListRemoteValue; + }; +} +export namespace Script { + export type WeakMapRemoteValue = { + type: 'weakmap'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type WeakSetRemoteValue = { + type: 'weakset'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type GeneratorRemoteValue = { + type: 'generator'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type ErrorRemoteValue = { + type: 'error'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type ProxyRemoteValue = { + type: 'proxy'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type PromiseRemoteValue = { + type: 'promise'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type TypedArrayRemoteValue = { + type: 'typedarray'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type ArrayBufferRemoteValue = { + type: 'arraybuffer'; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type NodeListRemoteValue = { + type: 'nodelist'; + handle?: Script.Handle; + internalId?: Script.InternalId; + value?: Script.ListRemoteValue; + }; +} +export namespace Script { + export type HtmlCollectionRemoteValue = { + type: 'htmlcollection'; + handle?: Script.Handle; + internalId?: Script.InternalId; + value?: Script.ListRemoteValue; + }; +} +export namespace Script { + export type NodeRemoteValue = { + type: 'node'; + sharedId?: Script.SharedId; + handle?: Script.Handle; + internalId?: Script.InternalId; + value?: Script.NodeProperties; + }; +} +export namespace Script { + export type NodeProperties = { + nodeType: JsUint; + childNodeCount: JsUint; + attributes?: { + [key: string]: string; + }; + children?: [...Script.NodeRemoteValue[]]; + localName?: string; + mode?: 'open' | 'closed'; + namespaceURI?: string; + nodeValue?: string; + shadowRoot?: Script.NodeRemoteValue | null; + }; +} +export namespace Script { + export type WindowProxyRemoteValue = { + type: 'window'; + value: Script.WindowProxyProperties; + handle?: Script.Handle; + internalId?: Script.InternalId; + }; +} +export namespace Script { + export type WindowProxyProperties = { + context: BrowsingContext.BrowsingContext; + }; +} +export namespace Script { + export const enum ResultOwnership { + Root = 'root', + None = 'none', + } +} +export namespace Script { + export type SerializationOptions = { + /** + * @defaultValue `0` + */ + maxDomDepth?: JsUint | null; + /** + * @defaultValue `null` + */ + maxObjectDepth?: JsUint | null; + /** + * @defaultValue `"none"` + */ + includeShadowTree?: 'none' | 'open' | 'all'; + }; +} +export namespace Script { + export type SharedId = string; +} +export namespace Script { + export type StackFrame = { + columnNumber: JsUint; + functionName: string; + lineNumber: JsUint; + url: string; + }; +} +export namespace Script { + export type StackTrace = { + callFrames: [...Script.StackFrame[]]; + }; +} +export namespace Script { + export type Source = { + realm: Script.Realm; + context?: BrowsingContext.BrowsingContext; + }; +} +export namespace Script { + export type RealmTarget = { + realm: Script.Realm; + }; +} +export namespace Script { + export type ContextTarget = { + context: BrowsingContext.BrowsingContext; + sandbox?: string; + }; +} +export namespace Script { + export type Target = Script.ContextTarget | Script.RealmTarget; +} +export namespace Script { + export type AddPreloadScript = { + method: 'script.addPreloadScript'; + params: Script.AddPreloadScriptParameters; + }; +} +export namespace Script { + export type AddPreloadScriptParameters = { + functionDeclaration: string; + arguments?: [...Script.ChannelValue[]]; + contexts?: [ + BrowsingContext.BrowsingContext, + ...BrowsingContext.BrowsingContext[], + ]; + sandbox?: string; + }; +} +export namespace Script { + export type AddPreloadScriptResult = { + script: Script.PreloadScript; + }; +} +export namespace Script { + export type Disown = { + method: 'script.disown'; + params: Script.DisownParameters; + }; +} +export namespace Script { + export type DisownParameters = { + handles: [...Script.Handle[]]; + target: Script.Target; + }; +} +export namespace Script { + export type CallFunctionParameters = { + functionDeclaration: string; + awaitPromise: boolean; + target: Script.Target; + arguments?: [...Script.LocalValue[]]; + resultOwnership?: Script.ResultOwnership; + serializationOptions?: Script.SerializationOptions; + this?: Script.LocalValue; + /** + * @defaultValue `false` + */ + userActivation?: boolean; + }; +} +export namespace Script { + export type CallFunction = { + method: 'script.callFunction'; + params: Script.CallFunctionParameters; + }; +} +export namespace Script { + export type Evaluate = { + method: 'script.evaluate'; + params: Script.EvaluateParameters; + }; +} +export namespace Script { + export type EvaluateParameters = { + expression: string; + target: Script.Target; + awaitPromise: boolean; + resultOwnership?: Script.ResultOwnership; + serializationOptions?: Script.SerializationOptions; + /** + * @defaultValue `false` + */ + userActivation?: boolean; + }; +} +export namespace Script { + export type GetRealms = { + method: 'script.getRealms'; + params: Script.GetRealmsParameters; + }; +} +export namespace Script { + export type GetRealmsParameters = { + context?: BrowsingContext.BrowsingContext; + type?: Script.RealmType; + }; +} +export namespace Script { + export type GetRealmsResult = { + realms: [...Script.RealmInfo[]]; + }; +} +export namespace Script { + export type RemovePreloadScript = { + method: 'script.removePreloadScript'; + params: Script.RemovePreloadScriptParameters; + }; +} +export namespace Script { + export type RemovePreloadScriptParameters = { + script: Script.PreloadScript; + }; +} +export namespace Script { + export type MessageParameters = { + channel: Script.Channel; + data: Script.RemoteValue; + source: Script.Source; + }; +} +export namespace Script { + export type RealmCreated = { + method: 'script.realmCreated'; + params: Script.RealmInfo; + }; +} +export namespace Script { + export type Message = { + method: 'script.message'; + params: Script.MessageParameters; + }; +} +export namespace Script { + export type RealmDestroyed = { + method: 'script.realmDestroyed'; + params: Script.RealmDestroyedParameters; + }; +} +export namespace Script { + export type RealmDestroyedParameters = { + realm: Script.Realm; + }; +} +export type StorageCommand = + | Storage.DeleteCookies + | Storage.GetCookies + | Storage.SetCookie; +export type StorageResult = + | Storage.DeleteCookiesResult + | Storage.GetCookiesResult + | Storage.SetCookieResult; +export namespace Storage { + export type PartitionKey = { + userContext?: string; + sourceOrigin?: string; + } & Extensible; +} +export namespace Storage { + export type GetCookies = { + method: 'storage.getCookies'; + params: Storage.GetCookiesParameters; + }; +} +export namespace Storage { + export type CookieFilter = { + name?: string; + value?: Network.BytesValue; + domain?: string; + path?: string; + size?: JsUint; + httpOnly?: boolean; + secure?: boolean; + sameSite?: Network.SameSite; + expiry?: JsUint; + } & Extensible; +} +export namespace Storage { + export type BrowsingContextPartitionDescriptor = { + type: 'context'; + context: BrowsingContext.BrowsingContext; + }; +} +export namespace Storage { + export type StorageKeyPartitionDescriptor = { + type: 'storageKey'; + userContext?: string; + sourceOrigin?: string; + } & Extensible; +} +export namespace Storage { + export type PartitionDescriptor = + | Storage.BrowsingContextPartitionDescriptor + | Storage.StorageKeyPartitionDescriptor; +} +export namespace Storage { + export type GetCookiesParameters = { + filter?: Storage.CookieFilter; + partition?: Storage.PartitionDescriptor; + }; +} +export namespace Storage { + export type GetCookiesResult = { + cookies: [...Network.Cookie[]]; + partitionKey: Storage.PartitionKey; + }; +} +export namespace Storage { + export type SetCookie = { + method: 'storage.setCookie'; + params: Storage.SetCookieParameters; + }; +} +export namespace Storage { + export type PartialCookie = { + name: string; + value: Network.BytesValue; + domain: string; + path?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: Network.SameSite; + expiry?: JsUint; + } & Extensible; +} +export namespace Storage { + export type SetCookieParameters = { + cookie: Storage.PartialCookie; + partition?: Storage.PartitionDescriptor; + }; +} +export namespace Storage { + export type SetCookieResult = { + partitionKey: Storage.PartitionKey; + }; +} +export namespace Storage { + export type DeleteCookies = { + method: 'storage.deleteCookies'; + params: Storage.DeleteCookiesParameters; + }; +} +export namespace Storage { + export type DeleteCookiesParameters = { + filter?: Storage.CookieFilter; + partition?: Storage.PartitionDescriptor; + }; +} +export namespace Storage { + export type DeleteCookiesResult = { + partitionKey: Storage.PartitionKey; + }; +} +export type LogEvent = Log.EntryAdded; +export namespace Log { + export const enum Level { + Debug = 'debug', + Info = 'info', + Warn = 'warn', + Error = 'error', + } +} +export namespace Log { + export type Entry = + | Log.GenericLogEntry + | Log.ConsoleLogEntry + | Log.JavascriptLogEntry; +} +export namespace Log { + export type BaseLogEntry = { + level: Log.Level; + source: Script.Source; + text: string | null; + timestamp: JsUint; + stackTrace?: Script.StackTrace; + }; +} +export namespace Log { + export type GenericLogEntry = Log.BaseLogEntry & { + type: string; + }; +} +export namespace Log { + export type ConsoleLogEntry = Log.BaseLogEntry & { + type: 'console'; + method: string; + args: [...Script.RemoteValue[]]; + }; +} +export namespace Log { + export type JavascriptLogEntry = Log.BaseLogEntry & { + type: 'javascript'; + }; +} +export namespace Log { + export type EntryAdded = { + method: 'log.entryAdded'; + params: Log.Entry; + }; +} +export type InputCommand = + | Input.PerformActions + | Input.ReleaseActions + | Input.SetFiles; +export namespace Input { + export type ElementOrigin = { + type: 'element'; + element: Script.SharedReference; + }; +} +export namespace Input { + export type PerformActionsParameters = { + context: BrowsingContext.BrowsingContext; + actions: [...Input.SourceActions[]]; + }; +} +export namespace Input { + export type NoneSourceActions = { + type: 'none'; + id: string; + actions: [...Input.NoneSourceAction[]]; + }; +} +export namespace Input { + export type KeySourceActions = { + type: 'key'; + id: string; + actions: [...Input.KeySourceAction[]]; + }; +} +export namespace Input { + export type PointerSourceActions = { + type: 'pointer'; + id: string; + parameters?: Input.PointerParameters; + actions: [...Input.PointerSourceAction[]]; + }; +} +export namespace Input { + export type PerformActions = { + method: 'input.performActions'; + params: Input.PerformActionsParameters; + }; +} +export namespace Input { + export type SourceActions = + | Input.NoneSourceActions + | Input.KeySourceActions + | Input.PointerSourceActions + | Input.WheelSourceActions; +} +export namespace Input { + export type NoneSourceAction = Input.PauseAction; +} +export namespace Input { + export type KeySourceAction = + | Input.PauseAction + | Input.KeyDownAction + | Input.KeyUpAction; +} +export namespace Input { + export const enum PointerType { + Mouse = 'mouse', + Pen = 'pen', + Touch = 'touch', + } +} +export namespace Input { + export type PointerParameters = { + /** + * @defaultValue `"mouse"` + */ + pointerType?: Input.PointerType; + }; +} +export namespace Input { + export type WheelSourceActions = { + type: 'wheel'; + id: string; + actions: [...Input.WheelSourceAction[]]; + }; +} +export namespace Input { + export type PointerSourceAction = + | Input.PauseAction + | Input.PointerDownAction + | Input.PointerUpAction + | Input.PointerMoveAction; +} +export namespace Input { + export type WheelSourceAction = Input.PauseAction | Input.WheelScrollAction; +} +export namespace Input { + export type PauseAction = { + type: 'pause'; + duration?: JsUint; + }; +} +export namespace Input { + export type KeyDownAction = { + type: 'keyDown'; + value: string; + }; +} +export namespace Input { + export type KeyUpAction = { + type: 'keyUp'; + value: string; + }; +} +export namespace Input { + export type PointerUpAction = { + type: 'pointerUp'; + button: JsUint; + }; +} +export namespace Input { + export type PointerDownAction = { + type: 'pointerDown'; + button: JsUint; + } & Input.PointerCommonProperties; +} +export namespace Input { + export type PointerMoveAction = { + type: 'pointerMove'; + x: JsInt; + y: JsInt; + duration?: JsUint; + origin?: Input.Origin; + } & Input.PointerCommonProperties; +} +export namespace Input { + export type WheelScrollAction = { + type: 'scroll'; + x: JsInt; + y: JsInt; + deltaX: JsInt; + deltaY: JsInt; + duration?: JsUint; + /** + * @defaultValue `"viewport"` + */ + origin?: Input.Origin; + }; +} +export namespace Input { + export type PointerCommonProperties = { + /** + * @defaultValue `1` + */ + width?: JsUint; + /** + * @defaultValue `1` + */ + height?: JsUint; + /** + * @defaultValue `0` + */ + pressure?: number; + /** + * @defaultValue `0` + */ + tangentialPressure?: number; + /** + * Must be between `0` and `359`, inclusive. + * + * @defaultValue `0` + */ + twist?: number; + /** + * Must be between `0` and `1.5707963267948966`, inclusive. + * + * @defaultValue `0` + */ + altitudeAngle?: number; + /** + * Must be between `0` and `6.283185307179586`, inclusive. + * + * @defaultValue `0` + */ + azimuthAngle?: number; + }; +} +export namespace Input { + export type Origin = 'viewport' | 'pointer' | Input.ElementOrigin; +} +export namespace Input { + export type ReleaseActions = { + method: 'input.releaseActions'; + params: Input.ReleaseActionsParameters; + }; +} +export namespace Input { + export type ReleaseActionsParameters = { + context: BrowsingContext.BrowsingContext; + }; +} +export namespace Input { + export type SetFiles = { + method: 'input.setFiles'; + params: Input.SetFilesParameters; + }; +} +export namespace Input { + export type SetFilesParameters = { + context: BrowsingContext.BrowsingContext; + element: Script.SharedReference; + files: [...string[]]; + }; +} diff --git a/packages/playwright-core/src/server/bidi/third_party/bidiSerializer.ts b/packages/playwright-core/src/server/bidi/third_party/bidiSerializer.ts new file mode 100644 index 0000000000..97e8381328 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/third_party/bidiSerializer.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Modifications copyright (c) Microsoft Corporation. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from './bidiProtocol'; + +/* eslint-disable curly, indent */ + +/** + * @internal + */ +class UnserializableError extends Error {} + +/** + * @internal + */ +export class BidiSerializer { + static serialize(arg: unknown): Bidi.Script.LocalValue { + switch (typeof arg) { + case 'symbol': + case 'function': + throw new UnserializableError(`Unable to serializable ${typeof arg}`); + case 'object': + return BidiSerializer._serializeObject(arg); + + case 'undefined': + return { + type: 'undefined', + }; + case 'number': + return BidiSerializer._serializeNumber(arg); + case 'bigint': + return { + type: 'bigint', + value: arg.toString(), + }; + case 'string': + return { + type: 'string', + value: arg, + }; + case 'boolean': + return { + type: 'boolean', + value: arg, + }; + } + } + + static _serializeNumber(arg: number): Bidi.Script.LocalValue { + let value: Bidi.Script.SpecialNumber | number; + if (Object.is(arg, -0)) { + value = '-0'; + } else if (Object.is(arg, Infinity)) { + value = 'Infinity'; + } else if (Object.is(arg, -Infinity)) { + value = '-Infinity'; + } else if (Object.is(arg, NaN)) { + value = 'NaN'; + } else { + value = arg; + } + return { + type: 'number', + value, + }; + } + + static _serializeObject(arg: object | null): Bidi.Script.LocalValue { + if (arg === null) { + return { + type: 'null', + }; + } else if (Array.isArray(arg)) { + const parsedArray = arg.map(subArg => { + return BidiSerializer.serialize(subArg); + }); + + return { + type: 'array', + value: parsedArray, + }; + } else if (isPlainObject(arg)) { + try { + JSON.stringify(arg); + } catch (error) { + if ( + error instanceof TypeError && + error.message.startsWith('Converting circular structure to JSON') + ) { + error.message += ' Recursive objects are not allowed.'; + } + throw error; + } + + const parsedObject: Bidi.Script.MappingLocalValue = []; + for (const key in arg) { + parsedObject.push([BidiSerializer.serialize(key), BidiSerializer.serialize(arg[key])]); + } + + return { + type: 'object', + value: parsedObject, + }; + } else if (isRegExp(arg)) { + return { + type: 'regexp', + value: { + pattern: arg.source, + flags: arg.flags, + }, + }; + } else if (isDate(arg)) { + return { + type: 'date', + value: arg.toISOString(), + }; + } + + throw new UnserializableError( + 'Custom object serialization not possible. Use plain objects instead.' + ); + } +} + +/** + * @internal + */ +export const isPlainObject = (obj: unknown): obj is Record => { + return typeof obj === 'object' && obj?.constructor === Object; +}; + +/** + * @internal + */ +export const isRegExp = (obj: unknown): obj is RegExp => { + return typeof obj === 'object' && obj?.constructor === RegExp; +}; + +/** + * @internal + */ +export const isDate = (obj: unknown): obj is Date => { + return typeof obj === 'object' && obj?.constructor === Date; +}; diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 6e154b7e66..19cee87cb3 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -52,6 +52,7 @@ export interface BrowserReadyState { export abstract class BrowserType extends SdkObject { private _name: BrowserName; + _useBidi: boolean = false; constructor(parent: SdkObject, browserName: BrowserName) { super(parent, 'browser-type'); @@ -69,6 +70,8 @@ export abstract class BrowserType extends SdkObject { async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise { options = this._validateLaunchOptions(options); + if (this._useBidi) + options.useWebSocket = true; const controller = new ProgressController(metadata, this); controller.setLogName('browser'); const browser = await controller.run(progress => { @@ -82,6 +85,8 @@ export abstract class BrowserType extends SdkObject { async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise { options = this._validateLaunchOptions(options); + if (this._useBidi) + options.useWebSocket = true; const controller = new ProgressController(metadata, this); const persistent: channels.BrowserNewContextParams = { ...options }; controller.setLogName('browser'); diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index 2122f064c9..cc0a259d75 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -44,6 +44,7 @@ export class PlaywrightDispatcher extends Dispatcher extends js.JSHandle { this._page._timeoutSettings.timeout(options)); } - private async _clickablePoint(): Promise { + private async _clickablePoint(): Promise { const intersectQuadWithViewport = (quad: types.Quad): types.Quad => { return quad.map(point => ({ x: Math.min(Math.max(point.x, 0), metrics.width), @@ -257,6 +257,8 @@ export class ElementHandle extends js.JSHandle { this._page._delegate.getContentQuads(this), this._page.mainFrame()._utilityContext().then(utility => utility.evaluate(() => ({ width: innerWidth, height: innerHeight }))), ] as const); + if (quads === 'error:notconnected') + return quads; if (!quads || !quads.length) return 'error:notvisible'; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 3b952ea02a..32699a199f 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -900,7 +900,7 @@ export class Frame extends SdkObject { const waitUntil = options.waitUntil === undefined ? 'load' : options.waitUntil; progress.log(`setting frame content, waiting until "${waitUntil}"`); const tag = `--playwright--set--content--${this._id}--${++this._setContentCounter}--`; - const context = await this._utilityContext(); + const context = this._page._delegate.useMainWorldForSetContent?.() ? await this._mainContext() : await this._utilityContext(); const lifecyclePromise = new Promise((resolve, reject) => { this._page._frameManager._consoleMessageTags.set(tag, () => { // Clear lifecycle right after document.open() - see 'tag' below. diff --git a/packages/playwright-core/src/server/input.ts b/packages/playwright-core/src/server/input.ts index 4e4c95a8f3..a4407d36d7 100644 --- a/packages/playwright-core/src/server/input.ts +++ b/packages/playwright-core/src/server/input.ts @@ -162,6 +162,7 @@ export interface RawMouse { move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise; down(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise; up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise; + click?(x: number, y: number, options: { delay?: number, button?: types.MouseButton, clickCount?: number }): Promise; wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise; } @@ -216,6 +217,8 @@ export class Mouse { async click(x: number, y: number, options: { delay?: number, button?: types.MouseButton, clickCount?: number } = {}, metadata?: CallMetadata) { if (metadata) metadata.point = { x, y }; + if (this._raw.click) + return await this._raw.click(x, y, options); const { delay = null, clickCount = 1 } = options; if (delay) { this.move(x, y, { forClick: true }); diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index e18b43708d..42c94fe97b 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -108,6 +108,7 @@ export class Request extends SdkObject { private _waitForResponsePromise = new ManualPromise(); _responseEndTiming = -1; private _overrides: NormalizedContinueOverrides | undefined; + private _bodySize: number | undefined; constructor(context: contexts.BrowserContext, frame: frames.Frame | null, serviceWorker: pages.Worker | null, redirectedFrom: Request | null, documentId: string | undefined, url: string, resourceType: string, method: string, postData: Buffer | null, headers: HeadersArray) { @@ -223,8 +224,13 @@ export class Request extends SdkObject { }; } + // TODO(bidi): remove once post body is available. + _setBodySize(size: number) { + this._bodySize = size; + } + bodySize(): number { - return this.postDataBuffer()?.length || 0; + return this._bodySize || this.postDataBuffer()?.length || 0; } async requestHeadersSize(): Promise { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index e0436968ec..144b34c28e 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -76,7 +76,7 @@ export interface PageDelegate { adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise>; getContentFrame(handle: dom.ElementHandle): Promise; // Only called for frame owner elements. getOwnerFrame(handle: dom.ElementHandle): Promise; // Returns frameId. - getContentQuads(handle: dom.ElementHandle): Promise; + getContentQuads(handle: dom.ElementHandle): Promise; setInputFiles(handle: dom.ElementHandle, files: types.FilePayload[]): Promise; setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise; getBoundingBox(handle: dom.ElementHandle): Promise; @@ -98,6 +98,8 @@ export interface PageDelegate { resetForReuse(): Promise; // WebKit hack. shouldToggleStyleSheetToSyncAnimations(): boolean; + // Bidi throws on attempt to document.open() in utility context. + useMainWorldForSetContent?(): boolean; } type EmulatedSize = { screen: types.Size, viewport: types.Size }; diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index f33c2b3699..b4ebbed098 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -28,6 +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'; type PlaywrightOptions = { socksProxyPort?: number; @@ -41,6 +42,7 @@ export class Playwright extends SdkObject { readonly chromium: BrowserType; readonly android: Android; readonly electron: Electron; + readonly bidi; readonly firefox: BrowserType; readonly webkit: BrowserType; readonly options: PlaywrightOptions; @@ -62,6 +64,7 @@ export class Playwright extends SdkObject { } }, null); this.chromium = new Chromium(this); + this.bidi = new BidiFirefox(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 c7ce3a2e7e..6069b765f6 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -264,6 +264,9 @@ const DOWNLOAD_PATHS: Record = { 'mac14-arm64': 'builds/android/%s/android.zip', 'win64': 'builds/android/%s/android.zip', }, + // TODO(bidi): implement downloads. + 'bidi': { + } as DownloadPaths, }; export const registryDirectory = (() => { @@ -349,14 +352,15 @@ function readDescriptors(browsersJSON: BrowsersJSON) { }); } -export type BrowserName = 'chromium' | 'firefox' | 'webkit'; +export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi'; type InternalTool = 'ffmpeg' | 'firefox-beta' | 'chromium-tip-of-tree' | 'android'; +type BidiChannel = 'bidi-firefox-stable'; 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']; export interface Executable { type: 'browser' | 'tool' | 'channel'; - name: BrowserName | InternalTool | ChromiumChannel; + name: BrowserName | InternalTool | ChromiumChannel | BidiChannel; browserName: BrowserName | undefined; installType: 'download-by-default' | 'download-on-demand' | 'install-script' | 'none'; directory: string | undefined; @@ -521,6 +525,12 @@ export class Registry { 'win32': `\\Microsoft\\Edge SxS\\Application\\msedge.exe`, })); + this._executables.push(this._createBidiChannel('bidi-firefox-stable', { + 'linux': '/usr/bin/firefox', + 'darwin': '/Applications/Firefox.app/Contents/MacOS/firefox', + 'win32': '\\Mozilla Firefox\\firefox.exe', + })); + const firefox = descriptors.find(d => d.name === 'firefox')!; const firefoxExecutable = findExecutablePath(firefox.dir, 'firefox'); this._executables.push({ @@ -616,6 +626,21 @@ export class Registry { _dependencyGroup: 'tools', _isHermeticInstallation: true, }); + + this._executables.push({ + type: 'browser', + name: 'bidi', + browserName: 'bidi', + directory: undefined, + executablePath: () => undefined, + executablePathOrDie: () => '', + installType: 'none', + _validateHostRequirements: () => Promise.resolve(), + downloadURLs: [], + _install: () => Promise.resolve(), + _dependencyGroup: 'tools', + _isHermeticInstallation: true, + }); } private _createChromiumChannel(name: ChromiumChannel, lookAt: Record<'linux' | 'darwin' | 'win32', string>, install?: () => Promise): ExecutableImpl { @@ -656,6 +681,44 @@ export class Registry { }; } + private _createBidiChannel(name: BidiChannel, lookAt: Record<'linux' | 'darwin' | 'win32', string>, install?: () => Promise): ExecutableImpl { + const executablePath = (sdkLanguage: string, shouldThrow: boolean) => { + const suffix = lookAt[process.platform as 'linux' | 'darwin' | 'win32']; + if (!suffix) { + if (shouldThrow) + throw new Error(`Firefox distribution '${name}' is not supported on ${process.platform}`); + return undefined; + } + const prefixes = (process.platform === 'win32' ? [ + process.env.LOCALAPPDATA, process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'] + ].filter(Boolean) : ['']) as string[]; + + for (const prefix of prefixes) { + const executablePath = path.join(prefix, suffix); + if (canAccessFile(executablePath)) + return executablePath; + } + if (!shouldThrow) + return undefined; + + const location = prefixes.length ? ` at ${path.join(prefixes[0], suffix)}` : ``; + const installation = install ? `\nRun "${buildPlaywrightCLICommand(sdkLanguage, 'install ' + name)}"` : ''; + throw new Error(`Firefox distribution '${name}' is not found${location}${installation}`); + }; + return { + type: 'channel', + name, + browserName: 'bidi', + directory: undefined, + executablePath: (sdkLanguage: string) => executablePath(sdkLanguage, false), + executablePathOrDie: (sdkLanguage: string) => executablePath(sdkLanguage, true)!, + installType: install ? 'install-script' : 'none', + _validateHostRequirements: () => Promise.resolve(), + _isHermeticInstallation: false, + _install: install, + }; + } + executables(): Executable[] { return this._executables; } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 4735669267..8970d336cc 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -15135,6 +15135,7 @@ export type AndroidKey = export const _electron: Electron; export const _android: Android; +export const _experimentalBidi: BrowserType; // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 export {}; diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 130e731b5a..8273c5ef76 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -83,15 +83,15 @@ const playwrightFixtures: Fixtures = ({ options.channel = channel; options.tracesDir = tracing().tracesDir(); - for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit]) + for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit, playwright._experimentalBidi]) (browserType as any)._defaultLaunchOptions = options; await use(options); - for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit]) + for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit, playwright._experimentalBidi]) (browserType as any)._defaultLaunchOptions = undefined; }, { scope: 'worker', auto: true, box: true }], browser: [async ({ playwright, browserName, _browserOptions, connectOptions, _reuseContext }, use, testInfo) => { - if (!['chromium', 'firefox', 'webkit'].includes(browserName)) + if (!['chromium', 'firefox', 'webkit', '_experimentalBidi'].includes(browserName)) throw new Error(`Unexpected browserName "${browserName}", must be one of "chromium", "firefox" or "webkit"`); if (connectOptions) { diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index d0f5d43f79..0aead445ab 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -560,6 +560,7 @@ export interface RootEvents { // ----------- Playwright ----------- export type PlaywrightInitializer = { chromium: BrowserTypeChannel, + bidi: BrowserTypeChannel, firefox: BrowserTypeChannel, webkit: BrowserTypeChannel, android: AndroidChannel, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index d7c33b05d8..71fd44255d 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -668,6 +668,7 @@ Playwright: initializer: chromium: BrowserType + bidi: BrowserType firefox: BrowserType webkit: BrowserType android: Android diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts new file mode 100644 index 0000000000..a0c7becf19 --- /dev/null +++ b/tests/bidi/playwright.config.ts @@ -0,0 +1,95 @@ +/** + * 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 { config as loadEnv } from 'dotenv'; +loadEnv({ path: path.join(__dirname, '..', '..', '.env'), override: true }); + +import { type Config, type PlaywrightTestOptions, type PlaywrightWorkerOptions, type ReporterDescription } from '@playwright/test'; +import * as path from 'path'; +import type { TestModeWorkerOptions } from '../config/testModeFixtures'; + +const getExecutablePath = () => { + return process.env.BIDIPATH; +}; + +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'); +const testDir = path.join(__dirname, '..'); +const reporters = () => { + const result: ReporterDescription[] = process.env.CI ? [ + ['dot'], + ['json', { outputFile: path.join(outputDir, 'report.json') }], + ['blob', { fileName: `${process.env.PWTEST_BOT_NAME}.zip` }], + ] : [ + ['html', { open: 'on-failure' }] + ]; + return result; +}; + +const config: Config = { + testDir, + outputDir, + expect: { + timeout: 10000, + }, + maxFailures: 200, + timeout: 30000, + globalTimeout: 5400000, + workers: process.env.CI ? 2 : undefined, + fullyParallel: !process.env.CI, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 3 : 0, + reporter: reporters(), + projects: [], +}; + +const browserName: any = '_experimentalBidi'; +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, + }, + trace: trace ? 'on' : undefined, + }, + metadata: { + platform: process.platform, + docker: !!process.env.INSIDE_DOCKER, + headless: !headed, + browserName, + channel, + trace: !!trace, + }, + }); +} + +export default config; diff --git a/tests/library/channels.spec.ts b/tests/library/channels.spec.ts index 44ad5e0943..1804fa1450 100644 --- a/tests/library/channels.spec.ts +++ b/tests/library/channels.spec.ts @@ -45,6 +45,7 @@ it('should scope context handles', async ({ browserType, server, expectScopeStat { _guid: 'android', objects: [] }, { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, + { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [ { _guid: 'browser', objects: [] } ] }, @@ -67,6 +68,7 @@ it('should scope context handles', async ({ browserType, server, expectScopeStat { _guid: 'android', objects: [] }, { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, + { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [ { _guid: 'browser', objects: [ { _guid: 'browser-context', objects: [ @@ -103,6 +105,7 @@ it('should scope CDPSession handles', async ({ browserType, browserName, expectS { _guid: 'android', objects: [] }, { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, + { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [ { _guid: 'browser', objects: [] } ] }, @@ -121,6 +124,7 @@ it('should scope CDPSession handles', async ({ browserType, browserName, expectS { _guid: 'android', objects: [] }, { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, + { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [ { _guid: 'browser', objects: [ { _guid: 'cdp-session', objects: [] }, @@ -147,6 +151,7 @@ it('should scope browser handles', async ({ browserType, expectScopeState }) => { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, + { _guid: 'browser-type', objects: [] }, { _guid: 'electron', objects: [] }, { _guid: 'localUtils', objects: [] }, { _guid: 'Playwright', objects: [] }, @@ -163,6 +168,7 @@ it('should scope browser handles', async ({ browserType, expectScopeState }) => { _guid: 'android', objects: [] }, { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, + { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [ { _guid: 'browser', objects: [ @@ -199,6 +205,7 @@ it('should not generate dispatchers for subresources w/o listeners', async ({ pa { _guid: 'android', objects: [] }, { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, + { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [ { _guid: 'browser', objects: [ @@ -278,6 +285,10 @@ it('exposeFunction should not leak', async ({ page, expectScopeState, server }) '_guid': 'browser-type', 'objects': [], }, + { + '_guid': 'browser-type', + 'objects': [], + }, { '_guid': 'browser-type', 'objects': [ diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index e679bbb9cb..f2cb83997a 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -377,6 +377,7 @@ export type AndroidKey = export const _electron: Electron; export const _android: Android; +export const _experimentalBidi: BrowserType; // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 export {};