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
33 changes: 33 additions & 0 deletions docs/src/api/class-browsertype.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,39 @@ can see what is going on. Defaults to 0.
Maximum time in milliseconds to wait for the connection to be established. Defaults to
`30000` (30 seconds). Pass `0` to disable timeout.


## async method: BrowserType.connectToWorker
* since: v1.60
* langs: js
- returns: <[Worker]>

This method attaches Playwright to an existing JavaScript engine exposing Chrome DevTools Protocol, for example to a Node.js process or an Electron application.

:::note
This is only supported on `chromium`.
:::

**Usage**

```js
const worker = await playwright.chromium.connectToWorker('http://localhost:9229');
const global = await worker.evaluate(() => globalThis);
```

### param: BrowserType.connectToWorker.endpoint
* since: v1.60
- `endpoint` <[string]>

A CDP websocket endpoint or http url to connect to. For example `http://localhost:9229/` or `ws://127.0.0.1:9229/something`.

### option: BrowserType.connectToWorker.timeout
* since: v1.60
- `timeout` <[float]>

Maximum time in milliseconds to wait for the connection to be established. Defaults to
`30000` (30 seconds). Pass `0` to disable timeout.


## method: BrowserType.executablePath
* since: v1.8
- returns: <[string]>
Expand Down
13 changes: 13 additions & 0 deletions docs/src/api/class-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs

Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`.

## async method: Worker.disconnect
* since: v1.60
* langs: js

Disconnects from a worker that was connected through [`method: BrowserType.connectToWorker`]. Calling this method on any other worker will throw.

### option: Worker.disconnect.reason
* since: v1.60
- `reason` <[string]>

The reason to be reported to the operations interrupted by the worker disconnect.


## async method: Worker.evaluate
* since: v1.8
- returns: <[Serializable]>
Expand Down
2 changes: 2 additions & 0 deletions packages/isomorphic/protocolMetainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
['BrowserType.launchPersistentContext', { title: 'Launch persistent context', }],
['BrowserType.connectOverCDP', { title: 'Connect over CDP', }],
['BrowserType.connectOverCDPTransport', { title: 'Connect over CDP transport', }],
['BrowserType.connectToWorker', { title: 'Connect to worker', }],
['Browser.startServer', { title: 'Start server', }],
['Browser.stopServer', { title: 'Stop server', }],
['Browser.close', { title: 'Close browser', pause: true, }],
Expand Down Expand Up @@ -205,6 +206,7 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
['Frame.waitForFunction', { title: 'Wait for function', snapshot: true, pause: true, }],
['Frame.waitForSelector', { title: 'Wait for selector', snapshot: true, }],
['Frame.expect', { title: 'Expect "{expression}"', snapshot: true, pause: true, }],
['Worker.disconnect', { title: 'Disconnect from worker', }],
['Worker.evaluateExpression', { title: 'Evaluate', }],
['Worker.evaluateExpressionHandle', { title: 'Evaluate', }],
['Worker.updateSubscription', { internal: true, }],
Expand Down
38 changes: 38 additions & 0 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10624,6 +10624,19 @@ export interface Worker {
*/
prependListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this;

/**
* Disconnects from a worker that was connected through
* [browserType.connectToWorker(endpoint[, options])](https://playwright.dev/docs/api/class-browsertype#browser-type-connect-to-worker).
* Calling this method on any other worker will throw.
* @param options
*/
disconnect(options?: {
/**
* The reason to be reported to the operations interrupted by the worker disconnect.
*/
reason?: string;
}): Promise<void>;

url(): string;

/**
Expand Down Expand Up @@ -15068,6 +15081,31 @@ export interface BrowserType<Unused = {}> {
* @param options
*/
connect(options: ConnectOptions & { wsEndpoint?: string }): Promise<Browser>;
/**
* This method attaches Playwright to an existing JavaScript engine exposing Chrome DevTools Protocol, for example to
* a Node.js process or an Electron application.
*
* **NOTE** This is only supported on `chromium`.
*
* **Usage**
*
* ```js
* const worker = await playwright.chromium.connectToWorker('http://localhost:9229');
* const global = await worker.evaluate(() => globalThis);
* ```
*
* @param endpoint A CDP websocket endpoint or http url to connect to. For example `http://localhost:9229/` or
* `ws://127.0.0.1:9229/something`.
* @param options
*/
connectToWorker(endpoint: string, options?: {
/**
* Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass
* `0` to disable timeout.
*/
timeout?: number;
}): Promise<Worker>;

