diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 311736f6399aa..56ef466cba5d3 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2727,6 +2727,11 @@ Returns whether the element is [visible](../actionability.md#visible). [`param: Clears all stored console messages from this page. Subsequent calls to [`method: Page.consoleMessages`] will only return messages logged after the clear. +## async method: Page.clearPageErrors +* since: v1.59 + +Clears all stored page errors from this page. Subsequent calls to [`method: Page.pageErrors`] will only return errors thrown after the clear. + ## async method: Page.consoleMessages * since: v1.56 - returns: <[Array]<[ConsoleMessage]>> diff --git a/package.json b/package.json index 812df6211f400..1b692e728d78a 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "check-deps": "node utils/check_deps.js", "build-android-driver": "./utils/build_android_driver.sh", "innerloop": "playwright run-server --reuse-browser", - "playwright-cli": "node packages/playwright/lib/mcp/terminal/cli.js", + "playwright-cli": "node packages/playwright/lib/cli/client/program.js", "test-playwright-cli": "playwright test --config=tests/mcp/playwright.config.ts --project=chrome cli-", "playwright-cli-readme": "node utils/generate_cli_help.js --readme" }, diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index dbbbf92580e80..f4f8719400e9a 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -17,9 +17,10 @@ import React from 'react'; import './devtools.css'; import { navigate } from './index'; -import { DevToolsTransport } from './transport'; +import { DevToolsClient } from './devtoolsClient'; -type TabInfo = { id: string; title: string; url: string }; +import type { DevToolsClientChannel } from './devtoolsClient'; +import type { Tab } from './devtoolsChannel'; function tabFavicon(url: string): string { try { @@ -31,16 +32,17 @@ function tabFavicon(url: string): string { } } +const BUTTONS = ['left', 'middle', 'right'] as const; + export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { const [status, setStatus] = React.useState<{ text: string; cls: string }>({ text: 'Connecting', cls: '' }); - const [tabs, setTabs] = React.useState([]); - const [selectedPageId, setSelectedPageId] = React.useState(); + const [tabs, setTabs] = React.useState([]); const [url, setUrl] = React.useState(''); const [frameSrc, setFrameSrc] = React.useState(''); const [captured, setCaptured] = React.useState(false); const [hintVisible, setHintVisible] = React.useState(false); - const transportRef = React.useRef(null); + const channelRef = React.useRef(null); const displayRef = React.useRef(null); const screenRef = React.useRef(null); const omniboxRef = React.useRef(null); @@ -57,34 +59,30 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { React.useEffect(() => { if (!wsUrl) return; - const transport = new DevToolsTransport(wsUrl); - transportRef.current = transport; + const channel = DevToolsClient.create(wsUrl); + channelRef.current = channel; - transport.onopen = () => setStatus({ text: 'Connected', cls: 'connected' }); + channel.onopen = () => setStatus({ text: 'Connected', cls: 'connected' }); - transport.onevent = (method: string, params: any) => { - if (method === 'selectPage') { - setSelectedPageId(params.pageId); - if (params.pageId) - omniboxRef.current?.focus(); - } - if (method === 'frame') { - setFrameSrc('data:image/jpeg;base64,' + params.data); - if (params.viewportWidth) - viewportSizeRef.current.width = params.viewportWidth; - if (params.viewportHeight) - viewportSizeRef.current.height = params.viewportHeight; - resizeToFit(); - } - if (method === 'url') - setUrl(params.url); - if (method === 'tabs') - setTabs(params.tabs); - }; + channel.on('tabs', params => { + setTabs(params.tabs); + const selected = params.tabs.find(t => t.selected); + if (selected) + setUrl(selected.url); + }); - transport.onclose = () => setStatus({ text: 'Disconnected', cls: 'error' }); + channel.on('frame', params => { + setFrameSrc('data:image/jpeg;base64,' + params.data); + if (params.viewportWidth) + viewportSizeRef.current.width = params.viewportWidth; + if (params.viewportHeight) + viewportSizeRef.current.height = params.viewportHeight; + resizeToFit(); + }); - return () => transport.close(); + channel.onclose = () => setStatus({ text: 'Disconnected', cls: 'error' }); + + return () => channel.close(); }, [wsUrl]); function resizeToFit() { @@ -134,8 +132,6 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { }; } - const BUTTONS: string[] = ['left', 'middle', 'right']; - function onScreenMouseDown(e: React.MouseEvent) { e.preventDefault(); screenRef.current?.focus(); @@ -145,7 +141,7 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { return; } const { x, y } = imgCoords(e); - transportRef.current?.sendNoReply('mousedown', { x, y, button: BUTTONS[e.button] || 'left' }); + channelRef.current?.mousedown({ x, y, button: BUTTONS[e.button] || 'left' }); } function onScreenMouseUp(e: React.MouseEvent) { @@ -153,7 +149,7 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { return; e.preventDefault(); const { x, y } = imgCoords(e); - transportRef.current?.sendNoReply('mouseup', { x, y, button: BUTTONS[e.button] || 'left' }); + channelRef.current?.mouseup({ x, y, button: BUTTONS[e.button] || 'left' }); } function onScreenMouseMove(e: React.MouseEvent) { @@ -164,14 +160,14 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { return; moveThrottleRef.current = now; const { x, y } = imgCoords(e); - transportRef.current?.sendNoReply('mousemove', { x, y }); + channelRef.current?.mousemove({ x, y }); } function onScreenWheel(e: React.WheelEvent) { if (!capturedRef.current) return; e.preventDefault(); - transportRef.current?.sendNoReply('wheel', { deltaX: e.deltaX, deltaY: e.deltaY }); + channelRef.current?.wheel({ deltaX: e.deltaX, deltaY: e.deltaY }); } function onScreenKeyDown(e: React.KeyboardEvent) { @@ -182,14 +178,14 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { setCaptured(false); return; } - transportRef.current?.sendNoReply('keydown', { key: e.key }); + channelRef.current?.keydown({ key: e.key }); } function onScreenKeyUp(e: React.KeyboardEvent) { if (!capturedRef.current) return; e.preventDefault(); - transportRef.current?.sendNoReply('keyup', { key: e.key }); + channelRef.current?.keyup({ key: e.key }); } function onScreenBlur() { @@ -203,12 +199,12 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { if (!/^https?:\/\//i.test(value)) value = 'https://' + value; setUrl(value); - transportRef.current?.send('navigate', { url: value }); + channelRef.current?.navigate({ url: value }); omniboxRef.current?.blur(); } } - const hasPages = !!selectedPageId; + const hasPages = tabs.some(t => t.selected); return (
{/* Tab bar */} @@ -221,12 +217,12 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
{tabs.map(tab => (
transportRef.current?.sendNoReply('selectTab', { id: tab.id })} + onClick={() => channelRef.current?.selectTab({ pageId: tab.pageId })} > {tab.title || 'New Tab'} @@ -235,7 +231,7 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { title='Close tab' onClick={e => { e.stopPropagation(); - transportRef.current?.sendNoReply('closeTab', { id: tab.id }); + channelRef.current?.closeTab({ pageId: tab.pageId }); }} > @@ -246,7 +242,7 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
))}
- - - - )} - {!canConnect && ( - - )} -
-
- {canConnect && visible && wsUrl && } - {!canConnect &&
Session closed
} -
- - ); - } - return (
{model.loading && sessions.length === 0 &&
Loading sessions...
} {model.error &&
Error: {model.error}
} @@ -145,7 +86,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => {
{isExpanded && (
- {entries.map(({ config, canConnect }) => renderSessionChip(config, canConnect, isExpanded))} + {entries.map(({ config, canConnect }) => )}
)} @@ -154,3 +95,84 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { ); }; + +const SessionChip: React.FC<{ config: SessionConfig; canConnect: boolean; visible: boolean; model: SessionModel }> = ({ config, canConnect, visible, model }) => { + const href = '#session=' + encodeURIComponent(config.socketPath); + const wsUrl = model.wsUrls.get(config.socketPath); + + const channel = React.useMemo(() => { + if (!canConnect || !visible || !wsUrl) + return undefined; + return DevToolsClient.create(wsUrl); + }, [canConnect, visible, wsUrl]); + + const [selectedTab, setSelectedTab] = React.useState(); + + React.useEffect(() => { + if (!channel) + return; + const onTabs = (params: { tabs: Tab[] }) => { + setSelectedTab(params.tabs.find(t => t.selected)); + }; + channel.tabs().then(onTabs); + channel.on('tabs', onTabs); + return () => { + channel.off('tabs', onTabs); + channel.close(); + }; + }, [channel]); + + const chipTitle = selectedTab ? `[${config.name}] ${selectedTab.url} \u2014 ${selectedTab.title}` : config.name; + + return ( + { + e.preventDefault(); + if (canConnect) + navigate(href); + }}> +
+
+ + {selectedTab ? <>[{config.name}] {selectedTab.url} — {selectedTab.title} : config.name} + + {canConnect && ( + + )} + {!canConnect && ( + + )} +
+
+ {channel && } + {!canConnect &&
Session closed
} +
+
+ ); +}; diff --git a/packages/devtools/src/screencast.tsx b/packages/devtools/src/screencast.tsx index d537614a8de0b..f48dd16c7b778 100644 --- a/packages/devtools/src/screencast.tsx +++ b/packages/devtools/src/screencast.tsx @@ -15,19 +15,19 @@ */ import React from 'react'; -import { DevToolsTransport } from './transport'; -export const Screencast: React.FC<{ wsUrl: string }> = ({ wsUrl }) => { +import type { DevToolsClientChannel } from './devtoolsClient'; + +export const Screencast: React.FC<{ channel: DevToolsClientChannel }> = ({ channel }) => { const [frameSrc, setFrameSrc] = React.useState(''); React.useEffect(() => { - const transport = new DevToolsTransport(wsUrl); - transport.onevent = (method: string, params: any) => { - if (method === 'frame') - setFrameSrc('data:image/jpeg;base64,' + params.data); + const listener = (params: { data: string }) => { + setFrameSrc('data:image/jpeg;base64,' + params.data); }; - return () => transport.close(); - }, [wsUrl]); + channel.on('frame', listener); + return () => channel.off('frame', listener); + }, [channel]); if (!frameSrc) return
Connecting...
; diff --git a/packages/devtools/src/sessionModel.ts b/packages/devtools/src/sessionModel.ts index 597fd0dc8a0e7..15b132d25503c 100644 --- a/packages/devtools/src/sessionModel.ts +++ b/packages/devtools/src/sessionModel.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { ClientInfo, SessionConfig } from '../../playwright/src/mcp/terminal/registry'; +import type { ClientInfo, SessionConfig } from '../../playwright/src/cli/client/registry'; export type SessionStatus = { config: SessionConfig; diff --git a/packages/devtools/src/transport.ts b/packages/devtools/src/transport.ts index 65029b104b1c1..61399adc1783a 100644 --- a/packages/devtools/src/transport.ts +++ b/packages/devtools/src/transport.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -export class DevToolsTransport { +export class Transport { private _ws: WebSocket; private _lastId = 0; private _pending = new Map void; reject: (error: Error) => void }>(); + private _sendQueue: string[] | undefined = []; onopen?: () => void; onevent?: (method: string, params: any) => void; @@ -26,6 +27,9 @@ export class DevToolsTransport { constructor(url: string) { this._ws = new WebSocket(url); this._ws.onopen = () => { + for (const message of this._sendQueue || []) + this._ws.send(message); + this._sendQueue = undefined; if (this.onopen) this.onopen(); }; @@ -67,7 +71,11 @@ export class DevToolsTransport { send(method: string, params?: any): Promise { const id = ++this._lastId; - this._ws.send(JSON.stringify({ id, method, params })); + const message = JSON.stringify({ id, method, params }); + if (this._sendQueue) + this._sendQueue.push(message); + else + this._ws.send(message); return new Promise((resolve, reject) => { this._pending.set(id, { resolve, reject }); }); diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 39a29ac015edc..604208ad14b25 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -2263,6 +2263,13 @@ export interface Page { */ clearConsoleMessages(): Promise; + /** + * Clears all stored page errors from this page. Subsequent calls to + * [page.pageErrors()](https://playwright.dev/docs/api/class-page#page-page-errors) will only return errors thrown + * after the clear. + */ + clearPageErrors(): Promise; + /** * **NOTE** Use locator-based [locator.click([options])](https://playwright.dev/docs/api/class-locator#locator-click) instead. * Read more about [locators](https://playwright.dev/docs/locators). diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 9375390fe9fdb..e556bde327526 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -664,6 +664,10 @@ export class Page extends ChannelOwner implements api.Page return messages.map(message => new ConsoleMessage(this._platform, message, this, null)); } + async clearPageErrors(): Promise { + await this._channel.clearPageErrors(); + } + async pageErrors(): Promise { const { errors } = await this._channel.pageErrors(); return errors.map(error => parseError(error)); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 6064116b89fa3..bc93e70939146 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1427,6 +1427,8 @@ scheme.PageTouchscreenTapParams = tObject({ y: tFloat, }); scheme.PageTouchscreenTapResult = tOptional(tObject({})); +scheme.PageClearPageErrorsParams = tOptional(tObject({})); +scheme.PageClearPageErrorsResult = tOptional(tObject({})); scheme.PagePageErrorsParams = tOptional(tObject({})); scheme.PagePageErrorsResult = tObject({ errors: tArray(tType('SerializedError')), diff --git a/packages/playwright-core/src/server/devtoolsController.ts b/packages/playwright-core/src/server/devtoolsController.ts index b43db3108166b..30ada58b4abb4 100644 --- a/packages/playwright-core/src/server/devtoolsController.ts +++ b/packages/playwright-core/src/server/devtoolsController.ts @@ -22,6 +22,7 @@ import { ProgressController } from './progress'; import type { RegisteredListener } from '../utils'; import type { Transport } from './utils/httpServer'; +import type { DevToolsChannel, DevToolsChannelEvents, Tab } from '@devtools/devtoolsChannel'; export class DevToolsController { private _context: BrowserContext; @@ -48,7 +49,7 @@ export class DevToolsController { } } -class DevToolsConnection implements Transport { +class DevToolsConnection implements Transport, DevToolsChannel { sendEvent?: (method: string, params: any) => void; close?: () => void; @@ -58,11 +59,39 @@ class DevToolsConnection implements Transport { private _pageListeners: RegisteredListener[] = []; private _contextListeners: RegisteredListener[] = []; private _context: BrowserContext; + private _eventListeners = new Map>(); constructor(context: BrowserContext) { this._context = context; } + // -- IDevToolsConnection: event subscription -- + + on(event: K, listener: (params: DevToolsChannelEvents[K]) => void): void { + let set = this._eventListeners.get(event); + if (!set) { + set = new Set(); + this._eventListeners.set(event, set); + } + set.add(listener); + } + + off(event: K, listener: (params: DevToolsChannelEvents[K]) => void): void { + this._eventListeners.get(event)?.delete(listener); + } + + /** Sends an event to the remote client and notifies local listeners. */ + private _emit(event: K, params: DevToolsChannelEvents[K]): void { + this.sendEvent?.(event, params); + const set = this._eventListeners.get(event); + if (set) { + for (const fn of set) + fn(params); + } + } + + // -- Transport: lifecycle -- + onconnect() { const context = this._context; @@ -78,17 +107,12 @@ class DevToolsConnection implements Transport { const pages = context.pages(); if (pages.length > 0) this._selectPage(pages[0]); - else - this.sendEvent?.('selectPage', { pageId: undefined }); } this._sendTabList(); }), eventsHelper.addEventListener(context, BrowserContext.Events.InternalFrameNavigatedToNewDocument, (frame, page) => { - if (frame === page.mainFrame()) { + if (frame === page.mainFrame()) this._sendTabList(); - if (page === this.selectedPage) - this.sendEvent?.('url', { url: frame.url() }); - } }), ); @@ -106,55 +130,105 @@ class DevToolsConnection implements Transport { this._contextListeners = []; } + // -- Transport: dispatch (skeleton) -- + async dispatch(method: string, params: any): Promise { - if (method === 'selectTab') { - const page = this._context.pages().find(p => p.guid === params.id); - if (page) - await this._selectPage(page); + return (this as any)[method]?.(params); + } + + // -- IDevToolsConnection: RPC method implementations -- + + async selectTab(params: { pageId: string }) { + const page = this._context.pages().find(p => p.guid === params.pageId); + if (page) + await this._selectPage(page); + } + + async closeTab(params: { pageId: string }) { + const page = this._context.pages().find(p => p.guid === params.pageId); + if (page) + await page.close({ reason: 'Closed from devtools' }); + } + + async newTab() { + await ProgressController.runInternalTask(async progress => { + const page = await this._context.newPage(progress); + await this._selectPage(page); + }); + } + + async navigate(params: { url: string }) { + if (!this.selectedPage || !params.url) return; - } + const page = this.selectedPage; + await ProgressController.runInternalTask(async progress => { await page.mainFrame().goto(progress, params.url); }); + } - if (method === 'closeTab') { - const page = this._context.pages().find(p => p.guid === params.id); - if (page) - await page.close({ reason: 'Closed from devtools' }); + async back() { + if (!this.selectedPage) return; - } + const page = this.selectedPage; + await ProgressController.runInternalTask(async progress => { await page.goBack(progress, {}); }); + } - if (method === 'newTab') { - await ProgressController.runInternalTask(async progress => { - const page = await this._context.newPage(progress); - await this._selectPage(page); - }); + async forward() { + if (!this.selectedPage) return; - } + const page = this.selectedPage; + await ProgressController.runInternalTask(async progress => { await page.goForward(progress, {}); }); + } + + async reload() { + if (!this.selectedPage) + return; + const page = this.selectedPage; + await ProgressController.runInternalTask(async progress => { await page.reload(progress, {}); }); + } + + async mousemove(params: { x: number; y: number }) { + if (!this.selectedPage) + return; + const page = this.selectedPage; + await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); }); + } + + async mousedown(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }) { + if (!this.selectedPage) + return; + const page = this.selectedPage; + await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); await page.mouse.down(progress, { button: params.button || 'left' }); }); + } + + async mouseup(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }) { + if (!this.selectedPage) + return; + const page = this.selectedPage; + await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); await page.mouse.up(progress, { button: params.button || 'left' }); }); + } + async wheel(params: { deltaX: number; deltaY: number }) { if (!this.selectedPage) return; + const page = this.selectedPage; + await ProgressController.runInternalTask(async progress => { await page.mouse.wheel(progress, params.deltaX, params.deltaY); }); + } + async keydown(params: { key: string }) { + if (!this.selectedPage) + return; const page = this.selectedPage; - if (method === 'navigate' && params.url) - await ProgressController.runInternalTask(async progress => { await page.mainFrame().goto(progress, params.url); }); - else if (method === 'back') - await ProgressController.runInternalTask(async progress => { await page.goBack(progress, {}); }); - else if (method === 'forward') - await ProgressController.runInternalTask(async progress => { await page.goForward(progress, {}); }); - else if (method === 'reload') - await ProgressController.runInternalTask(async progress => { await page.reload(progress, {}); }); - else if (method === 'mousemove') - await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); }); - else if (method === 'mousedown') - await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); await page.mouse.down(progress, { button: params.button || 'left' }); }); - else if (method === 'mouseup') - await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); await page.mouse.up(progress, { button: params.button || 'left' }); }); - else if (method === 'wheel') - await ProgressController.runInternalTask(async progress => { await page.mouse.wheel(progress, params.deltaX, params.deltaY); }); - else if (method === 'keydown') - await ProgressController.runInternalTask(async progress => { await page.keyboard.down(progress, params.key); }); - else if (method === 'keyup') - await ProgressController.runInternalTask(async progress => { await page.keyboard.up(progress, params.key); }); + await ProgressController.runInternalTask(async progress => { await page.keyboard.down(progress, params.key); }); } + async keyup(params: { key: string }) { + if (!this.selectedPage) + return; + const page = this.selectedPage; + await ProgressController.runInternalTask(async progress => { await page.keyboard.up(progress, params.key); }); + } + + // -- Internal helpers -- + private async _selectPage(page: Page) { if (this.selectedPage === page) return; @@ -169,7 +243,7 @@ class DevToolsConnection implements Transport { this.selectedPage = page; this._lastFrameData = null; this._lastViewportSize = null; - this.sendEvent?.('selectPage', { pageId: page.guid }); + this._sendTabList(); // Start screencast on new page. this._pageListeners.push( @@ -177,11 +251,6 @@ class DevToolsConnection implements Transport { ); await page.screencast.startScreencast(this, { width: 800, height: 600, quality: 90 }); - - // Send URL to this client. - const url = page.mainFrame().url(); - if (url) - this.sendEvent?.('url', { url }); } private _deselectPage() { @@ -196,33 +265,32 @@ class DevToolsConnection implements Transport { } private _sendCachedState() { - this.sendEvent?.('selectPage', { pageId: this.selectedPage?.guid }); - if (this._lastFrameData) - this.sendEvent?.('frame', { data: this._lastFrameData, viewportWidth: this._lastViewportSize?.width, viewportHeight: this._lastViewportSize?.height }); - if (this.selectedPage) { - const url = this.selectedPage.mainFrame().url(); - if (url) - this.sendEvent?.('url', { url }); - } + if (this._lastFrameData && this._lastViewportSize) + this._emit('frame', { data: this._lastFrameData, viewportWidth: this._lastViewportSize.width, viewportHeight: this._lastViewportSize.height }); this._sendTabList(); } - private async _tabList(): Promise<{ id: string, title: string, url: string }[]> { + async tabs(): Promise<{ tabs: Tab[] }> { + return { tabs: await this._tabList() }; + } + + private async _tabList(): Promise { return await Promise.all(this._context.pages().map(async page => ({ - id: page.guid, + pageId: page.guid, title: await page.mainFrame().title().catch(() => '') || page.mainFrame().url(), url: page.mainFrame().url(), + selected: page === this.selectedPage, }))); } private _sendTabList() { - this._tabList().then(tabs => this.sendEvent?.('tabs', { tabs })); + this._tabList().then(tabs => this._emit('tabs', { tabs })); } private _writeFrame(frame: Buffer, viewportWidth: number, viewportHeight: number) { const data = frame.toString('base64'); this._lastFrameData = data; this._lastViewportSize = { width: viewportWidth, height: viewportHeight }; - this.sendEvent?.('frame', { data, viewportWidth, viewportHeight }); + this._emit('frame', { data, viewportWidth, viewportHeight }); } } diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 18cc7f413ec28..8c29a14b04dcd 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -284,6 +284,10 @@ export class PageDispatcher extends Dispatcher this.parentScope().serializeConsoleMessage(message, this)) }; } + async clearPageErrors(params: channels.PageClearPageErrorsParams, progress: Progress): Promise { + this._page.clearPageErrors(); + } + async pageErrors(params: channels.PagePageErrorsParams, progress: Progress): Promise { return { errors: this._page.pageErrors().map(error => serializeError(error)) }; } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 5039267d282ca..ec708bce58ec3 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -419,6 +419,10 @@ export class Page extends SdkObject { this.emitOnContext(BrowserContext.Events.PageError, pageError, this); } + clearPageErrors() { + this._pageErrors.length = 0; + } + pageErrors() { return this._pageErrors; } diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 55042c86d955c..974e31bc335ed 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -133,6 +133,7 @@ export const methodMetainfo = new Map; + /** + * Clears all stored page errors from this page. Subsequent calls to + * [page.pageErrors()](https://playwright.dev/docs/api/class-page#page-page-errors) will only return errors thrown + * after the clear. + */ + clearPageErrors(): Promise; + /** * **NOTE** Use locator-based [locator.click([options])](https://playwright.dev/docs/api/class-locator#locator-click) instead. * Read more about [locators](https://playwright.dev/docs/locators). diff --git a/packages/playwright/src/mcp/terminal/DEPS.list b/packages/playwright/src/cli/client/DEPS.list similarity index 56% rename from packages/playwright/src/mcp/terminal/DEPS.list rename to packages/playwright/src/cli/client/DEPS.list index 6827d182e4fd1..0f79a6e8a2f2d 100644 --- a/packages/playwright/src/mcp/terminal/DEPS.list +++ b/packages/playwright/src/cli/client/DEPS.list @@ -1,12 +1,3 @@ -[daemon.ts] -../browser/browserServerBackend.ts -../browser/tools -../cli/socketConnection.ts - -[cli.ts] -"strict" -./program.ts - [program.ts] "strict" ./session.ts diff --git a/packages/playwright/src/mcp/terminal/appIcon.png b/packages/playwright/src/cli/client/appIcon.png similarity index 100% rename from packages/playwright/src/mcp/terminal/appIcon.png rename to packages/playwright/src/cli/client/appIcon.png diff --git a/packages/playwright/src/mcp/terminal/devtoolsApp.ts b/packages/playwright/src/cli/client/devtoolsApp.ts similarity index 100% rename from packages/playwright/src/mcp/terminal/devtoolsApp.ts rename to packages/playwright/src/cli/client/devtoolsApp.ts diff --git a/packages/playwright/src/mcp/terminal/program.ts b/packages/playwright/src/cli/client/program.ts similarity index 96% rename from packages/playwright/src/mcp/terminal/program.ts rename to packages/playwright/src/cli/client/program.ts index 405e2f33ee387..0478304641295 100644 --- a/packages/playwright/src/mcp/terminal/program.ts +++ b/packages/playwright/src/cli/client/program.ts @@ -25,7 +25,7 @@ import path from 'path'; import { createClientInfo, Registry } from './registry'; import { Session, renderResolvedConfig } from './session'; -import type { Config } from '../config'; +import type { Config } from '../../mcp/config'; import type { SessionConfig, ClientInfo } from './registry'; type MinimistArgs = { @@ -74,7 +74,7 @@ const booleanOptions: (keyof (GlobalOptions & OpenOptions & { all?: boolean }))[ 'version', ]; -export async function program() { +async function program() { const clientInfo = createClientInfo(); const help = require('./help.json'); @@ -327,7 +327,7 @@ async function killAllDaemons(): Promise { const result = execSync( `powershell -NoProfile -NonInteractive -Command ` + `"Get-CimInstance Win32_Process ` - + `| Where-Object { $_.CommandLine -like '*run-mcp-server*' -and $_.CommandLine -like '*--daemon-session*' } ` + + `| Where-Object { $_.CommandLine -like '*-server*' -and $_.CommandLine -like '*--daemon-session*' } ` + `| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $_.ProcessId }"`, { encoding: 'utf-8' } ); @@ -341,7 +341,7 @@ async function killAllDaemons(): Promise { const result = execSync('ps aux', { encoding: 'utf-8' }); const lines = result.split('\n'); for (const line of lines) { - if (line.includes('run-mcp-server') && line.includes('--daemon-session')) { + if ((line.includes('-server')) && line.includes('--daemon-session')) { const parts = line.trim().split(/\s+/); const pid = parts[1]; if (pid && /^\d+$/.test(pid)) { @@ -424,3 +424,10 @@ async function renderSessionStatus(session: Session) { text.push(...renderResolvedConfig(config.resolvedConfig)); return text.join('\n'); } + +program().catch(e => { + /* eslint-disable no-console */ + console.error(e.message); + /* eslint-disable no-restricted-properties */ + process.exit(1); +}); diff --git a/packages/playwright/src/mcp/terminal/registry.ts b/packages/playwright/src/cli/client/registry.ts similarity index 98% rename from packages/playwright/src/mcp/terminal/registry.ts rename to packages/playwright/src/cli/client/registry.ts index e03eae6474d0b..15c69ad517055 100644 --- a/packages/playwright/src/mcp/terminal/registry.ts +++ b/packages/playwright/src/cli/client/registry.ts @@ -19,7 +19,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import type { FullConfig } from '../browser/config'; +import type { FullConfig } from '../../mcp/browser/config'; export type ClientInfo = { version: string; diff --git a/packages/playwright/src/mcp/terminal/session.ts b/packages/playwright/src/cli/client/session.ts similarity index 97% rename from packages/playwright/src/mcp/terminal/session.ts rename to packages/playwright/src/cli/client/session.ts index 60daf0ebed8ae..c336eaba1e2bb 100644 --- a/packages/playwright/src/mcp/terminal/session.ts +++ b/packages/playwright/src/cli/client/session.ts @@ -23,9 +23,9 @@ import fs from 'fs'; import net from 'net'; import os from 'os'; import path from 'path'; -import { SocketConnection } from './socketConnection'; +import { compareSemver, SocketConnection } from './socketConnection'; -import type { FullConfig } from '../browser/config'; +import type { FullConfig } from '../../mcp/browser/config'; import type { SessionConfig, ClientInfo } from './registry'; type MinimistArgs = { @@ -48,7 +48,7 @@ export class Session { } isCompatible(): boolean { - return this._clientInfo.version === this.config.version; + return compareSemver(this._clientInfo.version, this.config.version) >= 0; } checkCompatible() { @@ -212,7 +212,7 @@ to restart the browser session.`); const args = [ cliPath, - 'run-mcp-server', + 'run-cli-server', `--daemon-session=${sessionConfigFile}`, ]; diff --git a/packages/playwright/src/mcp/terminal/socketConnection.ts b/packages/playwright/src/cli/client/socketConnection.ts similarity index 97% rename from packages/playwright/src/mcp/terminal/socketConnection.ts rename to packages/playwright/src/cli/client/socketConnection.ts index 9ea6dc5761cee..7ebaa01519c23 100644 --- a/packages/playwright/src/mcp/terminal/socketConnection.ts +++ b/packages/playwright/src/cli/client/socketConnection.ts @@ -89,8 +89,8 @@ export class SocketConnection { } export function compareSemver(a: string, b: string): number { - a = a.replace(/-[\w]+$/, ''); - b = b.replace(/-[\w]+$/, ''); + a = a.replace(/-.*$/, ''); + b = b.replace(/-.*$/, ''); const aParts = a.split('.').map(Number); const bParts = b.split('.').map(Number); for (let i = 0; i < 3; i++) { diff --git a/packages/playwright/src/cli/daemon/DEPS.list b/packages/playwright/src/cli/daemon/DEPS.list new file mode 100644 index 0000000000000..0d46ee2f4fd02 --- /dev/null +++ b/packages/playwright/src/cli/daemon/DEPS.list @@ -0,0 +1,4 @@ +[*] +../../mcp/browser/** +../../mcp/extension/** +../client/socketConnection.ts diff --git a/packages/playwright/src/mcp/terminal/command.ts b/packages/playwright/src/cli/daemon/command.ts similarity index 100% rename from packages/playwright/src/mcp/terminal/command.ts rename to packages/playwright/src/cli/daemon/command.ts diff --git a/packages/playwright/src/mcp/terminal/commands.ts b/packages/playwright/src/cli/daemon/commands.ts similarity index 100% rename from packages/playwright/src/mcp/terminal/commands.ts rename to packages/playwright/src/cli/daemon/commands.ts diff --git a/packages/playwright/src/mcp/terminal/daemon.ts b/packages/playwright/src/cli/daemon/daemon.ts similarity index 93% rename from packages/playwright/src/mcp/terminal/daemon.ts rename to packages/playwright/src/cli/daemon/daemon.ts index 321e9f790b007..25945300b4a39 100644 --- a/packages/playwright/src/mcp/terminal/daemon.ts +++ b/packages/playwright/src/cli/daemon/daemon.ts @@ -23,14 +23,14 @@ import url from 'url'; import { debug } from 'playwright-core/lib/utilsBundle'; import { gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils'; -import { BrowserServerBackend } from '../browser/browserServerBackend'; -import { SocketConnection } from './socketConnection'; +import { BrowserServerBackend } from '../../mcp/browser/browserServerBackend'; +import { SocketConnection } from '../client/socketConnection'; import { commands } from './commands'; import { parseCommand } from './command'; -import type * as mcp from '../sdk/exports'; -import type { BrowserContextFactory } from '../browser/browserContextFactory'; -import type { FullConfig } from '../browser/config'; +import type * as mcp from '../../mcp/sdk/exports'; +import type { BrowserContextFactory } from '../../mcp/browser/browserContextFactory'; +import type { FullConfig } from '../../mcp/browser/config'; const daemonDebug = debug('pw:daemon'); diff --git a/packages/playwright/src/mcp/terminal/helpGenerator.ts b/packages/playwright/src/cli/daemon/helpGenerator.ts similarity index 98% rename from packages/playwright/src/mcp/terminal/helpGenerator.ts rename to packages/playwright/src/cli/daemon/helpGenerator.ts index 800ee611555da..d5e03406d91ff 100644 --- a/packages/playwright/src/mcp/terminal/helpGenerator.ts +++ b/packages/playwright/src/cli/daemon/helpGenerator.ts @@ -16,10 +16,10 @@ import { z } from 'playwright-core/lib/mcpBundle'; -import { commands } from './commands'; +import { commands } from '../daemon/commands'; import type zodType from 'zod'; -import type { AnyCommandSchema, Category } from './command'; +import type { AnyCommandSchema, Category } from '../daemon/command'; type CommandArg = { name: string, description: string, optional: boolean }; diff --git a/packages/playwright/src/cli/daemon/program.ts b/packages/playwright/src/cli/daemon/program.ts new file mode 100644 index 0000000000000..c25ef796a2477 --- /dev/null +++ b/packages/playwright/src/cli/daemon/program.ts @@ -0,0 +1,107 @@ +/** + * 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. + */ + +/* eslint-disable no-console */ + +import fs from 'fs'; + +import { startMcpDaemonServer } from './daemon'; +import { setupExitWatchdog } from '../../mcp/browser/watchdog'; +import { contextFactory } from '../../mcp/browser/browserContextFactory'; +import { ExtensionContextFactory } from '../../mcp/extension/extensionContextFactory'; +import { configFromCLIOptions, configFromEnv, defaultConfig, loadConfig, mergeConfig, validateConfig } from '../../mcp/browser/config'; + +import type { Command } from 'playwright-core/lib/utilsBundle'; +import type { SessionConfig } from '../client/registry'; +import type { FullConfig } from '../../mcp/browser/config'; + +export function decorateCLICommand(command: Command, version: string) { + command + .version(version) + .option('--daemon-session ', 'path to the daemon config.') + .action(async options => { + // normalize the --no-chromium-sandbox option: chromiumSandbox = true => nothing was passed, chromiumSandbox = false => --no-chromium-sandbox was passed. + options.chromiumSandbox = options.chromiumSandbox === true ? undefined : false; + setupExitWatchdog(); + + const config = await resolveCLIConfig(options.daemonSession); + const browserContextFactory = contextFactory(config); + const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath); + + const cf = config.extension ? extensionContextFactory : browserContextFactory; + try { + const socketPath = await startMcpDaemonServer(config, cf); + console.log(`### Config`); + console.log('```json'); + console.log(JSON.stringify(config, null, 2)); + console.log('```'); + console.log(`### Success\nDaemon listening on ${socketPath}`); + console.log(''); + } catch (error) { + const message = process.env.PWDEBUGIMPL ? (error as Error).stack || (error as Error).message : (error as Error).message; + console.log(`### Error\n${message}`); + console.log(''); + } + }); +} + +export async function resolveCLIConfig(daemonSession: string): Promise { + const sessionConfig = await fs.promises.readFile(daemonSession, 'utf-8').then(data => JSON.parse(data) as SessionConfig); + const daemonOverrides = configFromCLIOptions({ + config: sessionConfig.cli.config, + browser: sessionConfig.cli.browser, + isolated: sessionConfig.cli.persistent === true ? false : undefined, + headless: sessionConfig.cli.headed ? false : undefined, + extension: sessionConfig.cli.extension, + userDataDir: sessionConfig.cli.profile, + outputMode: 'file', + snapshotMode: 'full', + }); + + const envOverrides = configFromEnv(); + const configFile = envOverrides.configFile ?? daemonOverrides.configFile; + const configInFile = await loadConfig(configFile); + + let result = mergeConfig(defaultConfig, { + browser: { + launchOptions: { + headless: true, + }, + isolated: true, + } + }); + + result = mergeConfig(result, configInFile); + result = mergeConfig(result, daemonOverrides); + result = mergeConfig(result, envOverrides); + + if (!result.extension && !result.browser.userDataDir && sessionConfig.userDataDirPrefix) { + // No custom value provided, use the daemon data dir. + const browserToken = result.browser.launchOptions?.channel ?? result.browser?.browserName; + const userDataDir = `${sessionConfig.userDataDirPrefix}-${browserToken}`; + result.browser.userDataDir = userDataDir; + } + + result.configFile = configFile; + result.sessionConfig = sessionConfig; + result.skillMode = true; + if (result.sessionConfig && result.browser.launchOptions.headless !== false) + result.browser.contextOptions.viewport ??= { width: 1280, height: 720 }; + + await validateConfig(result); + + return result; +} diff --git a/packages/playwright/src/mcp/browser/config.ts b/packages/playwright/src/mcp/browser/config.ts index 4f4bb5fff72df..9b3e189cfd2c7 100644 --- a/packages/playwright/src/mcp/browser/config.ts +++ b/packages/playwright/src/mcp/browser/config.ts @@ -18,15 +18,17 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { registry } from 'playwright-core/lib/server'; import { devices } from 'playwright-core'; import { dotenv, debug } from 'playwright-core/lib/utilsBundle'; + import { fileExistsAsync } from '../../util'; import { firstRootPath } from '../sdk/server'; import type * as playwright from '../../../types/test'; import type { Config, ToolCapability } from '../config'; import type { ClientInfo } from '../sdk/server'; -import type { SessionConfig } from '../terminal/registry'; +import type { SessionConfig } from '../../cli/client/registry'; type ViewportSize = { width: number; height: number }; @@ -45,7 +47,6 @@ export type CLIOptions = { codegen?: 'typescript' | 'none'; config?: string; consoleLevel?: 'error' | 'warning' | 'info' | 'debug'; - daemonSession?: string; device?: string; extension?: boolean; executablePath?: string; @@ -108,15 +109,6 @@ export const defaultConfig: FullConfig = { }, }; -const defaultDaemonConfig: FullConfig = mergeConfig(defaultConfig, { - browser: { - launchOptions: { - headless: true, - }, - isolated: true, - } -}); - type BrowserUserConfig = NonNullable; export type FullConfig = Config & { @@ -151,48 +143,38 @@ export async function resolveConfig(config: Config): Promise { export async function resolveCLIConfig(cliOptions: CLIOptions): Promise { const envOverrides = configFromEnv(); - const daemonOverrides = await configForDaemonSession(cliOptions); const cliOverrides = configFromCLIOptions(cliOptions); - const configFile = cliOverrides.configFile ?? envOverrides.configFile ?? daemonOverrides.configFile; + const configFile = cliOverrides.configFile ?? envOverrides.configFile; const configInFile = await loadConfig(configFile); - let result = cliOptions.daemonSession ? defaultDaemonConfig : defaultConfig; + let result = defaultConfig; result = mergeConfig(result, configInFile); - result = mergeConfig(result, daemonOverrides); result = mergeConfig(result, envOverrides); result = mergeConfig(result, cliOverrides); + result.configFile = configFile; + await validateConfig(result); + return result; +} - if (daemonOverrides.sessionConfig) { - result.skillMode = true; - - if (!result.extension && !result.browser.userDataDir && daemonOverrides.sessionConfig.userDataDirPrefix) { - // No custom value provided, use the daemon data dir. - const browserToken = result.browser.launchOptions?.channel ?? result.browser?.browserName; - const userDataDir = `${daemonOverrides.sessionConfig.userDataDirPrefix}-${browserToken}`; - result.browser.userDataDir = userDataDir; - } - } - - if (result.browser.browserName === 'chromium' && result.browser.launchOptions.chromiumSandbox === undefined) { +export async function validateConfig(config: FullConfig): Promise { + if (config.browser.browserName === 'chromium' && config.browser.launchOptions.chromiumSandbox === undefined) { if (process.platform === 'linux') - result.browser.launchOptions.chromiumSandbox = result.browser.launchOptions.channel !== 'chromium'; + config.browser.launchOptions.chromiumSandbox = config.browser.launchOptions.channel !== 'chromium'; else - result.browser.launchOptions.chromiumSandbox = true; + config.browser.launchOptions.chromiumSandbox = true; } - result.configFile = configFile; - result.sessionConfig = daemonOverrides.sessionConfig; - - // Daemon has different defaults. - if (result.sessionConfig && result.browser.launchOptions.headless !== false) - result.browser.contextOptions.viewport ??= { width: 1280, height: 720 }; - - await validateConfig(result); - - return result; -} + if (config.saveVideo && !checkFfmpeg()) { + // eslint-disable-next-line no-console + console.error(`\nError: ffmpeg required to save the video is not installed.`); + // eslint-disable-next-line no-console + console.error(`\nPlease run the command below. It will install a local copy of ffmpeg and will not change any system-wide settings.`); + // eslint-disable-next-line no-console + console.error(`\n npx playwright install ffmpeg\n`); + // eslint-disable-next-line no-restricted-properties + process.exit(1); + } -async function validateConfig(config: FullConfig): Promise { if (config.browser.initScript) { for (const script of config.browser.initScript) { if (!await fileExistsAsync(script)) @@ -324,7 +306,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config & { configF return { ...config, configFile: cliOptions.config }; } -function configFromEnv(): Config & { configFile?: string } { +export function configFromEnv(): Config & { configFile?: string } { const options: CLIOptions = {}; options.allowedHosts = commaSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_HOSTNAMES); options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS); @@ -373,25 +355,7 @@ function configFromEnv(): Config & { configFile?: string } { return configFromCLIOptions(options); } -async function configForDaemonSession(cliOptions: CLIOptions): Promise { - if (!cliOptions.daemonSession) - return {}; - - const sessionConfig = await fs.promises.readFile(cliOptions.daemonSession, 'utf-8').then(data => JSON.parse(data) as SessionConfig); - const config = configFromCLIOptions({ - config: sessionConfig.cli.config, - browser: sessionConfig.cli.browser, - isolated: sessionConfig.cli.persistent === true ? false : undefined, - headless: sessionConfig.cli.headed ? false : undefined, - extension: sessionConfig.cli.extension, - userDataDir: sessionConfig.cli.profile, - outputMode: 'file', - snapshotMode: 'full', - }); - return { ...config, sessionConfig }; -} - -async function loadConfig(configFile: string | undefined): Promise { +export async function loadConfig(configFile: string | undefined): Promise { if (!configFile) return {}; @@ -451,7 +415,7 @@ function pickDefined(obj: T | undefined): Partial { ) as Partial; } -function mergeConfig(base: FullConfig, overrides: Config): FullConfig { +export function mergeConfig(base: FullConfig, overrides: Config): FullConfig { const browser: FullConfig['browser'] = { ...pickDefined(base.browser), ...pickDefined(overrides.browser), @@ -569,3 +533,12 @@ function envToBoolean(value: string | undefined): boolean | undefined { function envToString(value: string | undefined): string | undefined { return value ? value.trim() : undefined; } + +function checkFfmpeg(): boolean { + try { + const executable = registry.findExecutable('ffmpeg')!; + return fs.existsSync(executable.executablePath()!); + } catch (error) { + return false; + } +} diff --git a/packages/playwright/src/mcp/browser/tab.ts b/packages/playwright/src/mcp/browser/tab.ts index b9a9cf993611b..7472db257d333 100644 --- a/packages/playwright/src/mcp/browser/tab.ts +++ b/packages/playwright/src/mcp/browser/tab.ts @@ -91,7 +91,6 @@ export class Tab extends EventEmitter { readonly context: Context; readonly page: Page; private _lastHeader: TabHeader = { title: 'about:blank', url: 'about:blank', current: false, console: { total: 0, warnings: 0, errors: 0 } }; - private _consoleMessages: ConsoleMessage[] = []; private _downloads: Download[] = []; private _requests: playwright.Request[] = []; private _onPageClose: (tab: Tab) => void; @@ -201,7 +200,6 @@ export class Tab extends EventEmitter { } private _clearCollectedArtifacts() { - this._consoleMessages.length = 0; this._downloads.length = 0; this._requests.length = 0; this._recentEventEntries.length = 0; @@ -236,7 +234,6 @@ export class Tab extends EventEmitter { } private _handleConsoleMessage(message: ConsoleMessage) { - this._consoleMessages.push(message); const wallTime = message.timestamp; this._addLogEntry({ type: 'console', wallTime, message }); const level = consoleLevelForMessageType(message.type); @@ -283,6 +280,8 @@ export class Tab extends EventEmitter { async navigate(url: string) { await this._initializedPromise; + + await this.clearConsoleMessages(); this._clearCollectedArtifacts(); const { promise: downloadEvent, abort: abortDownloadEvent } = eventWaiter(this.page, 'download', 3000); @@ -311,25 +310,42 @@ export class Tab extends EventEmitter { async consoleMessageCount(): Promise<{ total: number, errors: number, warnings: number }> { await this._initializedPromise; - let errors = 0; + const messages = await this.page.consoleMessages(); + const pageErrors = await this.page.pageErrors(); + let errors = pageErrors.length; let warnings = 0; - for (const message of this._consoleMessages) { - if (message.type === 'error') + for (const message of messages) { + if (message.type() === 'error') errors++; - else if (message.type === 'warning') + else if (message.type() === 'warning') warnings++; } - return { total: this._consoleMessages.length, errors, warnings }; + return { total: messages.length + pageErrors.length, errors, warnings }; } async consoleMessages(level: ConsoleMessageLevel): Promise { await this._initializedPromise; - return this._consoleMessages.filter(message => shouldIncludeMessage(level, message.type)); + const result: ConsoleMessage[] = []; + const messages = await this.page.consoleMessages(); + for (const message of messages) { + const cm = messageToConsoleMessage(message); + if (shouldIncludeMessage(level, cm.type)) + result.push(cm); + } + if (shouldIncludeMessage(level, 'error')) { + const errors = await this.page.pageErrors(); + for (const error of errors) + result.push(pageErrorToConsoleMessage(error)); + } + return result; } async clearConsoleMessages() { await this._initializedPromise; - this._consoleMessages.length = 0; + await Promise.all([ + this.page.clearConsoleMessages(), + this.page.clearPageErrors() + ]); } async requests(): Promise { diff --git a/packages/playwright/src/mcp/program.ts b/packages/playwright/src/mcp/program.ts index bb515bee37e3a..fda9347a8dce2 100644 --- a/packages/playwright/src/mcp/program.ts +++ b/packages/playwright/src/mcp/program.ts @@ -14,15 +14,9 @@ * limitations under the License. */ -/* eslint-disable no-console */ - -import fs from 'fs'; - -import { colors, ProgramOption } from 'playwright-core/lib/utilsBundle'; -import { registry } from 'playwright-core/lib/server'; +import { ProgramOption } from 'playwright-core/lib/utilsBundle'; import * as mcpServer from './sdk/server'; -import { startMcpDaemonServer } from './terminal/daemon'; import { commaSeparatedList, dotenvFileLoader, enumParser, headerParser, numberParser, resolutionParser, resolveCLIConfig, semicolonSeparatedList } from './browser/config'; import { setupExitWatchdog } from './browser/watchdog'; import { contextFactory } from './browser/browserContextFactory'; @@ -31,7 +25,7 @@ import { ExtensionContextFactory } from './extension/extensionContextFactory'; import type { Command } from 'playwright-core/lib/utilsBundle'; -export function decorateCommand(command: Command, version: string) { +export function decorateMCPCommand(command: Command, version: string) { command .option('--allowed-hosts ', 'comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass \'*\' to disable the host check.', commaSeparatedList) .option('--allowed-origins ', 'semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all.\nImportant: *does not* serve as a security boundary and *does not* affect redirects. ', semicolonSeparatedList) @@ -78,7 +72,6 @@ export function decorateCommand(command: Command, version: string) { .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280x720"', resolutionParser.bind(null, '--viewport-size')) .addOption(new ProgramOption('--vision', 'Legacy option, use --caps=vision instead').hideHelp()) - .addOption(new ProgramOption('--daemon-session ', 'path to the daemon config.').hideHelp()) .action(async options => { // normalize the --no-chromium-sandbox option: chromiumSandbox = true => nothing was passed, chromiumSandbox = false => --no-chromium-sandbox was passed. @@ -87,6 +80,7 @@ export function decorateCommand(command: Command, version: string) { setupExitWatchdog(); if (options.vision) { + // eslint-disable-next-line no-console console.error('The --vision option is deprecated, use --caps=vision instead'); options.caps = 'vision'; } @@ -95,37 +89,9 @@ export function decorateCommand(command: Command, version: string) { options.caps.push('devtools'); const config = await resolveCLIConfig(options); - - // Chromium browsers require ffmpeg to be installed to save video. - if (config.saveVideo && !checkFfmpeg()) { - console.error(colors.red(`\nError: ffmpeg required to save the video is not installed.`)); - console.error(`\nPlease run the command below. It will install a local copy of ffmpeg and will not change any system-wide settings.`); - console.error(`\n npx playwright install ffmpeg\n`); - // eslint-disable-next-line no-restricted-properties - process.exit(1); - } - const browserContextFactory = contextFactory(config); const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath); - if (config.sessionConfig) { - const contextFactory = config.extension ? extensionContextFactory : browserContextFactory; - try { - const socketPath = await startMcpDaemonServer(config, contextFactory); - console.log(`### Config`); - console.log('```json'); - console.log(JSON.stringify(config, null, 2)); - console.log('```'); - console.log(`### Success\nDaemon listening on ${socketPath}`); - console.log(''); - } catch (error) { - const message = process.env.PWDEBUGIMPL ? (error as Error).stack || (error as Error).message : (error as Error).message; - console.log(`### Error\n${message}`); - console.log(''); - } - return; - } - if (config.extension) { const serverBackendFactory: mcpServer.ServerBackendFactory = { name: 'Playwright w/ extension', @@ -146,12 +112,3 @@ export function decorateCommand(command: Command, version: string) { await mcpServer.start(factory, config.server); }); } - -function checkFfmpeg(): boolean { - try { - const executable = registry.findExecutable('ffmpeg')!; - return fs.existsSync(executable.executablePath()!); - } catch (error) { - return false; - } -} diff --git a/packages/playwright/src/mcp/sdk/server.ts b/packages/playwright/src/mcp/sdk/server.ts index 9346c720a865c..aa5e5428b9edf 100644 --- a/packages/playwright/src/mcp/sdk/server.ts +++ b/packages/playwright/src/mcp/sdk/server.ts @@ -104,7 +104,7 @@ export function createServer(name: string, version: string, backend: ServerBacke total: params.total, message: params.message, }, - }).catch(serverDebug); + }).catch(e => serverDebug('notification', e)); } : () => {}; try { diff --git a/packages/playwright/src/mcp/terminal/cli.ts b/packages/playwright/src/mcp/terminal/cli.ts deleted file mode 100644 index 6db3e40ee050b..0000000000000 --- a/packages/playwright/src/mcp/terminal/cli.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 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 { program } from './program'; - -program().catch(e => { - /* eslint-disable no-console */ - console.error(e.message); - /* eslint-disable no-restricted-properties */ - process.exit(1); -}); diff --git a/packages/playwright/src/mcp/terminal/icon.ico b/packages/playwright/src/mcp/terminal/icon.ico deleted file mode 100644 index 30bf01f48566b..0000000000000 Binary files a/packages/playwright/src/mcp/terminal/icon.ico and /dev/null differ diff --git a/packages/playwright/src/mcp/terminal/icon.png b/packages/playwright/src/mcp/terminal/icon.png deleted file mode 100644 index 40eab8ca05d09..0000000000000 Binary files a/packages/playwright/src/mcp/terminal/icon.png and /dev/null differ diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index da4ad0f6483e3..5059c633d5d19 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -35,8 +35,9 @@ import { runAllTestsWithConfig, TestRunner } from './runner/testRunner'; import { createErrorCollectingReporter } from './runner/reporters'; import * as mcp from './mcp/sdk/exports'; import { TestServerBackend } from './mcp/test/testBackend'; -import { decorateCommand } from './mcp/program'; +import { decorateMCPCommand } from './mcp/program'; import { setupExitWatchdog } from './mcp/browser/watchdog'; +import { decorateCLICommand } from './cli/daemon/program'; import { ClaudeGenerator, OpencodeGenerator, VSCodeGenerator, CopilotGenerator } from './agents/generateAgents'; import type { ConfigCLIOverrides } from './common/ipc'; @@ -150,7 +151,13 @@ Examples: function addBrowserMCPServerCommand(program: Command) { const command = program.command('run-mcp-server', { hidden: true }); command.description('Interact with the browser over MCP'); - decorateCommand(command, packageJSON.version); + decorateMCPCommand(command, packageJSON.version); +} + +function addBrowserCLIServerCommand(program: Command) { + const command = program.command('run-cli-server', { hidden: true }); + command.description('Interact with the browser over CLI'); + decorateCLICommand(command, packageJSON.version); } function addTestMCPServerCommand(program: Command) { @@ -445,6 +452,7 @@ addShowReportCommand(program); addMergeReportsCommand(program); addClearCacheCommand(program); addBrowserMCPServerCommand(program); +addBrowserCLIServerCommand(program); addTestMCPServerCommand(program); addDevServerCommand(program); addTestServerCommand(program); diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 40cd7dd77e9cb..053c690d2b743 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2098,6 +2098,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { mouseClick(params: PageMouseClickParams, progress?: Progress): Promise; mouseWheel(params: PageMouseWheelParams, progress?: Progress): Promise; touchscreenTap(params: PageTouchscreenTapParams, progress?: Progress): Promise; + clearPageErrors(params?: PageClearPageErrorsParams, progress?: Progress): Promise; pageErrors(params?: PagePageErrorsParams, progress?: Progress): Promise; pdf(params: PagePdfParams, progress?: Progress): Promise; requests(params?: PageRequestsParams, progress?: Progress): Promise; @@ -2495,6 +2496,9 @@ export type PageTouchscreenTapOptions = { }; export type PageTouchscreenTapResult = void; +export type PageClearPageErrorsParams = {}; +export type PageClearPageErrorsOptions = {}; +export type PageClearPageErrorsResult = void; export type PagePageErrorsParams = {}; export type PagePageErrorsOptions = {}; export type PagePageErrorsResult = { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index feeadf0d14842..8ae8ed0a32f3d 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1920,6 +1920,9 @@ Page: snapshot: true pausesBeforeAction: true + clearPageErrors: + title: Clear page errors + pageErrors: title: Get page errors group: getter diff --git a/tests/mcp/cli-fixtures.ts b/tests/mcp/cli-fixtures.ts index eaef8a977449d..b90ed6d251093 100644 --- a/tests/mcp/cli-fixtures.ts +++ b/tests/mcp/cli-fixtures.ts @@ -62,7 +62,7 @@ async function runCli(childProcess: CommonFixtures['childProcess'], args: string return await test.step(stepTitle, async () => { const testInfo = test.info(); const cli = childProcess({ - command: [process.execPath, require.resolve('../../packages/playwright/lib/mcp/terminal/cli.js'), ...args], + command: [process.execPath, require.resolve('../../packages/playwright/lib/cli/client/program.js'), ...args], cwd: cliOptions.cwd ?? testInfo.outputPath(), env: { ...process.env, diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index e437530225573..4c0e72498c213 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -208,32 +208,39 @@ test('list --all lists sessions from all workspaces', async ({ cli, server }, te await cli('-s', 'session2', 'close', { cwd: workspace2 }); }); -test('incompatible version - command fails with version mismatch error', async ({ cli, server }) => { +test('newer client with older daemon is compatible', async ({ cli, server }) => { await cli('open', server.HELLO_WORLD); - const { output, error, exitCode } = await cli('eval', '1+1', { env: { PLAYWRIGHT_CLI_VERSION_FOR_TEST: '9.9.9' } }); + const { exitCode } = await cli('eval', '1+1', { env: { PLAYWRIGHT_CLI_VERSION_FOR_TEST: '9.9.9' } }); + expect(exitCode).toBe(0); +}); + +test('older client with newer daemon fails with version mismatch error', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + + const { output, error, exitCode } = await cli('eval', '1+1', { env: { PLAYWRIGHT_CLI_VERSION_FOR_TEST: '0.0.1' } }); expect(exitCode).not.toBe(0); const fullOutput = output + error; - expect(fullOutput).toContain('Client is v9.9.9'); + expect(fullOutput).toContain('Client is v0.0.1'); expect(fullOutput).toContain('playwright-cli open'); expect(fullOutput).toContain('to restart the browser session'); }); -test('incompatible version - named session includes session name in error', async ({ cli, server }) => { +test('older client with newer daemon - named session includes session name in error', async ({ cli, server }) => { await cli('-s', 'mysession', 'open', server.HELLO_WORLD); - const { output, error, exitCode } = await cli('-s', 'mysession', 'eval', '1+1', { env: { PLAYWRIGHT_CLI_VERSION_FOR_TEST: '9.9.9' } }); + const { output, error, exitCode } = await cli('-s', 'mysession', 'eval', '1+1', { env: { PLAYWRIGHT_CLI_VERSION_FOR_TEST: '0.0.1' } }); expect(exitCode).not.toBe(0); const fullOutput = output + error; - expect(fullOutput).toContain('Client is v9.9.9'); + expect(fullOutput).toContain('Client is v0.0.1'); expect(fullOutput).toContain(`session 'mysession'`); expect(fullOutput).toContain('playwright-cli -s=mysession open'); }); -test('incompatible version - list shows incompatible warning', async ({ cli, server }) => { +test('older client with newer daemon - list shows incompatible warning', async ({ cli, server }) => { await cli('open', server.HELLO_WORLD); - const { output } = await cli('list', { env: { PLAYWRIGHT_CLI_VERSION_FOR_TEST: '9.9.9' } }); + const { output } = await cli('list', { env: { PLAYWRIGHT_CLI_VERSION_FOR_TEST: '0.0.1' } }); expect(output).toContain('### Browsers'); expect(output).toContain('- default:'); expect(output).toContain('[incompatible please re-open]'); diff --git a/tests/page/page-event-pageerror.spec.ts b/tests/page/page-event-pageerror.spec.ts index 2f31f554c3443..116805b63f515 100644 --- a/tests/page/page-event-pageerror.spec.ts +++ b/tests/page/page-event-pageerror.spec.ts @@ -169,6 +169,32 @@ it('pageErrors should work', async ({ page }) => { expect(messages.slice(messages.length - expected.length), 'should return last errors').toEqual(expected); }); +it('clearPageErrors should work', async ({ page }) => { + await page.evaluate(() => { + window.builtins.setTimeout(() => { throw new Error('error1'); }, 0); + window.builtins.setTimeout(() => { throw new Error('error2'); }, 0); + }); + await page.waitForTimeout(1000); + + let errors = await page.pageErrors(); + expect(errors.map(e => e.message)).toContain('error1'); + expect(errors.map(e => e.message)).toContain('error2'); + + await page.clearPageErrors(); + + errors = await page.pageErrors(); + expect(errors).toEqual([]); + + await page.evaluate(() => { + window.builtins.setTimeout(() => { throw new Error('error3'); }, 0); + }); + await page.waitForTimeout(1000); + + errors = await page.pageErrors(); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain('error3'); +}); + it('should fire illegal character error', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/38388' }, }, async ({ page, server, browserName, isWindows }) => { diff --git a/tests/tsconfig.json b/tests/tsconfig.json index a921b85a096a7..2ffc326c747ae 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -11,6 +11,7 @@ "useUnknownInCatchVariables": false, "baseUrl": "..", "paths": { + "@dvtools/*": ["packages/devtools/src/*"], "@isomorphic/*": ["packages/playwright-core/src/utils/isomorphic/*"], "@testIsomorphic/*": ["packages/playwright/src/isomorphic/*"], "@protocol/*": ["packages/protocol/src/*"], diff --git a/tsconfig.json b/tsconfig.json index 268c30e76b9ce..2b8a7d80edf49 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ - @foo/* is for importing types only, - .../lib/* means require dependency */ + "@devtools/*": ["./packages/devtools/src/*"], "@html-reporter/*": ["./packages/html-reporter/src/*"], "@injected/*": ["./packages/injected/src/*"], "@isomorphic/*": ["./packages/playwright-core/src/utils/isomorphic/*"], diff --git a/utils/build/build.js b/utils/build/build.js index a58e99096e90f..543a3eb76fa15 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -654,7 +654,7 @@ copyFiles.push({ }); copyFiles.push({ - files: 'packages/playwright/src/mcp/terminal/*.{png,ico}', + files: 'packages/playwright/src/cli/client/*.{png,ico}', from: 'packages/playwright/src', to: 'packages/playwright/lib', }); diff --git a/utils/generate_cli_help.js b/utils/generate_cli_help.js index 4097f96e548fe..1bb4869dd05ae 100644 --- a/utils/generate_cli_help.js +++ b/utils/generate_cli_help.js @@ -19,7 +19,7 @@ const fs = require('fs') const path = require('path') -const { generateHelp, generateReadme, generateHelpJSON } = require('../packages/playwright/lib/mcp/terminal/helpGenerator.js'); +const { generateHelp, generateReadme, generateHelpJSON } = require('../packages/playwright/lib/cli/daemon/helpGenerator.js'); if (process.argv[2] === '--readme') { console.log(generateReadme()); @@ -31,6 +31,6 @@ if (process.argv[2] === '--print') { process.exit(0); } -const fileName = path.resolve(__dirname, '../packages/playwright/lib/mcp/terminal/help.json'); +const fileName = path.resolve(__dirname, '../packages/playwright/lib/cli/client/help.json'); console.log('Writing ', path.relative(process.cwd(), fileName)); fs.writeFileSync(fileName, JSON.stringify(generateHelpJSON(), null, 2));