diff --git a/package.json b/package.json index 6b2e043765e6a..e01aae1896bac 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 593e0bd49fe2d..9415125be473c 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 1768380d3019c..0ec06e23b369d 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 bc32bb8486c71..4446d36a24ed6 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 0000000000000..5f9ffe919defe --- /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 0000000000000..878d01b0d7480 --- /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 0000000000000..7138f2e06a1d7 --- /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 0000000000000..eaacb629e6758 --- /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 0000000000000..499ab7f4c7a04 --- /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 0000000000000..29d1dd48dbb1e --- /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 0000000000000..00846b124aba7 --- /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 0000000000000..0028b9fc9596f --- /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 0000000000000..d2c171df74e93 --- /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 0000000000000..9242cc061da4a --- /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 0000000000000..3637b4af360ce --- /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 0000000000000..307d83fb87b3f --- /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 0000000000000..c349e583379dd --- /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 0000000000000..97e8381328ccc --- /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 6e154b7e66e86..19cee87cb3e1b 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 2122f064c957a..cc0a259d75ae4 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 3b952ea02ad54..32699a199f999 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 4e4c95a8f3356..a4407d36d7960 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 e18b43708de77..42c94fe97bf81 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 e0436968ec720..144b34c28e236 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 f33c2b3699589..b4ebbed0988db 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 c7ce3a2e7e243..6069b765f67ab 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 4735669267d8c..8970d336cc997 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 130e731b5acc4..8273c5ef76eeb 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 d0f5d43f795e6..0aead445ab728 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 d7c33b05d804e..71fd44255d9ab 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 0000000000000..a0c7becf19a7f --- /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 44ad5e09431f5..1804fa1450ef8 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 e679bbb9cbaf5..f2cb83997a1d0 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 {};