/**
* A path where Playwright expects to find a bundled browser executable.
*/
Expand Down
11 changes: 11 additions & 0 deletions packages/playwright-core/src/client/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ChannelOwner } from './channelOwner';
import { envObjectToArray } from './clientHelper';
import { connectToBrowser } from './connect';
import { TimeoutSettings } from './timeoutSettings';
import { Worker } from './worker';

import type { Playwright } from './playwright';
import type { ConnectOptions, LaunchOptions, LaunchPersistentContextOptions, LaunchServerOptions } from './types';
Expand Down Expand Up @@ -164,6 +165,16 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
return browser;
}

async connectToWorker(endpoint: string, options: { timeout?: number } = {}): Promise<Worker> {
if (this.name() !== 'chromium')
throw new Error('Connecting to workers is only supported in Chromium.');
const result = await this._channel.connectToWorker({
endpoint,
timeout: new TimeoutSettings(this._platform).timeout(options),
});
return Worker.from(result.worker);
}

async _connectOverCDPTransport(transport: /* ConnectionTransport */ any) {
if (this.name() !== 'chromium')
throw new Error('Connecting over CDP is only supported in Chromium.');
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/client/consoleMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ export class ConsoleMessage implements api.ConsoleMessage {

private _page: Page | null;
private _worker: Worker | null;
private _event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent;
private _event: channels.BrowserContextConsoleEvent | channels.WorkerConsoleEvent | channels.ElectronApplicationConsoleEvent;

constructor(platform: Platform, event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent, page: Page | null, worker: Worker | null) {
constructor(platform: Platform, event: channels.BrowserContextConsoleEvent | channels.WorkerConsoleEvent | channels.ElectronApplicationConsoleEvent, page: Page | null, worker: Worker | null) {
this._page = page;
this._worker = worker;
this._event = event;
Expand Down
29 changes: 26 additions & 3 deletions packages/playwright-core/src/client/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@

import { LongStandingScope } from '@isomorphic/manualPromise';
import { ChannelOwner } from './channelOwner';
import { TargetClosedError } from './errors';
import { ConsoleMessage } from './consoleMessage';
import { isTargetClosedError, TargetClosedError } from './errors';
import { Events } from './events';
import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle';
import { TimeoutSettings } from './timeoutSettings';
Expand All @@ -34,6 +35,7 @@ export class Worker extends ChannelOwner<channels.WorkerChannel> implements api.
_page: Page | undefined; // Set for web workers.
_context: BrowserContext | undefined; // Set for service workers.
readonly _closedScope = new LongStandingScope();
private _closeReason: string | undefined;

static fromNullable(worker: channels.WorkerChannel | undefined): Worker | null {
return worker ? Worker.from(worker) : null;
Expand All @@ -48,14 +50,24 @@ export class Worker extends ChannelOwner<channels.WorkerChannel> implements api.
this._setEventToSubscriptionMapping(new Map<string, channels.WorkerUpdateSubscriptionParams['event']>([
[Events.Worker.Console, 'console'],
]));
this._channel.on('console', event => {
// Note: we only receive console events here for workers from "chromium.connectToWorker".
this.emit(Events.Worker.Console, new ConsoleMessage(this._platform, event, null, this));
});
this._channel.on('close', () => {
if (this._page)
this._page._workers.delete(this);
if (this._context)
this._context._serviceWorkers.delete(this);
this.emit(Events.Worker.Close, this);
});
this.once(Events.Worker.Close, () => this._closedScope.close(this._page?._closeErrorWithReason() || new TargetClosedError()));
this.once(Events.Worker.Close, () => this._closedScope.close(this._closeErrorWithReason()));
}

_closeErrorWithReason() {
if (this._closeReason)
return new TargetClosedError(this._closeReason);
return this._page?._closeErrorWithReason() || new TargetClosedError();
}

url(): string {
Expand All @@ -82,10 +94,21 @@ export class Worker extends ChannelOwner<channels.WorkerChannel> implements api.
const waiter = Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== Events.Worker.Close)
waiter.rejectOnEvent(this, Events.Worker.Close, () => new TargetClosedError());
waiter.rejectOnEvent(this, Events.Worker.Close, () => this._closeErrorWithReason());
const result = await waiter.waitForEvent(this, event, predicate as any);
waiter.dispose();
return result;
});
}

async disconnect(options: { reason?: string } = {}): Promise<void> {
this._closeReason = options.reason;
try {
await this._channel.disconnect(options);
} catch (e) {
if (isTargetClosedError(e))
return;
throw e;
}
}
}
22 changes: 22 additions & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,13 @@ scheme.BrowserTypeConnectOverCDPTransportResult = tObject({
browser: tChannel(['Browser']),
defaultContext: tOptional(tChannel(['BrowserContext'])),
});
scheme.BrowserTypeConnectToWorkerParams = tObject({
endpoint: tString,
timeout: tFloat,
});
scheme.BrowserTypeConnectToWorkerResult = tObject({
worker: tChannel(['Worker']),
});
scheme.BrowserInitializer = tObject({
version: tString,
name: tString,
Expand Down Expand Up @@ -2033,7 +2040,22 @@ scheme.FrameExpectResult = tObject({
scheme.WorkerInitializer = tObject({
url: tString,
});
scheme.WorkerConsoleEvent = tObject({
type: tString,
text: tString,
args: tArray(tChannel(['ElementHandle', 'JSHandle'])),
location: tObject({
url: tString,
lineNumber: tInt,
columnNumber: tInt,
}),
timestamp: tFloat,
});
scheme.WorkerCloseEvent = tOptional(tObject({}));
scheme.WorkerDisconnectParams = tObject({
reason: tOptional(tString),
});
scheme.WorkerDisconnectResult = tOptional(tObject({}));
scheme.WorkerEvaluateExpressionParams = tObject({
expression: tString,
isFunction: tOptional(tBoolean),
Expand Down
5 changes: 5 additions & 0 deletions packages/playwright-core/src/server/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { WebSocketTransport } from './transport';

import type { Browser, BrowserOptions, BrowserProcess } from './browser';
import type { BrowserContext } from './browserContext';
import type { Worker } from './page';
import type { Progress } from './progress';
import type { BrowserName } from './registry';
import type { ConnectionTransport } from './transport';
Expand Down Expand Up @@ -293,6 +294,10 @@ export abstract class BrowserType extends SdkObject {
throw new Error('CDP connections are only supported by Chromium');
}

async connectToWorker(progress: Progress, endpoint: string): Promise<Worker> {
throw new Error('CDP connections are only supported by Chromium');
}

async launchWithSeleniumHub(progress: Progress, hubUrl: string, options: types.LaunchOptions): Promise<Browser> {
throw new Error('Connecting to SELENIUM_REMOTE_URL is only supported by Chromium');
}
Expand Down
35 changes: 34 additions & 1 deletion packages/playwright-core/src/server/chromium/chromium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@ import { fetchData } from '../utils';
import { getUserAgent } from '../userAgent';
import { chromiumSwitches } from './chromiumSwitches';
import { shouldProxyLoopback, CRBrowser } from './crBrowser';
import { kBrowserCloseMessageId } from './crConnection';
import { ConnectionEvents, CRConnection, kBrowserCloseMessageId } from './crConnection';
import { validateBrowserContextOptions } from '../browserContext';
import { BrowserType, kNoXServerRunningError } from '../browserType';
import { helper } from '../helper';
import { registry } from '../registry';
import { WebSocketTransport } from '../transport';
import { CRDevTools } from './crDevTools';
import { Browser } from '../browser';
import { Worker } from '../page';
import { CRExecutionContext, createHandle } from './crExecutionContext';
import { ConsoleMessage } from '../console';
import { toConsoleMessageLocation } from './crProtocolHelper';

import type { HTTPRequestParams } from '@utils/network';
import type { BrowserOptions, BrowserProcess } from '../browser';
Expand Down Expand Up @@ -143,6 +147,35 @@ export class Chromium extends BrowserType {
return this._connectOverCDPImpl(progress, transport, closeAndWait, { isLocal: true });
}

override async connectToWorker(progress: Progress, endpoint: string) {
const wsEndpoint = await urlToWSEndpoint(progress, endpoint, {});
const transport = await WebSocketTransport.connect(progress, wsEndpoint);
try {
const connection = new CRConnection(this, transport, helper.debugProtocolLogger(), new RecentLogsCollector());
const session = connection.rootSession;
const worker = new Worker(this, '', () => transport.closeAndWait());
session.on('Runtime.executionContextCreated', event => {
worker.workerScriptLoaded();
worker.createExecutionContext(new CRExecutionContext(session, event.context));
});
session.on('Runtime.executionContextDestroyed', () => worker.destroyExecutionContext('Execution context was destroyed'));
session.on('Runtime.consoleAPICalled', event => {
if (!worker.existingExecutionContext)
return;
const args = event.args.map(o => createHandle(worker.existingExecutionContext!, o));
const message = new ConsoleMessage(null, worker, event.type, undefined, args, toConsoleMessageLocation(event.stackTrace), event.timestamp);
worker.emit(Worker.Events.Console, message);
});
connection.on(ConnectionEvents.Disconnected, () => worker.didClose());
session._sendMayFail('Runtime.enable');
session._sendMayFail('Runtime.runIfWaitingForDebugger');
return worker;
} catch (error) {
await progress.race(transport.closeAndWait().catch(() => {}));
throw error;
}
}

private _createDevTools() {
// TODO: this is totally wrong when using channels.
const directory = registry.findExecutable('chromium').directory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class CRServiceWorker extends Worker {
if (!process.env.PLAYWRIGHT_DISABLE_SERVICE_WORKER_NETWORK)
this._networkManager = new CRNetworkManager(null, this);

session.on('Inspector.targetCrashed', () => this._prepareContextForRestart());
session.on('Inspector.targetCrashed', () => this.destroyExecutionContext('Service worker restarted'));

session.on('Runtime.executionContextCreated', (event: Protocol.Runtime.executionContextCreatedPayload) => {
this.createExecutionContext(new CRExecutionContext(session, event.context));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import { BrowserContextDispatcher } from './browserContextDispatcher';
import { BrowserDispatcher } from './browserDispatcher';
import { WorkerDispatcher } from './pageDispatcher';
import { Dispatcher } from './dispatcher';

import type { BrowserType } from '../browserType';
Expand Down Expand Up @@ -72,4 +73,11 @@ export class BrowserTypeDispatcher extends Dispatcher<BrowserType, channels.Brow
const browserDispatcher = new BrowserDispatcher(this, browser);
return { browser: browserDispatcher, defaultContext: browser._defaultContext ? BrowserContextDispatcher.from(browserDispatcher, browser._defaultContext) : undefined };
}

async connectToWorker(params: channels.BrowserTypeConnectToWorkerParams, progress: Progress): Promise<channels.BrowserTypeConnectToWorkerResult> {
if (this._denyLaunch)
throw new Error(`Launching more browsers is not allowed.`);
const worker = await this._object.connectToWorker(progress, params.endpoint);
return { worker: new WorkerDispatcher(this, worker) };
}
}
Loading
Loading