Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
"@types/react-dom": "^19.2.1",
"@types/retry": "^0.12.5",
"@types/source-map-support": "^0.5.4",
"@types/ws": "8.2.2",
"@types/ws": "8.18.1",
"@types/xml2js": "^0.4.9",
"@types/yazl": "^2.4.2",
"@typescript-eslint/eslint-plugin": "^8.59.0",
Expand Down
15 changes: 8 additions & 7 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15384,10 +15384,6 @@ export interface BrowserType<Unused = {}> {
* @param options
*/
connectOverCDP(endpointURL: string, options?: ConnectOverCDPOptions): Promise<Browser>;
/**
* Option `wsEndpoint` is deprecated. Instead use `endpointURL`.
* @deprecated
*/
/**
* This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol.
*
Expand All @@ -15413,7 +15409,11 @@ export interface BrowserType<Unused = {}> {
* `ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4`.
* @param options
*/
connectOverCDP(options: ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<Browser>;
connectOverCDP(transport: ConnectOverCDPTransport, options?: ConnectOverCDPOptions): Promise<Browser>;
/**
* Option `wsEndpoint` is deprecated. Instead use `endpointURL`.
* @deprecated
*/
/**
* This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol.
*
Expand All @@ -15439,7 +15439,7 @@ export interface BrowserType<Unused = {}> {
* `ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4`.
* @param options
*/
connectOverCDP(transport: ConnectionTransport, options?: ConnectOverCDPOptions): Promise<Browser>;
connectOverCDP(options: ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<Browser>;

/**
* This method attaches Playwright to an existing browser instance created via `BrowserType.launchServer` in Node.js.
Expand Down Expand Up @@ -16221,7 +16221,8 @@ export interface BrowserType<Unused = {}> {
name(): string;
}

export interface ConnectionTransport {
export interface ConnectOverCDPTransport {
open?(): void;
send(message: object): void;
close(): void;
onmessage?: (message: object) => void;
Expand Down
8 changes: 4 additions & 4 deletions packages/playwright-core/src/client/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,10 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple

async connectOverCDP(options: api.ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<api.Browser>;
async connectOverCDP(endpointURL: string, options?: api.ConnectOverCDPOptions): Promise<api.Browser>;
async connectOverCDP(transport: api.ConnectionTransport, options?: api.ConnectOverCDPOptions): Promise<api.Browser>;
async connectOverCDP(overloaded: (api.ConnectOverCDPOptions & { wsEndpoint?: string }) | string | api.ConnectionTransport, options?: api.ConnectOverCDPOptions): Promise<Browser> {
async connectOverCDP(transport: api.ConnectOverCDPTransport, options?: api.ConnectOverCDPOptions): Promise<api.Browser>;
async connectOverCDP(overloaded: (api.ConnectOverCDPOptions & { wsEndpoint?: string }) | string | api.ConnectOverCDPTransport, options?: api.ConnectOverCDPOptions): Promise<Browser> {
let endpointURL: string | undefined;
let transport: api.ConnectionTransport | undefined;
let transport: api.ConnectOverCDPTransport | undefined;
let params: api.ConnectOverCDPOptions;
if (typeof overloaded === 'string') {
endpointURL = overloaded;
Expand Down Expand Up @@ -193,6 +193,6 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
}
}

function isConnectionTransport(value: any): value is api.ConnectionTransport {
function isConnectionTransport(value: any): value is api.ConnectOverCDPTransport {
return !!value && typeof value === 'object' && typeof value.send === 'function' && typeof value.close === 'function';
}
1 change: 1 addition & 0 deletions packages/playwright-core/src/server/webkit/DEPS.list
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
../registry/
node_modules/jpeg-js
node_modules/pngjs
node_modules/ws

[webkit.ts]
./webview/wvBrowser.ts
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/webkit/webkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class WebKit extends BrowserType {
}

override async connectOverCDP(progress: Progress, params: channels.BrowserTypeConnectOverCDPParams): Promise<Browser> {
return connectOverRDP(progress, this, params.endpointURL!, params);
return connectOverRDP(progress, this, params);
}

override amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean, options: types.LaunchOptions): NodeJS.ProcessEnv {
Expand Down
103 changes: 84 additions & 19 deletions packages/playwright-core/src/server/webkit/webview/wvBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
import os from 'os';
import path from 'path';

import ws from 'ws';
import { debugLogger, RecentLogsCollector } from '@utils/debugLogger';
import { removeFolders } from '@utils/fileUtils';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '@utils/happyEyeballs';
import { headersArrayToObject } from '@isomorphic/headers';
import { Browser } from '../../browser';
import { helper } from '../../helper';
import { WebSocketTransport } from '../../transport';
import { perMessageDeflate } from '../../transport';
import { getUserAgent } from '../../userAgent';
import { BrowserContext } from '../../browserContext';
import { DialogBridge } from './dialogBridge';
Expand All @@ -33,9 +35,11 @@ import { WVPage } from './wvPage';
import type { BrowserOptions, BrowserProcess } from '../../browser';
import type { SdkObject } from '../../instrumentation';
import type { InitScript, Page } from '../../page';
import type { ProtocolRequest, ProtocolResponse } from '../../transport';
import type * as types from '../../types';
import type * as channels from '@protocol/channels';
import type { Progress } from '../../progress';
import type { ConnectOverCDPTransport } from '../../../../types/types.d.ts';

type ProxyTab = {
url: string;
Expand Down Expand Up @@ -64,26 +68,82 @@ async function listTabs(proxyBase: string, headers: { [key: string]: string }):
return data.filter(t => !!t.webSocketDebuggerUrl);
}

export async function connectOverRDP(progress: Progress, parent: SdkObject, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray, isLocal?: boolean, noDefaults?: boolean, artifactsDir?: string }): Promise<Browser> {
// Local WebSocket-backed transport: defers opening the socket until `open()`
// is called, so listeners on `onmessage` are wired before the remote side
// starts emitting events.
class DeferredWebSocketTransport implements ConnectOverCDPTransport {
private readonly _url: string;
private readonly _headers: { [key: string]: string };
private _ws: ws | undefined;
private _closed = false;

onmessage?: (message: object) => void;
onclose?: (reason?: string) => void;

constructor(url: string, headers: { [key: string]: string }) {
this._url = url;
this._headers = headers;
}

open(): void {
if (this._closed)
return;
const url = this._url;
this._ws = new ws(url, [], {
maxPayload: 256 * 1024 * 1024,
headers: this._headers,
followRedirects: true,
agent: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent,
perMessageDeflate,
allowSynchronousEvents: false,
});
this._ws.addEventListener('message', event => {
const eventData = event.data as string;
let parsedJson: ProtocolResponse;
try {
parsedJson = JSON.parse(eventData);
this.onmessage?.(parsedJson);
} catch {
this._ws?.close();
}
});
this._ws.addEventListener('close', event => {
this.onclose?.(event.reason);
});
this._ws.addEventListener('error', () => {});
}

send(message: object): void {
this._ws?.send(JSON.stringify(message as ProtocolRequest));
}

close(): void {
this._closed = true;
this._ws?.close();
}
}

export async function connectOverRDP(progress: Progress, parent: SdkObject, params: channels.BrowserTypeConnectOverCDPParams): Promise<Browser> {
let headersMap: { [key: string]: string; } | undefined;
if (options.headers)
headersMap = headersArrayToObject(options.headers, false);
if (params.headers)
headersMap = headersArrayToObject(params.headers, false);
if (!headersMap)
headersMap = { 'User-Agent': getUserAgent() };
else if (!Object.keys(headersMap).some(key => key.toLowerCase() === 'user-agent'))
headersMap['User-Agent'] = getUserAgent();

const proxyBase = deriveProxyBase(endpointURL);
const transport = params.transport as ConnectOverCDPTransport | undefined;
const proxyBase = transport ? '' : deriveProxyBase(params.endpointURL!);

const artifactsDir = options.artifactsDir ?? path.join(os.tmpdir(), 'playwright-artifacts-');
const artifactsDir = params.artifactsDir ?? path.join(os.tmpdir(), 'playwright-artifacts-');
const doCleanup = async () => {
await removeFolders([artifactsDir]);
};

const browser = await progress.race((async () => {
const dialogBridge = await DialogBridge.start();
const created = new WVBrowser(parent, proxyBase, headersMap!, dialogBridge, {
slowMo: options.slowMo,
const created = new WVBrowser(parent, proxyBase, headersMap!, dialogBridge, transport, {
slowMo: params.slowMo,
name: 'webkit',
browserType: 'webkit',
browserProcess: { close: async () => {}, kill: async () => {} } as BrowserProcess,
Expand All @@ -104,15 +164,15 @@ export async function connectOverRDP(progress: Progress, parent: SdkObject, endp
return created;
})());

if (!options.isLocal)
if (!params.isLocal)
browser._isCollocatedWithServer = false;
browser.on(Browser.Events.Disconnected, doCleanup);
return browser;
}

type TabEntry = {
pageId: string;
transport: WebSocketTransport;
transport: ConnectOverCDPTransport;
connection: WVConnection;
page: WVPage;
};
Expand All @@ -122,24 +182,30 @@ export class WVBrowser extends Browser {
readonly _proxyBase: string;
readonly _headers: { [key: string]: string };
readonly _dialogBridge: DialogBridge;
readonly _directPageTransport: ConnectOverCDPTransport | undefined;
readonly _tabs = new Map<string, TabEntry>();
private _didCloseFired = false;
// Backwards compat — old code still reads `_page` for the "primary" tab.
_page!: WVPage;

constructor(parent: SdkObject, proxyBase: string, headers: { [key: string]: string }, dialogBridge: DialogBridge, options: BrowserOptions) {
constructor(parent: SdkObject, proxyBase: string, headers: { [key: string]: string }, dialogBridge: DialogBridge, directPageTransport: ConnectOverCDPTransport | undefined, options: BrowserOptions) {
super(parent, options);
this._proxyBase = proxyBase;
this._headers = headers;
this._dialogBridge = dialogBridge;
this._directPageTransport = directPageTransport;
this._context = new WVBrowserContext(this);
}

async _initialize(): Promise<void> {
await this._context.initialize();
await this._syncTabs();
if (!this._tabs.size)
throw new Error(`No Mobile Safari tabs found at ${this._proxyBase}/json — open Safari first.`);
if (this._directPageTransport) {
await this._attachTab('rdp-transport', this._directPageTransport);
} else {
await this._syncTabs();
if (!this._tabs.size)
throw new Error(`No Mobile Safari tabs found at ${this._proxyBase}/json — open Safari first.`);
}
this._page = this._firstTab().page;
}

Expand All @@ -156,7 +222,7 @@ export class WVBrowser extends Browser {
if (this._tabs.has(pageId))
continue;
try {
await this._attachTab(pageId, tab);
await this._attachTab(pageId, new DeferredWebSocketTransport(tab.webSocketDebuggerUrl, this._headers));
} catch (e) {
debugLogger.log('error', `webview: failed to attach to tab ${pageId}: ${(e as Error).message}`);
}
Expand All @@ -168,15 +234,14 @@ export class WVBrowser extends Browser {
}
}

private async _attachTab(pageId: string, tab: ProxyTab): Promise<void> {
const transport = await WebSocketTransport.connect(undefined, tab.webSocketDebuggerUrl, { headers: this._headers, followRedirects: true });
private async _attachTab(pageId: string, transport: ConnectOverCDPTransport): Promise<void> {
const connection = new WVConnection(transport, () => this._detachTab(pageId), this.options.protocolLogger, this.options.browserLogsCollector);
// TODO: handle this as RDP connection parameter.
connection.outerSession.sendMayFail('Target.setPauseOnStart', { pauseOnStart: true });
const dialogEndpoint = this._dialogBridge.endpointFor(pageId);
const page = new WVPage(this._context, connection.outerSession, dialogEndpoint);
this._dialogBridge.registerTab(pageId, req => page.onBridgeDialog(req));
this._tabs.set(pageId, { pageId, transport, connection, page });
transport.open?.();
connection.outerSession.sendMayFail('Target.setPauseOnStart', { pauseOnStart: true });
await page.waitForInitialized();
}

Expand Down
7 changes: 3 additions & 4 deletions packages/playwright-core/src/server/webkit/webview/wvPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { PNG } from 'pngjs';
import jpegjs from 'jpeg-js';
import { assert } from '@isomorphic/assert';
import { headersArrayToObject } from '@isomorphic/headers';
import { ManualPromise } from '@isomorphic/manualPromise';
import { splitErrorMessage } from '@isomorphic/stackTrace';
import { debugLogger } from '@utils/debugLogger';
import { eventsHelper } from '@utils/eventsHelper';
Expand Down Expand Up @@ -65,8 +66,7 @@ export class WVPage implements PageDelegate {
private _firstNonInitialNavigationCommittedPromise: Promise<void>;
private _firstNonInitialNavigationCommittedFulfill = () => {};
_firstNonInitialNavigationCommittedReject = (e: Error) => {};
private _initializedPromise: Promise<void>;
private _initializedFulfill = () => {};
private _initializedPromise = new ManualPromise<void>();
private _lastConsoleMessage: { derivedType: string, text: string, handles: JSHandle[]; count: number, location: types.ConsoleMessageLocation; } | null = null;
private readonly _requestIdToResponseReceivedPayloadEvent = new Map<string, Protocol.Network.responseReceivedPayload>();

Expand Down Expand Up @@ -95,7 +95,6 @@ export class WVPage implements PageDelegate {
});
// Avoid unhandled rejection on disconnect in the middle of initialization.
this._firstNonInitialNavigationCommittedPromise.catch(() => {});
this._initializedPromise = new Promise(f => { this._initializedFulfill = f; });
}

waitForInitialized(): Promise<void> {
Expand Down Expand Up @@ -225,7 +224,7 @@ export class WVPage implements PageDelegate {
if (targetInfo.isPaused)
this._outerSession.sendMayFail('Target.resume', { targetId: targetInfo.targetId });
await this._page.reportAsNew(undefined, pageOrError instanceof Page ? undefined : pageOrError);
this._initializedFulfill();
this._initializedPromise.resolve();
} else {
assert(!this._provisionalPage);
this._provisionalPage = new WVProvisionalPage(session, this);
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/tools/backend/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
return;
}

await this.page.evaluate(() => new Promise(f => setTimeout(f, 1000))).catch(() => {});
await this.page.evaluate(ms => new Promise(f => setTimeout(f, ms)), time).catch(() => {});
}
}

Expand Down
15 changes: 8 additions & 7 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15384,10 +15384,6 @@ export interface BrowserType<Unused = {}> {
* @param options
*/
connectOverCDP(endpointURL: string, options?: ConnectOverCDPOptions): Promise<Browser>;
/**
* Option `wsEndpoint` is deprecated. Instead use `endpointURL`.
* @deprecated
*/
/**
* This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol.
*
Expand All @@ -15413,7 +15409,11 @@ export interface BrowserType<Unused = {}> {
* `ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4`.
* @param options
*/
connectOverCDP(options: ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<Browser>;
connectOverCDP(transport: ConnectOverCDPTransport, options?: ConnectOverCDPOptions): Promise<Browser>;
/**
* Option `wsEndpoint` is deprecated. Instead use `endpointURL`.
* @deprecated
*/
/**
* This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol.
*
Expand All @@ -15439,7 +15439,7 @@ export interface BrowserType<Unused = {}> {
* `ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4`.
* @param options
*/
connectOverCDP(transport: ConnectionTransport, options?: ConnectOverCDPOptions): Promise<Browser>;
connectOverCDP(options: ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<Browser>;

/**
* This method attaches Playwright to an existing browser instance created via `BrowserType.launchServer` in Node.js.
Expand Down Expand Up @@ -16221,7 +16221,8 @@ export interface BrowserType<Unused = {}> {
name(): string;
}

export interface ConnectionTransport {
export interface ConnectOverCDPTransport {
open?(): void;
send(message: object): void;
close(): void;
onmessage?: (message: object) => void;
Expand Down
Loading
Loading