diff --git a/docs/src/api/class-browsertype.md b/docs/src/api/class-browsertype.md index fa7a0b05f7f9e..ee2af12c3e1c0 100644 --- a/docs/src/api/class-browsertype.md +++ b/docs/src/api/class-browsertype.md @@ -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]> diff --git a/docs/src/api/class-worker.md b/docs/src/api/class-worker.md index 5fc5992affc23..e010dbdad527d 100644 --- a/docs/src/api/class-worker.md +++ b/docs/src/api/class-worker.md @@ -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]> diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 9f2e384000d48..b788e939c0d59 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -53,6 +53,7 @@ export const methodMetainfo = new Map([ ['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, }], @@ -205,6 +206,7 @@ export const methodMetainfo = new Map([ ['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, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 2fbe9ef02a9c7..f2c9f3d6c680e 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -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; + url(): string; /** @@ -15068,6 +15081,31 @@ export interface BrowserType { * @param options */ connect(options: ConnectOptions & { wsEndpoint?: string }): Promise; + /** + * 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; + /** * A path where Playwright expects to find a bundled browser executable. */ diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 293a7e6cba2f6..a7ba85dfbc9ab 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -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'; @@ -164,6 +165,16 @@ export class BrowserType extends ChannelOwner imple return browser; } + async connectToWorker(endpoint: string, options: { timeout?: number } = {}): Promise { + 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.'); diff --git a/packages/playwright-core/src/client/consoleMessage.ts b/packages/playwright-core/src/client/consoleMessage.ts index 0900ece4a5dbd..fd6427881af68 100644 --- a/packages/playwright-core/src/client/consoleMessage.ts +++ b/packages/playwright-core/src/client/consoleMessage.ts @@ -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; diff --git a/packages/playwright-core/src/client/worker.ts b/packages/playwright-core/src/client/worker.ts index cb979a99b5d90..7bed47f6b6f9d 100644 --- a/packages/playwright-core/src/client/worker.ts +++ b/packages/playwright-core/src/client/worker.ts @@ -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'; @@ -34,6 +35,7 @@ export class Worker extends ChannelOwner 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; @@ -48,6 +50,10 @@ export class Worker extends ChannelOwner implements api. this._setEventToSubscriptionMapping(new Map([ [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); @@ -55,7 +61,13 @@ export class Worker extends ChannelOwner implements api. 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 { @@ -82,10 +94,21 @@ export class Worker extends ChannelOwner 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 { + this._closeReason = options.reason; + try { + await this._channel.disconnect(options); + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } + } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index a0174127d8215..15274b8fa45a7 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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, @@ -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), diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 1cddbd5923c8d..c4787cb3f641f 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -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'; @@ -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 { + throw new Error('CDP connections are only supported by Chromium'); + } + async launchWithSeleniumHub(progress: Progress, hubUrl: string, options: types.LaunchOptions): Promise { throw new Error('Connecting to SELENIUM_REMOTE_URL is only supported by Chromium'); } diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index 75cdfc39d89e6..fe8ac42b704aa 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -30,7 +30,7 @@ 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'; @@ -38,6 +38,10 @@ 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'; @@ -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; diff --git a/packages/playwright-core/src/server/chromium/crServiceWorker.ts b/packages/playwright-core/src/server/chromium/crServiceWorker.ts index b49bf0748c910..37c4357f1b6a2 100644 --- a/packages/playwright-core/src/server/chromium/crServiceWorker.ts +++ b/packages/playwright-core/src/server/chromium/crServiceWorker.ts @@ -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)); diff --git a/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts index 256d895262ba7..40633088d5d1e 100644 --- a/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts @@ -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'; @@ -72,4 +73,11 @@ export class BrowserTypeDispatcher extends Dispatcher { + 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) }; + } } diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 9d3dd342e356a..f02882170b040 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -44,6 +44,8 @@ import type { Frame } from '../frames'; import type { RouteHandler } from '../network'; import type { InitScript } from '../page'; import type { Disposable } from '../disposable'; +import type { BrowserTypeDispatcher } from './browserTypeDispatcher'; +import type { ConsoleMessage } from '../console'; import type * as channels from '@protocol/channels'; import type { Progress } from '@protocol/progress'; import type { URLMatch } from '@isomorphic/urlMatch'; @@ -483,26 +485,42 @@ export class PageDispatcher extends Dispatcher implements channels.WorkerChannel { +export class WorkerDispatcher extends Dispatcher implements channels.WorkerChannel { _type_Worker = true; _type_EventTarget = true; readonly _subscriptions = new Set(); - static fromNullable(scope: PageDispatcher | BrowserContextDispatcher, worker: Worker | null): WorkerDispatcher | undefined { + static fromNullable(scope: PageDispatcher | BrowserContextDispatcher | BrowserTypeDispatcher, worker: Worker | null): WorkerDispatcher | undefined { if (!worker) return undefined; const result = scope.connection.existingDispatcher(worker); return result || new WorkerDispatcher(scope, worker); } - constructor(scope: PageDispatcher | BrowserContextDispatcher, worker: Worker) { + constructor(scope: PageDispatcher | BrowserContextDispatcher | BrowserTypeDispatcher, worker: Worker) { super(scope, worker, 'Worker', { url: worker.url }); + this.addObjectListener(Worker.Events.Console, (message: ConsoleMessage) => { + if (!this._subscriptions.has('console')) + return; + this._dispatchEvent('console', { + type: message.type(), + text: message.text(), + args: message.args().map(a => JSHandleDispatcher.fromJSHandle(this, a)), + location: message.location(), + timestamp: message.timestamp(), + }); + }); this.addObjectListener(Worker.Events.Close, () => this._dispatchEvent('close')); } + async disconnect(params: channels.WorkerDisconnectParams, progress: Progress): Promise { + progress.metadata.potentiallyClosesScope = true; + await this._object.disconnect(progress, params); + } + async evaluateExpression(params: channels.WorkerEvaluateExpressionParams, progress: Progress): Promise { return { value: serializeResult(await this._object.evaluateExpression(progress, params.expression, params.isFunction, parseArgument(params.arg))) }; } diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index 01357e6eb69a0..250e360969480 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -26,7 +26,7 @@ import type { Dialog } from './dialog'; import type { Download } from './download'; import type { APIRequestContext } from './fetch'; import type { Frame } from './frames'; -import type { Page } from './page'; +import type { Page, Worker } from './page'; import type { Playwright } from './playwright'; import type { CallMetadata } from '@protocol/callMetadata'; export type { CallMetadata } from '@protocol/callMetadata'; @@ -39,6 +39,7 @@ export type Attribution = { context?: BrowserContext | APIRequestContext; page?: Page; frame?: Frame; + worker?: Worker; }; @@ -64,7 +65,8 @@ export class SdkObject extends EventEmitter } closeReason(): string | undefined { - return this.attribution.page?._closeReason || + return this.attribution.worker?._closeReason || + this.attribution.page?._closeReason || this.attribution.context?._closeReason || this.attribution.browser?._closeReason; } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 90f94de419abc..f6f6ce37af360 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -923,10 +923,12 @@ export class Page extends SdkObject { } export const WorkerEvent = { + Console: 'console', Close: 'close', } as const; export type WorkerEventMap = { + [WorkerEvent.Console]: [message: ConsoleMessage]; [WorkerEvent.Close]: [worker: Worker]; }; @@ -934,14 +936,18 @@ export class Worker extends SdkObject { static Events = WorkerEvent; readonly url: string; + private _onDisconnect?: () => Promise; private _executionContextPromise = new ManualPromise(); private _workerScriptLoaded = false; existingExecutionContext: js.ExecutionContext | null = null; readonly openScope = new LongStandingScope(); + _closeReason: string | undefined; - constructor(parent: SdkObject, url: string) { + constructor(parent: SdkObject, url: string, onDisconnect?: () => Promise) { super(parent, 'worker'); + this.attribution.worker = this; this.url = url; + this._onDisconnect = onDisconnect; } createExecutionContext(delegate: js.ExecutionContextDelegate) { @@ -951,22 +957,20 @@ export class Worker extends SdkObject { return this.existingExecutionContext; } - workerScriptLoaded() { - this._workerScriptLoaded = true; - if (this.existingExecutionContext) - this._executionContextPromise.resolve(this.existingExecutionContext); - } - - protected _prepareContextForRestart() { + destroyExecutionContext(errorMessage: string) { if (this.existingExecutionContext) - this.existingExecutionContext.contextDestroyed('Service worker restarted'); + this.existingExecutionContext.contextDestroyed(errorMessage); this.existingExecutionContext = null; - // Reset so createExecutionContext() waits for workerScriptLoaded() before resolving — - // mirroring initial startup and ensuring the script has run before evaluations proceed. this._workerScriptLoaded = false; this._executionContextPromise = new ManualPromise(); } + workerScriptLoaded() { + this._workerScriptLoaded = true; + if (this.existingExecutionContext) + this._executionContextPromise.resolve(this.existingExecutionContext); + } + didClose() { if (this.existingExecutionContext) this.existingExecutionContext.contextDestroyed('Worker was closed'); @@ -981,6 +985,13 @@ export class Worker extends SdkObject { async evaluateExpressionHandle(progress: Progress, expression: string, isFunction: boolean | undefined, arg: any): Promise { return progress.race(js.evaluateExpression(await this._executionContextPromise, expression, { returnByValue: false, isFunction }, arg)); } + + async disconnect(progress: Progress, options: { reason?: string } = {}) { + if (!this._onDisconnect) + throw new Error('Cannot disconnect from this worker'); + this._closeReason = options.reason; + await progress.race(this._onDisconnect()); + } } export class PageBinding extends DisposableObject { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 2fbe9ef02a9c7..f2c9f3d6c680e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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; + url(): string; /** @@ -15068,6 +15081,31 @@ export interface BrowserType { * @param options */ connect(options: ConnectOptions & { wsEndpoint?: string }): Promise; + /** + * 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; + /** * A path where Playwright expects to find a bundled browser executable. */ diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 323143bb1af9d..f8fb03cbf8aaf 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -894,6 +894,7 @@ export interface BrowserTypeChannel extends BrowserTypeEventTarget, Channel { launchPersistentContext(params: BrowserTypeLaunchPersistentContextParams, progress?: Progress): Promise; connectOverCDP(params: BrowserTypeConnectOverCDPParams, progress?: Progress): Promise; connectOverCDPTransport(params: BrowserTypeConnectOverCDPTransportParams, progress?: Progress): Promise; + connectToWorker(params: BrowserTypeConnectToWorkerParams, progress?: Progress): Promise; } export type BrowserTypeLaunchParams = { channel?: string, @@ -1157,6 +1158,16 @@ export type BrowserTypeConnectOverCDPTransportResult = { browser: BrowserChannel, defaultContext?: BrowserContextChannel, }; +export type BrowserTypeConnectToWorkerParams = { + endpoint: string, + timeout: number, +}; +export type BrowserTypeConnectToWorkerOptions = { + +}; +export type BrowserTypeConnectToWorkerResult = { + worker: WorkerChannel, +}; export interface BrowserTypeEvents { } @@ -3504,15 +3515,35 @@ export type WorkerInitializer = { url: string, }; export interface WorkerEventTarget { + on(event: 'console', callback: (params: WorkerConsoleEvent) => void): this; on(event: 'close', callback: (params: WorkerCloseEvent) => void): this; } export interface WorkerChannel extends WorkerEventTarget, EventTargetChannel { _type_Worker: boolean; + disconnect(params: WorkerDisconnectParams, progress?: Progress): Promise; evaluateExpression(params: WorkerEvaluateExpressionParams, progress?: Progress): Promise; evaluateExpressionHandle(params: WorkerEvaluateExpressionHandleParams, progress?: Progress): Promise; updateSubscription(params: WorkerUpdateSubscriptionParams, progress?: Progress): Promise; } +export type WorkerConsoleEvent = { + type: string, + text: string, + args: JSHandleChannel[], + location: { + url: string, + lineNumber: number, + columnNumber: number, + }, + timestamp: number, +}; export type WorkerCloseEvent = {}; +export type WorkerDisconnectParams = { + reason?: string, +}; +export type WorkerDisconnectOptions = { + reason?: string, +}; +export type WorkerDisconnectResult = void; export type WorkerEvaluateExpressionParams = { expression: string, isFunction?: boolean, @@ -3545,6 +3576,7 @@ export type WorkerUpdateSubscriptionOptions = { export type WorkerUpdateSubscriptionResult = void; export interface WorkerEvents { + 'console': WorkerConsoleEvent; 'close': WorkerCloseEvent; } diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index c11dad3b8f4e1..d4c73295d6f90 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1043,6 +1043,14 @@ BrowserType: browser: Browser defaultContext: BrowserContext? + connectToWorker: + title: Connect to worker + parameters: + endpoint: string + timeout: float + returns: + worker: Worker + Browser: type: interface @@ -3000,6 +3008,13 @@ Worker: commands: + disconnect: + title: Disconnect from worker + parameters: + reason: string? + flags: + pausesBeforeAction: true + evaluateExpression: title: Evaluate parameters: @@ -3029,6 +3044,10 @@ Worker: events: + console: + parameters: + $mixin: ConsoleMessage + close: diff --git a/tests/library/chromium/connect-to-worker.spec.ts b/tests/library/chromium/connect-to-worker.spec.ts new file mode 100644 index 0000000000000..750f999eabab6 --- /dev/null +++ b/tests/library/chromium/connect-to-worker.spec.ts @@ -0,0 +1,47 @@ +/** + * 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 { playwrightTest as test, expect } from '../../config/browserTest'; + +test.skip(({ mode }) => mode === 'service2'); + +test('should connect, evaluate, receive console and disconnect', async ({ browserType, childProcess }) => { + const child = childProcess({ command: [process.execPath, '--inspect-brk=0', '-e', 'console.log("hello from node"); setTimeout(() => {}, 1e9)'] }); + await child.waitForOutput('Debugger listening on ws://'); + const endpoint = child.output.match(/Debugger listening on (ws:\/\/\S+)/)![1]; + const worker = await browserType.connectToWorker(endpoint); + // Script runs after connect due to --inspect-brk, so listen before evaluating. + const messagePromise = worker.waitForEvent('console'); + const result = await worker.evaluate(() => 1 + 1); + expect(result).toBe(2); + const message = await messagePromise; + expect(message.text()).toBe('hello from node'); + expect(message.type()).toBe('log'); + // Disconnect and receive close event. + const closePromise = worker.waitForEvent('close'); + await worker.disconnect(); + await closePromise; +}); + +test('should receive close when node process exits', async ({ browserType, childProcess }) => { + const child = childProcess({ command: [process.execPath, '--inspect-brk=0', '-e', 'setTimeout(() => {}, 1e9)'] }); + await child.waitForOutput('Debugger listening on ws://'); + const endpoint = child.output.match(/Debugger listening on (ws:\/\/\S+)/)![1]; + const worker = await browserType.connectToWorker(endpoint); + const closePromise = worker.waitForEvent('close'); + child.process.kill(); + await closePromise; +});