diff --git a/docs/src/api/class-worker.md b/docs/src/api/class-worker.md index fe69a8424889b..3298697febff8 100644 --- a/docs/src/api/class-worker.md +++ b/docs/src/api/class-worker.md @@ -58,6 +58,16 @@ foreach(var pageWorker in page.Workers) Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is terminated. +## event: Worker.console +* since: v1.56 +- argument: <[ConsoleMessage]> + +:::note +Console events from Web Workers are dispatched on the page object. Note that console events are only supported on Chromium-based browsers and within Service Workers. +::: + +Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + ## async method: Worker.evaluate * since: v1.8 - returns: <[Serializable]> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 2f6edbe3b372e..8534aa43043fa 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -10326,33 +10326,72 @@ export interface Worker { */ on(event: 'close', listener: (worker: Worker) => any): this; + /** + * **NOTE** Console events from Web Workers are dispatched on the page object. Note that console events are only + * supported on Chromium-based browsers and within Service Workers. + * + * Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + */ + on(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ once(event: 'close', listener: (worker: Worker) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is * terminated. */ addListener(event: 'close', listener: (worker: Worker) => any): this; + /** + * **NOTE** Console events from Web Workers are dispatched on the page object. Note that console events are only + * supported on Chromium-based browsers and within Service Workers. + * + * Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + */ + addListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ removeListener(event: 'close', listener: (worker: Worker) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ off(event: 'close', listener: (worker: Worker) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is * terminated. */ prependListener(event: 'close', listener: (worker: Worker) => any): this; + /** + * **NOTE** Console events from Web Workers are dispatched on the page object. Note that console events are only + * supported on Chromium-based browsers and within Service Workers. + * + * Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + */ + prependListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + url(): string; } diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index a074b26f3d581..06a536086c523 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -87,6 +87,7 @@ export const Events = { Worker: { Close: 'close', + Console: 'console', }, ElectronApplication: { diff --git a/packages/playwright-core/src/client/worker.ts b/packages/playwright-core/src/client/worker.ts index 5a5a5fe246c80..06e77290c36dd 100644 --- a/packages/playwright-core/src/client/worker.ts +++ b/packages/playwright-core/src/client/worker.ts @@ -15,6 +15,7 @@ */ import { ChannelOwner } from './channelOwner'; +import { ConsoleMessage } from './consoleMessage'; import { TargetClosedError } from './errors'; import { Events } from './events'; import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; @@ -45,7 +46,13 @@ export class Worker extends ChannelOwner implements api. this._context._serviceWorkers.delete(this); this.emit(Events.Worker.Close, this); }); + this._channel.on('console', event => { + this.emit(Events.Worker.Console, new ConsoleMessage(this._page?.context()._platform ?? this._context?._platform!, event)); + }); this.once(Events.Worker.Close, () => this._closedScope.close(this._page?._closeErrorWithReason() || new TargetClosedError())); + this._setEventToSubscriptionMapping(new Map([ + [Events.Worker.Console, 'console'], + ])); } url(): string { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 27c6d635ebcbe..437f1d489ac37 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1929,6 +1929,16 @@ scheme.WorkerInitializer = tObject({ url: tString, }); scheme.WorkerCloseEvent = tOptional(tObject({})); +scheme.WorkerConsoleEvent = tObject({ + type: tString, + text: tString, + args: tArray(tChannel(['ElementHandle', 'JSHandle'])), + location: tObject({ + url: tString, + lineNumber: tInt, + columnNumber: tInt, + }), +}); scheme.WorkerEvaluateExpressionParams = tObject({ expression: tString, isFunction: tOptional(tBoolean), @@ -1945,6 +1955,11 @@ scheme.WorkerEvaluateExpressionHandleParams = tObject({ scheme.WorkerEvaluateExpressionHandleResult = tObject({ handle: tChannel(['ElementHandle', 'JSHandle']), }); +scheme.WorkerUpdateSubscriptionParams = tObject({ + event: tEnum(['console']), + enabled: tBoolean, +}); +scheme.WorkerUpdateSubscriptionResult = tOptional(tObject({})); scheme.JSHandleInitializer = tObject({ preview: tString, }); diff --git a/packages/playwright-core/src/server/chromium/crServiceWorker.ts b/packages/playwright-core/src/server/chromium/crServiceWorker.ts index 23099867c588e..df6e3e1d5d13d 100644 --- a/packages/playwright-core/src/server/chromium/crServiceWorker.ts +++ b/packages/playwright-core/src/server/chromium/crServiceWorker.ts @@ -14,10 +14,12 @@ * limitations under the License. */ import { Worker } from '../page'; -import { CRExecutionContext } from './crExecutionContext'; +import { createHandle, CRExecutionContext } from './crExecutionContext'; import { CRNetworkManager } from './crNetworkManager'; import { BrowserContext } from '../browserContext'; +import { ConsoleMessage } from '../console'; import * as network from '../network'; +import { toConsoleMessageLocation } from './crProtocolHelper'; import type { CRBrowserContext } from './crBrowser'; import type { CRSession } from './crConnection'; @@ -51,6 +53,13 @@ export class CRServiceWorker extends Worker { // Resume service worker after restart. session._sendMayFail('Runtime.runIfWaitingForDebugger', {}); }); + session.on('Runtime.consoleAPICalled', event => { + if (!this.existingExecutionContext) + return; + const args = event.args.map(o => createHandle(this.existingExecutionContext!, o)); + const message = new ConsoleMessage(null, event.type, undefined, args, toConsoleMessageLocation(event.stackTrace)); + this.emit(Worker.Events.Console, message); + }); } override didClose() { diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 4a55c099b2822..cd48ef9eda296 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -31,6 +31,7 @@ import { urlMatches } from '../../utils/isomorphic/urlMatch'; import type { Artifact } from '../artifact'; import type { BrowserContext } from '../browserContext'; import type { CRCoverage } from '../chromium/crCoverage'; +import type { ConsoleMessage } from '../console'; import type { Download } from '../download'; import type { FileChooser } from '../fileChooser'; import type { JSHandle } from '../javascript'; @@ -387,6 +388,7 @@ export class PageDispatcher extends Dispatcher implements channels.WorkerChannel { _type_Worker = true; + private readonly _subscriptions = new Set(); static fromNullable(scope: PageDispatcher | BrowserContextDispatcher, worker: Worker | null): WorkerDispatcher | undefined { if (!worker) @@ -400,6 +402,23 @@ export class WorkerDispatcher extends Dispatcher this._dispatchEvent('close')); + 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(), + }); + }); + } + + async updateSubscription(params: channels.WorkerUpdateSubscriptionParams, progress: Progress): Promise { + if (params.enabled) + this._subscriptions.add(params.event); + else + this._subscriptions.delete(params.event); } async evaluateExpression(params: channels.WorkerEvaluateExpressionParams, progress: Progress): Promise { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 7f8f7b7163a02..6a7edd191decd 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -841,6 +841,7 @@ export class Page extends SdkObject { export class Worker extends SdkObject { static Events = { Close: 'close', + Console: 'console', }; readonly url: string; diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 23106604eb0d9..cb841371fa7ea 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -185,6 +185,7 @@ export const methodMetainfo = new Map any): this; + /** + * **NOTE** Console events from Web Workers are dispatched on the page object. Note that console events are only + * supported on Chromium-based browsers and within Service Workers. + * + * Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + */ + on(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ once(event: 'close', listener: (worker: Worker) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is * terminated. */ addListener(event: 'close', listener: (worker: Worker) => any): this; + /** + * **NOTE** Console events from Web Workers are dispatched on the page object. Note that console events are only + * supported on Chromium-based browsers and within Service Workers. + * + * Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + */ + addListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ removeListener(event: 'close', listener: (worker: Worker) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ off(event: 'close', listener: (worker: Worker) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** * Emitted when this dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is * terminated. */ prependListener(event: 'close', listener: (worker: Worker) => any): this; + /** + * **NOTE** Console events from Web Workers are dispatched on the page object. Note that console events are only + * supported on Chromium-based browsers and within Service Workers. + * + * Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + */ + prependListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + url(): string; } diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index b1b179f8cb671..73d5efef3a649 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -3311,13 +3311,25 @@ export type WorkerInitializer = { }; export interface WorkerEventTarget { on(event: 'close', callback: (params: WorkerCloseEvent) => void): this; + on(event: 'console', callback: (params: WorkerConsoleEvent) => void): this; } export interface WorkerChannel extends WorkerEventTarget, Channel { _type_Worker: boolean; evaluateExpression(params: WorkerEvaluateExpressionParams, progress?: Progress): Promise; evaluateExpressionHandle(params: WorkerEvaluateExpressionHandleParams, progress?: Progress): Promise; + updateSubscription(params: WorkerUpdateSubscriptionParams, progress?: Progress): Promise; } export type WorkerCloseEvent = {}; +export type WorkerConsoleEvent = { + type: string, + text: string, + args: JSHandleChannel[], + location: { + url: string, + lineNumber: number, + columnNumber: number, + }, +}; export type WorkerEvaluateExpressionParams = { expression: string, isFunction?: boolean, @@ -3340,9 +3352,18 @@ export type WorkerEvaluateExpressionHandleOptions = { export type WorkerEvaluateExpressionHandleResult = { handle: JSHandleChannel, }; +export type WorkerUpdateSubscriptionParams = { + event: 'console', + enabled: boolean, +}; +export type WorkerUpdateSubscriptionOptions = { + +}; +export type WorkerUpdateSubscriptionResult = void; export interface WorkerEvents { 'close': WorkerCloseEvent; + 'console': WorkerConsoleEvent; } // ----------- JSHandle ----------- diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index fbe6aeda51860..8da80c373a12c 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2856,10 +2856,23 @@ Worker: returns: handle: JSHandle + updateSubscription: + internal: true + parameters: + event: + type: enum + literals: + - console + enabled: boolean + events: close: + console: + parameters: + $mixin: ConsoleMessage + JSHandle: type: interface diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index f254ad3cf044e..29a2b079871d1 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -17,6 +17,7 @@ import { contextTest as test, expect } from '../../config/browserTest'; import { playwrightTest } from '../../config/browserTest'; +import { ConsoleMessage } from 'playwright-core'; test('should create a worker from a service worker', async ({ page, server }) => { const [worker] = await Promise.all([ @@ -128,6 +129,78 @@ test('should not create a worker from a shared worker', async ({ page, server }) expect(serviceWorkerCreated).not.toBeTruthy(); }); +test('should emit console messages from service worker', async ({ page, server }) => { + const [worker] = await Promise.all([ + page.context().waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/serviceworkers/empty/sw.html') + ]); + + const [consoleMessage] = await Promise.all([ + new Promise(resolve => worker.once('console', resolve)), + worker.evaluate(() => console.log('hello from service worker', { + i: 'am', + am: 1, + complex: { + yup: true, + } + })) + ]); + + expect(consoleMessage.text()).toContain('hello from service worker'); + expect(consoleMessage.type()).toBe('log'); + const args = consoleMessage.args(); + expect(args).toHaveLength(2); + expect(await args[0].jsonValue()).toBe('hello from service worker'); + expect(await args[1].jsonValue()).toEqual({ + i: 'am', + am: 1, + complex: { + yup: true, + } + }); +}); + +test('should emit different console types from service worker', async ({ page, server }) => { + const [worker] = await Promise.all([ + page.context().waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/serviceworkers/empty/sw.html') + ]); + + const messages: ConsoleMessage[] = []; + worker.on('console', m => messages.push(m)); + + await worker.evaluate(() => { + console.log('log message'); + console.warn('warn message'); + console.error('error message'); + }); + + expect(messages).toHaveLength(3); + expect(messages[0].type()).toBe('log'); + expect(messages[0].text()).toBe('log message'); + expect(messages[1].type()).toBe('warning'); + expect(messages[1].text()).toBe('warn message'); + expect(messages[2].type()).toBe('error'); + expect(messages[2].text()).toBe('error message'); +}); + +test('should capture console.log from ServiceWorker start', async ({ context, page, server }) => { + server.setRoute('/serviceworkers/empty/sw.js', (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/javascript' }); + res.write(`console.log('Hello from the first line of sw.js');`); + res.end(); + }); + + const [worker] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'), + ]); + + const consoleMessage = await new Promise(resolve => worker.once('console', resolve)); + expect(consoleMessage.text()).toBe('Hello from the first line of sw.js'); + expect(consoleMessage.type()).toBe('log'); +}); + test('Page.route should work with intervention headers', async ({ server, page }) => { server.setRoute('/intervention', (req, res) => res.end(`