diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 61f033cb979ca..311736f6399aa 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2722,6 +2722,11 @@ Returns whether the element is [visible](../actionability.md#visible). [`param: - type: <[Keyboard]> +## async method: Page.clearConsoleMessages +* since: v1.59 + +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.consoleMessages * since: v1.56 - returns: <[Array]<[ConsoleMessage]>> diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 9ba9bac94bc65..dbbbf92580e80 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -31,7 +31,7 @@ function tabFavicon(url: string): string { } } -export const DevTools: React.FC<{ wsUrl: string }> = ({ wsUrl }) => { +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(); @@ -55,6 +55,8 @@ export const DevTools: React.FC<{ wsUrl: string }> = ({ wsUrl }) => { }, [captured]); React.useEffect(() => { + if (!wsUrl) + return; const transport = new DevToolsTransport(wsUrl); transportRef.current = transport; diff --git a/packages/devtools/src/grid.css b/packages/devtools/src/grid.css index 4834c63084c4f..847571e769c67 100644 --- a/packages/devtools/src/grid.css +++ b/packages/devtools/src/grid.css @@ -43,12 +43,40 @@ .workspace-header { font-size: 13px; - font-weight: 600; color: var(--fg-dim); margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: flex; + align-items: center; +} + +.workspace-header.collapsible { + cursor: pointer; + user-select: none; +} + +.workspace-chevron { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 4px; + transition: transform 0.15s ease; +} + +.workspace-chevron.expanded { + transform: rotate(90deg); +} + +.workspace-name { + font-weight: 600; +} + +.workspace-path { + margin-left: 6px; + color: var(--fg-muted); + font-weight: 400; } .session-chips { @@ -106,7 +134,12 @@ color: var(--fg-muted); } -.session-chip-close { +.session-chip.disconnected { + opacity: 0.6; + cursor: default; +} + +.session-chip-action { width: 20px; height: 20px; border-radius: 50%; @@ -114,16 +147,16 @@ opacity: 0; } -.session-chip-close svg { - width: 10px; - height: 10px; +.session-chip-action svg { + width: 12px; + height: 12px; } -.session-chip:hover .session-chip-close { +.session-chip:hover .session-chip-action { opacity: 1; } -.session-chip-close:hover { +.session-chip-action:hover { background: rgba(255, 255, 255, 0.12); } diff --git a/packages/devtools/src/grid.tsx b/packages/devtools/src/grid.tsx index 9c2da1c18873f..47eab140fed81 100644 --- a/packages/devtools/src/grid.tsx +++ b/packages/devtools/src/grid.tsx @@ -20,113 +20,12 @@ import { navigate } from './index'; import { Screencast } from './screencast'; import type { SessionConfig } from '../../playwright/src/mcp/terminal/registry'; +import type { SessionModel, SessionStatus } from './sessionModel'; -type SessionStatus = { - config: SessionConfig; - canConnect: boolean; -}; - -export const Grid: React.FC = () => { - const [sessions, setSessions] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(); - const [screencastUrls, setScreencastUrls] = React.useState>({}); - - const lastJsonRef = React.useRef(''); - const knownTimestampsRef = React.useRef>(new Map()); - const startingRef = React.useRef>(new Set()); - - async function fetchSessions() { - try { - const response = await fetch('/api/sessions/list'); - if (!response.ok) - throw new Error(`HTTP ${response.status}`); - const text = await response.text(); - if (text !== lastJsonRef.current) { - lastJsonRef.current = text; - setSessions(JSON.parse(text)); - } - setError(undefined); - } catch (e: any) { - setError(e.message); - } finally { - setLoading(false); - } - } - - React.useEffect(() => { - let active = true; - let timeoutId: ReturnType; - async function poll() { - await fetchSessions(); - if (active) - timeoutId = setTimeout(poll, 3000); - } - poll(); - return () => { active = false; clearTimeout(timeoutId); }; - }, []); - - // Manage screencast lifecycle when sessions change. - React.useEffect(() => { - let active = true; - const liveSockets = new Set(); - - for (const { config, canConnect } of sessions) { - if (!canConnect) - continue; - const key = config.socketPath; - liveSockets.add(key); - - const known = knownTimestampsRef.current.get(key); - if (known === config.timestamp) - continue; - if (startingRef.current.has(key)) - continue; - - knownTimestampsRef.current.set(key, config.timestamp); - startingRef.current.add(key); - - void (async () => { - try { - const resp = await fetch('/api/sessions/start-screencast', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ config }), - }); - if (!resp.ok) - throw new Error(); - const { url } = await resp.json(); - if (active) - setScreencastUrls(prev => ({ ...prev, [key]: url })); - } catch { - knownTimestampsRef.current.delete(key); - } finally { - startingRef.current.delete(key); - } - })(); - } - - // Clean up sessions that are no longer live. - setScreencastUrls(prev => { - const next = { ...prev }; - let changed = false; - for (const key of Object.keys(next)) { - if (!liveSockets.has(key)) { - delete next[key]; - knownTimestampsRef.current.delete(key); - changed = true; - } - } - return changed ? next : prev; - }); - - return () => { active = false; }; - }, [sessions]); - - // Clear all screencasts on unmount. - React.useEffect(() => { - return () => setScreencastUrls({}); - }, []); +export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { + const [expandedWorkspaces, setExpandedWorkspaces] = React.useState>(new Set()); + const sessions = model.sessions; + const clientInfo = model.clientInfo; function browserLabel(config: SessionConfig): string { if (config.resolvedConfig) @@ -134,13 +33,17 @@ export const Grid: React.FC = () => { return config.cli.browser || 'chromium'; } - function headedLabel(config: SessionConfig): string { - if (config.resolvedConfig) - return config.resolvedConfig.browser.launchOptions.headless ? 'headless' : 'headed'; - return config.cli.headed ? 'headed' : 'headless'; + function toggleWorkspace(workspace: string) { + setExpandedWorkspaces(prev => { + const next = new Set(prev); + if (next.has(workspace)) + next.delete(workspace); + else + next.add(workspace); + return next; + }); } - const workspaceGroups = React.useMemo(() => { const groups = new Map(); for (const session of sessions) { @@ -154,59 +57,100 @@ export const Grid: React.FC = () => { } for (const list of groups.values()) list.sort((a, b) => a.config.name.localeCompare(b.config.name)); - return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); - }, [sessions]); + + // Current workspace first, then alphabetical. + const entries = [...groups.entries()]; + const current = entries.filter(([key]) => key === clientInfo?.workspaceDir); + const other = entries.filter(([key]) => key !== clientInfo?.workspaceDir).sort((a, b) => a[0].localeCompare(b[0])); + return [...current, ...other]; + }, [sessions, clientInfo?.workspaceDir]); + + function renderSessionChip(config: SessionConfig, canConnect: boolean, visible: boolean) { + const href = '#session=' + encodeURIComponent(config.socketPath); + const wsUrl = model.wsUrls.get(config.socketPath); + return ( + { + e.preventDefault(); if (canConnect) + navigate(href); + }}> +
+
+ {config.name} + {browserLabel(config)} + v{config.version} + {canConnect && ( + + )} + {!canConnect && ( + + )} +
+
+ {canConnect && visible && wsUrl && } + {!canConnect &&
Session closed
} +
+
+ ); + } return (
- {loading && sessions.length === 0 &&
Loading sessions...
} - {error &&
Error: {error}
} - {!loading && !error && sessions.length === 0 &&
No sessions found.
} + {model.loading && sessions.length === 0 &&
Loading sessions...
} + {model.error &&
Error: {model.error}
} + {!model.loading && !model.error && sessions.length === 0 &&
No sessions found.
}
- {workspaceGroups.map(([workspace, entries]) => ( -
-
{workspace}
-
- {entries.map(({ config, canConnect }) => { - const href = '#session=' + encodeURIComponent(config.socketPath); - return ( - { e.preventDefault(); navigate(href); }}> -
-
- {config.name} - {browserLabel(config)} - {headedLabel(config)} - v{config.version} - -
-
- {screencastUrls[config.socketPath] && ( - - )} -
-
- ); - })} + {workspaceGroups.map(([workspace, entries]) => { + const isCurrent = workspace === clientInfo?.workspaceDir; + const isExpanded = isCurrent || expandedWorkspaces.has(workspace); + return ( +
+
toggleWorkspace(workspace)} + > + {!isCurrent && ( + + + + )} + {workspace.split('/').pop() || workspace} + — {workspace} +
+ {isExpanded && ( +
+ {entries.map(({ config, canConnect }) => renderSessionChip(config, canConnect, isExpanded))} +
+ )}
-
- ))} + ); + })}
); }; diff --git a/packages/devtools/src/index.tsx b/packages/devtools/src/index.tsx index b7ea705e521ca..3c10d96c8ca26 100644 --- a/packages/devtools/src/index.tsx +++ b/packages/devtools/src/index.tsx @@ -19,8 +19,7 @@ import * as ReactDOM from 'react-dom/client'; import './common.css'; import { DevTools } from './devtools'; import { Grid } from './grid'; - -import type { SessionConfig } from '../../playwright/src/mcp/terminal/registry'; +import { SessionModel } from './sessionModel'; export function navigate(hash: string) { window.history.pushState(null, '', hash); @@ -35,55 +34,21 @@ function parseHash(): string | undefined { return undefined; } -const DevToolsSession: React.FC<{ socketPath: string }> = ({ socketPath }) => { - const [wsUrl, setWsUrl] = React.useState(); - const [error, setError] = React.useState(); - - React.useEffect(() => { - setWsUrl(undefined); - setError(undefined); - - let cancelled = false; - - void (async () => { - try { - const listResp = await fetch('/api/sessions/list'); - if (!listResp.ok) - throw new Error(`HTTP ${listResp.status}`); - const sessions: { config: SessionConfig; canConnect: boolean }[] = await listResp.json(); - const session = sessions.find(s => s.config.socketPath === socketPath); - if (!session) - throw new Error('Session not found'); - - const startResp = await fetch('/api/sessions/start-screencast', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ config: session.config }), - }); - if (!startResp.ok) - throw new Error(`HTTP ${startResp.status}`); - const { url } = await startResp.json(); - if (!cancelled) - setWsUrl(url); - } catch (e: any) { - if (!cancelled) - setError(e.message); - } - })(); - - return () => { cancelled = true; }; - }, [socketPath]); - - if (error) - return
Error: {error}
; - if (!wsUrl) - return
Connecting to session...
; - return ; -}; +const model = new SessionModel(); const App: React.FC = () => { + const [, setRevision] = React.useState(0); const [socketPath, setSocketPath] = React.useState(parseHash); + React.useEffect(() => { + model.startPolling(); + const unsubscribe = model.subscribe(() => setRevision(r => r + 1)); + return () => { + unsubscribe(); + model.stopPolling(); + }; + }, [model]); + React.useEffect(() => { const onPopState = () => setSocketPath(parseHash()); window.addEventListener('popstate', onPopState); @@ -91,8 +56,8 @@ const App: React.FC = () => { }, []); if (socketPath) - return ; - return ; + return ; + return ; }; ReactDOM.createRoot(document.querySelector('#root')!).render(); diff --git a/packages/devtools/src/sessionModel.ts b/packages/devtools/src/sessionModel.ts new file mode 100644 index 0000000000000..597fd0dc8a0e7 --- /dev/null +++ b/packages/devtools/src/sessionModel.ts @@ -0,0 +1,146 @@ +/** + * 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 type { ClientInfo, SessionConfig } from '../../playwright/src/mcp/terminal/registry'; + +export type SessionStatus = { + config: SessionConfig; + canConnect: boolean; +}; + +type Listener = () => void; + +export class SessionModel { + sessions: SessionStatus[] = []; + readonly wsUrls: Map = new Map(); + clientInfo: ClientInfo | undefined; + error: string | undefined; + loading = true; + + private _knownTimestamps = new Map(); + private _pollActive = false; + private _pollTimeout: ReturnType | undefined; + private _lastJson = ''; + private _listeners = new Set(); + + subscribe(listener: Listener): () => void { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + } + + private _notify() { + for (const listener of this._listeners) + listener(); + } + + startPolling() { + if (this._pollActive) + return; + this._pollActive = true; + const poll = async () => { + await this._fetchSessions(); + if (this._pollActive) + this._pollTimeout = setTimeout(poll, 3000); + }; + void poll(); + } + + stopPolling() { + this._pollActive = false; + if (this._pollTimeout) { + clearTimeout(this._pollTimeout); + this._pollTimeout = undefined; + } + } + + sessionBySocketPath(socketPath: string): SessionStatus | undefined { + return this.sessions.find(s => s.config.socketPath === socketPath); + } + + private async _fetchSessions() { + try { + this.loading = true; + const response = await fetch('/api/sessions/list'); + if (!response.ok) + throw new Error(`HTTP ${response.status}`); + const text = await response.text(); + if (text !== this._lastJson) { + this._lastJson = text; + const data = JSON.parse(text); + this.sessions = data.sessions; + this.clientInfo = data.clientInfo; + this._notify(); + + for (const session of this.sessions) { + if (session.canConnect) + this._obtainDevtoolsUrl(session.config); + } + } + this.error = undefined; + } catch (e: any) { + this.error = e.message; + } finally { + this.loading = false; + } + this._notify(); + } + + async fetchSessions() { + await this._fetchSessions(); + } + + async closeSession(config: SessionConfig) { + await fetch('/api/sessions/close', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }); + await this._fetchSessions(); + } + + async deleteSessionData(config: SessionConfig) { + await fetch('/api/sessions/delete-data', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }); + await this._fetchSessions(); + } + + private _obtainDevtoolsUrl(config: SessionConfig) { + if (this._knownTimestamps.get(config.socketPath) === config.timestamp) + return; + this._knownTimestamps.set(config.socketPath, config.timestamp); + fetch('/api/sessions/devtools-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }).then(async resp => { + if (resp.ok) { + const { url } = await resp.json(); + this.wsUrls.set(config.socketPath, url); + this._notify(); + } + }).catch(() => { + this._knownTimestamps.delete(config.socketPath); + }); + } + + dispose() { + this.stopPolling(); + this._listeners.clear(); + } +} diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 842b739220864..39a29ac015edc 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -2256,6 +2256,13 @@ export interface Page { trial?: boolean; }): Promise; + /** + * Clears all stored console messages from this page. Subsequent calls to + * [page.consoleMessages()](https://playwright.dev/docs/api/class-page#page-console-messages) will only return + * messages logged after the clear. + */ + clearConsoleMessages(): 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/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 94766db711abd..a123ae698bee6 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -586,12 +586,8 @@ export class BrowserContext extends ChannelOwner throw new Error(`File access denied: ${filePath} is outside allowed roots. Allowed roots: ${this._allowedDirectories.length ? this._allowedDirectories.join(', ') : 'none'}`); } - async _devtoolsStart(options: { size?: { width: number, height: number }, port?: number, host?: string } = {}): Promise<{ url: string }> { - return await this._channel.devtoolsStart(options); - } - - async _devtoolsStop(): Promise { - await this._channel.devtoolsStop(); + async _devtoolsStart(): Promise<{ url: string }> { + return await this._channel.devtoolsStart(); } } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 8fe1a33857ee1..9375390fe9fdb 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -655,6 +655,10 @@ export class Page extends ChannelOwner implements api.Page return await this._mainFrame.fill(selector, value, options); } + async clearConsoleMessages(): Promise { + await this._channel.clearConsoleMessages(); + } + async consoleMessages(): Promise { const { messages } = await this._channel.consoleMessages(); return messages.map(message => new ConsoleMessage(this._platform, message, this, null)); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 3861d86e577cd..6064116b89fa3 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1157,19 +1157,10 @@ scheme.BrowserContextClockSetSystemTimeParams = tObject({ timeString: tOptional(tString), }); scheme.BrowserContextClockSetSystemTimeResult = tOptional(tObject({})); -scheme.BrowserContextDevtoolsStartParams = tObject({ - size: tOptional(tObject({ - width: tInt, - height: tInt, - })), - port: tOptional(tInt), - host: tOptional(tString), -}); +scheme.BrowserContextDevtoolsStartParams = tOptional(tObject({})); scheme.BrowserContextDevtoolsStartResult = tObject({ url: tString, }); -scheme.BrowserContextDevtoolsStopParams = tOptional(tObject({})); -scheme.BrowserContextDevtoolsStopResult = tOptional(tObject({})); scheme.PageInitializer = tObject({ mainFrame: tChannel(['Frame']), viewportSize: tOptional(tObject({ @@ -1230,6 +1221,8 @@ scheme.PageCloseParams = tObject({ reason: tOptional(tString), }); scheme.PageCloseResult = tOptional(tObject({})); +scheme.PageClearConsoleMessagesParams = tOptional(tObject({})); +scheme.PageClearConsoleMessagesResult = tOptional(tObject({})); scheme.PageConsoleMessagesParams = tOptional(tObject({})); scheme.PageConsoleMessagesResult = tObject({ messages: tArray(tObject({ diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 033a6bfd5e9e4..1dbb020a875db 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -119,7 +119,7 @@ export abstract class BrowserContext extends Sdk private _playwrightBindingExposed?: Promise; readonly dialogManager: DialogManager; private _consoleApiExposed = false; - private _devtools: DevToolsController | undefined; + private _devtools: DevToolsController; constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(browser, 'browser-context'); @@ -130,6 +130,7 @@ export abstract class BrowserContext extends Sdk this._isPersistentContext = !browserContextId; this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill); this._selectors = new Selectors(options.selectorEngines || [], options.testIdAttributeName); + this._devtools = new DevToolsController(this); this.fetchRequest = new BrowserContextAPIRequestContext(this); this.tracing = new Tracing(this, browser.options.tracesDir); @@ -515,19 +516,9 @@ export abstract class BrowserContext extends Sdk await this.doUpdateRequestInterception(); } - async devtoolsStart(options: { size?: types.Size, port?: number, host?: string } = {}): Promise { - if (this._devtools) - await this._devtools.stop(); - const size = validateVideoSize(options.size, undefined); - this._devtools = new DevToolsController(this); - return await this._devtools.start({ width: size.width, height: size.height, quality: 90, port: options.port, host: options.host }); - } - - async devtoolsStop(): Promise { - if (!this._devtools) - throw new Error('DevTools is not running'); - await this._devtools.stop(); - this._devtools = undefined; + async devtoolsStart(): Promise { + const size = validateVideoSize(undefined, undefined); + return await this._devtools.start({ width: size.width, height: size.height, quality: 90 }); } isClosingOrClosed() { @@ -553,8 +544,7 @@ export abstract class BrowserContext extends Sdk this.emit(BrowserContext.Events.BeforeClose); this._closedStatus = 'closing'; - await this._devtools?.stop(); - this._devtools = undefined; + await this._devtools.dispose(); for (const harRecorder of this._harRecorders.values()) await harRecorder.flush(); diff --git a/packages/playwright-core/src/server/devtoolsController.ts b/packages/playwright-core/src/server/devtoolsController.ts index a0e7a8d84acab..b43db3108166b 100644 --- a/packages/playwright-core/src/server/devtoolsController.ts +++ b/packages/playwright-core/src/server/devtoolsController.ts @@ -25,24 +25,26 @@ import type { Transport } from './utils/httpServer'; export class DevToolsController { private _context: BrowserContext; - private _screencastOptions: { width: number, height: number, quality: number } = { width: 800, height: 600, quality: 90 }; - private _httpServer: HttpServer; + private _url: string | undefined; + private _httpServer: HttpServer | undefined; constructor(context: BrowserContext) { this._context = context; - this._httpServer = new HttpServer(); } async start(options: { width: number, height: number, quality: number, port?: number, host?: string }): Promise { - this._screencastOptions = options; - const guid = createGuid(); - this._httpServer.createWebSocket(() => new DevToolsConnection(this._context, this._screencastOptions), guid); - await this._httpServer.start({ port: options.port, host: options.host }); - return (this._httpServer.urlPrefix('human-readable') + `/${guid}`).replace('http://', 'ws://'); + if (!this._url) { + const guid = createGuid(); + this._httpServer = new HttpServer(); + this._httpServer.createWebSocket(() => new DevToolsConnection(this._context), guid); + await this._httpServer.start({ port: options.port, host: options.host }); + this._url = (this._httpServer.urlPrefix('human-readable') + `/${guid}`).replace('http://', 'ws://'); + } + return this._url; } - async stop() { - await this._httpServer.stop(); + async dispose() { + await this._httpServer?.stop(); } } @@ -56,11 +58,9 @@ class DevToolsConnection implements Transport { private _pageListeners: RegisteredListener[] = []; private _contextListeners: RegisteredListener[] = []; private _context: BrowserContext; - private _screencastOptions: { width: number, height: number, quality: number }; - constructor(context: BrowserContext, screencastOptions: { width: number, height: number, quality: number }) { + constructor(context: BrowserContext) { this._context = context; - this._screencastOptions = screencastOptions; } onconnect() { @@ -176,7 +176,7 @@ class DevToolsConnection implements Transport { eventsHelper.addEventListener(page, Page.Events.ScreencastFrame, frame => this._writeFrame(frame.buffer, frame.width, frame.height)) ); - await page.screencast.startScreencast(this, this._screencastOptions); + await page.screencast.startScreencast(this, { width: 800, height: 600, quality: 90 }); // Send URL to this client. const url = page.mainFrame().url(); diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 7f619c77f971a..1d71932c285de 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -402,14 +402,10 @@ export class BrowserContextDispatcher extends Dispatcher { - const url = await this._context.devtoolsStart(params); + const url = await this._context.devtoolsStart(); return { url }; } - async devtoolsStop(params: channels.BrowserContextDevtoolsStopParams, progress: Progress): Promise { - await this._context.devtoolsStop(); - } - async updateSubscription(params: channels.BrowserContextUpdateSubscriptionParams, progress: Progress): Promise { if (params.enabled) this._subscriptions.add(params.event); diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index e4319894001a0..18cc7f413ec28 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -272,6 +272,10 @@ export class PageDispatcher extends Dispatcher { + this._page.clearConsoleMessages(); + } + async consoleMessages(params: channels.PageConsoleMessagesParams, progress: Progress): Promise { // Send all future console messages to the client, so that it can reliably receive all of them. // Otherwise, if subscription is added in a different task from this call (either before or after), diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 90553ed1129d2..5039267d282ca 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -400,6 +400,10 @@ export class Page extends SdkObject { this.emitOnContext(BrowserContext.Events.Console, message); } + clearConsoleMessages() { + this._consoleMessages.length = 0; + } + consoleMessages() { return this._consoleMessages; } diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index cc1bc350736b4..55042c86d955c 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -103,9 +103,9 @@ export const methodMetainfo = new Map; + /** + * Clears all stored console messages from this page. Subsequent calls to + * [page.consoleMessages()](https://playwright.dev/docs/api/class-page#page-console-messages) will only return + * messages logged after the clear. + */ + clearConsoleMessages(): 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/browser/tools/devtools.ts b/packages/playwright/src/mcp/browser/tools/devtools.ts index 2dacf6bdd31f3..acdaf8a3d45f8 100644 --- a/packages/playwright/src/mcp/browser/tools/devtools.ts +++ b/packages/playwright/src/mcp/browser/tools/devtools.ts @@ -17,7 +17,7 @@ import { z } from 'playwright-core/lib/mcpBundle'; import { defineTool } from './tool'; -const devtoolsStart = defineTool({ +const devtoolsConnect = defineTool({ capability: 'devtools', skillOnly: true, @@ -25,38 +25,15 @@ const devtoolsStart = defineTool({ name: 'browser_devtools_start', title: 'Start browser DevTools', description: 'Start browser DevTools', - inputSchema: z.object({ - host: z.string().optional().describe('Host to use'), - port: z.number().optional().describe('Port to use'), - guid: z.string().optional().describe('Endpoint guid to expose'), - }), + inputSchema: z.object({}), type: 'action', }, handle: async (context, params, response) => { const browserContext = await context.ensureBrowserContext(); - const { url } = await (browserContext as any)._devtoolsStart(params); + const { url } = await (browserContext as any)._devtoolsStart(); response.addTextResult('Server is listening on: ' + url); }, }); -const devtoolsStop = defineTool({ - capability: 'devtools', - skillOnly: true, - - schema: { - name: 'browser_devtools_stop', - title: 'Stop browser DevTools', - description: 'Stop browser DevTools', - inputSchema: z.object({ - }), - type: 'action', - }, - - handle: async (context, params, response) => { - const browserContext = await context.ensureBrowserContext(); - await (browserContext as any)._devtoolsStop(); - }, -}); - -export default [devtoolsStart, devtoolsStop]; +export default [devtoolsConnect]; diff --git a/packages/playwright/src/mcp/terminal/commands.ts b/packages/playwright/src/mcp/terminal/commands.ts index bf3ebf5096828..4259e24072b55 100644 --- a/packages/playwright/src/mcp/terminal/commands.ts +++ b/packages/playwright/src/mcp/terminal/commands.ts @@ -781,15 +781,6 @@ const devtoolsStart = declareCommand({ toolParams: () => ({}), }); -const devtoolsStop = declareCommand({ - name: 'devtools-stop', - description: 'Stop browser DevTools', - category: 'devtools', - args: z.object({}), - toolName: 'browser_devtools_stop', - toolParams: () => ({}), -}); - // Sessions const sessionList = declareCommand({ @@ -958,7 +949,6 @@ const commandsArray: AnyCommandSchema[] = [ videoStop, devtoolsShow, devtoolsStart, - devtoolsStop, // session category sessionList, diff --git a/packages/playwright/src/mcp/terminal/devtoolsApp.ts b/packages/playwright/src/mcp/terminal/devtoolsApp.ts index f50d405543d06..60684a2f39eb6 100644 --- a/packages/playwright/src/mcp/terminal/devtoolsApp.ts +++ b/packages/playwright/src/mcp/terminal/devtoolsApp.ts @@ -58,18 +58,18 @@ async function handleApiRequest(clientInfo: ClientInfo, request: http.IncomingMe if (apiPath === '/api/sessions/list' && request.method === 'GET') { const registry = await Registry.load(); - const result: { config: SessionConfig, canConnect: boolean }[] = []; + const sessions: { config: SessionConfig, canConnect: boolean }[] = []; for (const [, entries] of registry.entryMap()) { for (const entry of entries) { const session = new Session(clientInfo, entry.config); const canConnect = await session.canConnect(); if (canConnect || entry.config.cli.persistent) - result.push({ config: entry.config, canConnect }); + sessions.push({ config: entry.config, canConnect }); else await session.deleteSessionConfig(); } } - sendJSON(response, result); + sendJSON(response, { sessions, clientInfo }); return; } @@ -82,6 +82,15 @@ async function handleApiRequest(clientInfo: ClientInfo, request: http.IncomingMe return; } + if (apiPath === '/api/sessions/delete-data' && request.method === 'POST') { + const body = await readBody(request); + if (!body.config) + throw new Error('Missing "config" parameter'); + await new Session(clientInfo, body.config).deleteData(); + sendJSON(response, { success: true }); + return; + } + if (apiPath === '/api/sessions/run' && request.method === 'POST') { const body = await readBody(request); if (!body.config) @@ -93,7 +102,7 @@ async function handleApiRequest(clientInfo: ClientInfo, request: http.IncomingMe return; } - if (apiPath === '/api/sessions/start-screencast' && request.method === 'POST') { + if (apiPath === '/api/sessions/devtools-start' && request.method === 'POST') { const body = await readBody(request); if (!body.config) throw new Error('Missing "config" parameter'); @@ -105,15 +114,6 @@ async function handleApiRequest(clientInfo: ClientInfo, request: http.IncomingMe return; } - if (apiPath === '/api/sessions/stop-screencast' && request.method === 'POST') { - const body = await readBody(request); - if (!body.config) - throw new Error('Missing "config" parameter'); - await new Session(clientInfo, body.config).run({ _: ['devtools-stop'] }); - sendJSON(response, { success: true }); - return; - } - response.statusCode = 404; response.end(JSON.stringify({ error: 'Not found' })); } diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 165caeae220a1..40cd7dd77e9cb 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1624,8 +1624,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT clockRunFor(params: BrowserContextClockRunForParams, progress?: Progress): Promise; clockSetFixedTime(params: BrowserContextClockSetFixedTimeParams, progress?: Progress): Promise; clockSetSystemTime(params: BrowserContextClockSetSystemTimeParams, progress?: Progress): Promise; - devtoolsStart(params: BrowserContextDevtoolsStartParams, progress?: Progress): Promise; - devtoolsStop(params?: BrowserContextDevtoolsStopParams, progress?: Progress): Promise; + devtoolsStart(params?: BrowserContextDevtoolsStartParams, progress?: Progress): Promise; } export type BrowserContextBindingCallEvent = { binding: BindingCallChannel, @@ -2018,28 +2017,11 @@ export type BrowserContextClockSetSystemTimeOptions = { timeString?: string, }; export type BrowserContextClockSetSystemTimeResult = void; -export type BrowserContextDevtoolsStartParams = { - size?: { - width: number, - height: number, - }, - port?: number, - host?: string, -}; -export type BrowserContextDevtoolsStartOptions = { - size?: { - width: number, - height: number, - }, - port?: number, - host?: string, -}; +export type BrowserContextDevtoolsStartParams = {}; +export type BrowserContextDevtoolsStartOptions = {}; export type BrowserContextDevtoolsStartResult = { url: string, }; -export type BrowserContextDevtoolsStopParams = {}; -export type BrowserContextDevtoolsStopOptions = {}; -export type BrowserContextDevtoolsStopResult = void; export interface BrowserContextEvents { 'bindingCall': BrowserContextBindingCallEvent; @@ -2088,6 +2070,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { _type_Page: boolean; addInitScript(params: PageAddInitScriptParams, progress?: Progress): Promise; close(params: PageCloseParams, progress?: Progress): Promise; + clearConsoleMessages(params?: PageClearConsoleMessagesParams, progress?: Progress): Promise; consoleMessages(params?: PageConsoleMessagesParams, progress?: Progress): Promise; emulateMedia(params: PageEmulateMediaParams, progress?: Progress): Promise; exposeBinding(params: PageExposeBindingParams, progress?: Progress): Promise; @@ -2187,6 +2170,9 @@ export type PageCloseOptions = { reason?: string, }; export type PageCloseResult = void; +export type PageClearConsoleMessagesParams = {}; +export type PageClearConsoleMessagesOptions = {}; +export type PageClearConsoleMessagesResult = void; export type PageConsoleMessagesParams = {}; export type PageConsoleMessagesOptions = {}; export type PageConsoleMessagesResult = { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index d6e204d6292ca..feeadf0d14842 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1468,20 +1468,9 @@ BrowserContext: devtoolsStart: internal: true - parameters: - size: - type: object? - properties: - width: int - height: int - port: int? - host: string? returns: url: string - devtoolsStop: - internal: true - events: bindingCall: @@ -1589,6 +1578,9 @@ Page: flags: pausesBeforeAction: true + clearConsoleMessages: + title: Clear console messages + consoleMessages: title: Get console messages group: getter diff --git a/tests/page/page-event-console.spec.ts b/tests/page/page-event-console.spec.ts index 832b1bf24a528..f6cf5778d7b12 100644 --- a/tests/page/page-event-console.spec.ts +++ b/tests/page/page-event-console.spec.ts @@ -275,3 +275,24 @@ it('consoleMessages should work', async ({ page }) => { expect(objects.length, 'should be at least 100 messages').toBeGreaterThanOrEqual(100); expect(objects.slice(objects.length - expected.length), 'should return last messages').toEqual(expected); }); + +it('clearConsoleMessages should work', async ({ page }) => { + await page.evaluate(() => { + console.log('message1'); + console.log('message2'); + }); + + let messages = await page.consoleMessages(); + expect(messages.map(m => m.text())).toContain('message1'); + expect(messages.map(m => m.text())).toContain('message2'); + + await page.clearConsoleMessages(); + + messages = await page.consoleMessages(); + expect(messages).toEqual([]); + + await page.evaluate(() => console.log('message3')); + messages = await page.consoleMessages(); + expect(messages.length).toBe(1); + expect(messages[0].text()).toBe('message3'); +});