diff --git a/docs/src/api/class-inspector.md b/docs/src/api/class-inspector.md index a642cc9752e52..67789d029bd33 100644 --- a/docs/src/api/class-inspector.md +++ b/docs/src/api/class-inspector.md @@ -28,8 +28,6 @@ console.log(locator); * since: v1.59 - argument: <[Object]> - `data` <[Buffer]> JPEG-encoded frame data. - - `width` <[int]> Frame width in pixels. - - `height` <[int]> Frame height in pixels. Emitted for each captured JPEG screencast frame while the screencast is running. @@ -41,7 +39,7 @@ inspector.on('screencastframe', ({ data, width, height }) => { console.log(`frame ${width}x${height}, jpeg size: ${data.length}`); require('fs').writeFileSync('frame.jpg', data); }); -await inspector.startScreencast({ size: { width: 1280, height: 720 } }); +await inspector.startScreencast({ maxSize: { width: 1200, height: 800 } }); // ... perform actions ... await inspector.stopScreencast(); ``` @@ -58,18 +56,18 @@ const inspector = page.inspector(); inspector.on('screencastframe', ({ data, width, height }) => { console.log(`frame ${width}x${height}, size: ${data.length}`); }); -await inspector.startScreencast({ size: { width: 800, height: 600 } }); +await inspector.startScreencast({ maxSize: { width: 800, height: 600 } }); // ... perform actions ... await inspector.stopScreencast(); ``` -### option: Inspector.startScreencast.size +### option: Inspector.startScreencast.maxSize * since: v1.59 -- `size` ?<[Object]> - - `width` <[int]> Frame width in pixels. - - `height` <[int]> Frame height in pixels. +- `maxSize` ?<[Object]> + - `width` <[int]> Max frame width in pixels. + - `height` <[int]> Max frame height in pixels. -Optional dimensions for the screencast frames. If not specified, the current page viewport size is used. +Maximum screencast frame dimensions. The output frame may be smaller to preserve the page aspect ratio. Defaults to 800×800. ## async method: Inspector.stopScreencast * since: v1.59 diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 9227156952db2..3089d164b44a1 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -20445,7 +20445,7 @@ export interface Inspector { * console.log(`frame ${width}x${height}, jpeg size: ${data.length}`); * require('fs').writeFileSync('frame.jpg', data); * }); - * await inspector.startScreencast({ size: { width: 1280, height: 720 } }); + * await inspector.startScreencast({ maxSize: { width: 1200, height: 800 } }); * // ... perform actions ... * await inspector.stopScreencast(); * ``` @@ -20456,16 +20456,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20476,16 +20466,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20499,7 +20479,7 @@ export interface Inspector { * console.log(`frame ${width}x${height}, jpeg size: ${data.length}`); * require('fs').writeFileSync('frame.jpg', data); * }); - * await inspector.startScreencast({ size: { width: 1280, height: 720 } }); + * await inspector.startScreencast({ maxSize: { width: 1200, height: 800 } }); * // ... perform actions ... * await inspector.stopScreencast(); * ``` @@ -20510,16 +20490,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20530,16 +20500,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20550,16 +20510,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20573,7 +20523,7 @@ export interface Inspector { * console.log(`frame ${width}x${height}, jpeg size: ${data.length}`); * require('fs').writeFileSync('frame.jpg', data); * }); - * await inspector.startScreencast({ size: { width: 1280, height: 720 } }); + * await inspector.startScreencast({ maxSize: { width: 1200, height: 800 } }); * // ... perform actions ... * await inspector.stopScreencast(); * ``` @@ -20584,16 +20534,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20630,7 +20570,7 @@ export interface Inspector { * inspector.on('screencastframe', ({ data, width, height }) => { * console.log(`frame ${width}x${height}, size: ${data.length}`); * }); - * await inspector.startScreencast({ size: { width: 800, height: 600 } }); + * await inspector.startScreencast({ maxSize: { width: 800, height: 600 } }); * // ... perform actions ... * await inspector.stopScreencast(); * ``` @@ -20639,16 +20579,17 @@ export interface Inspector { */ startScreencast(options?: { /** - * Optional dimensions for the screencast frames. If not specified, the current page viewport size is used. + * Maximum screencast frame dimensions. The output frame may be smaller to preserve the page aspect ratio. Defaults to + * 800×800. */ - size?: { + maxSize?: { /** - * Frame width in pixels. + * Max frame width in pixels. */ width: number; /** - * Frame height in pixels. + * Max frame height in pixels. */ height: number; }; diff --git a/packages/playwright-core/src/cli/client/devtoolsApp.ts b/packages/playwright-core/src/cli/client/devtoolsApp.ts index 4143302b324d2..b479cd9d85037 100644 --- a/packages/playwright-core/src/cli/client/devtoolsApp.ts +++ b/packages/playwright-core/src/cli/client/devtoolsApp.ts @@ -19,11 +19,12 @@ import path from 'path'; import os from 'os'; import net from 'net'; + import { chromium } from '../../..'; import { HttpServer } from '../../server/utils/httpServer'; import { gracefullyProcessExitDoNotHang } from '../../server/utils/processLauncher'; import { findChromiumChannelBestEffort, registryDirectory } from '../../server/registry/index'; - +import { calculateSha1 } from '../../utils'; import { createClientInfo, Registry } from './registry'; import { Session } from './session'; @@ -213,9 +214,10 @@ function socketsDirectory() { } function devtoolsSocketPath() { + const userNameHash = calculateSha1(process.env.USERNAME || 'default').slice(0, 8); return process.platform === 'win32' - ? `\\\\.\\pipe\\playwright-devtools-${process.env.USERNAME || 'default'}` - : path.join(socketsDirectory(), 'devtools.sock'); + ? `\\\\.\\pipe\\playwright-devtools-${userNameHash}` + : path.join(socketsDirectory(), `devtools-${userNameHash}.sock`); } async function acquireSingleton(): Promise { diff --git a/packages/playwright-core/src/cli/client/program.ts b/packages/playwright-core/src/cli/client/program.ts index 2eff9ad027c99..5b10e82ae6a06 100644 --- a/packages/playwright-core/src/cli/client/program.ts +++ b/packages/playwright-core/src/cli/client/program.ts @@ -19,6 +19,7 @@ import { execSync, spawn } from 'child_process'; +import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -298,7 +299,8 @@ async function findOrInstallDefaultBrowser() { } function daemonSocketPath(clientInfo: ClientInfo, sessionName: string): string { - const socketName = `${sessionName}.sock`; + const userNameHash = calculateSha1(process.env.USERNAME || 'default').slice(0, 8); + const socketName = `${sessionName}-${userNameHash}.sock`; if (os.platform() === 'win32') return `\\\\.\\pipe\\${clientInfo.workspaceDirHash}-${socketName}`; const socketsDir = process.env.PLAYWRIGHT_DAEMON_SOCKETS_DIR || path.join(os.tmpdir(), 'playwright-cli'); @@ -442,3 +444,9 @@ async function renderSessionStatus(clientInfo: ClientInfo, session: Session) { text.push(...renderResolvedConfig(config.resolvedConfig)); return text.join('\n'); } + +export function calculateSha1(buffer: Buffer | string): string { + const hash = crypto.createHash('sha1'); + hash.update(buffer); + return hash.digest('hex'); +} diff --git a/packages/playwright-core/src/cli/daemon/commands.ts b/packages/playwright-core/src/cli/daemon/commands.ts index 9a289fa35d236..2065cc1d9366c 100644 --- a/packages/playwright-core/src/cli/daemon/commands.ts +++ b/packages/playwright-core/src/cli/daemon/commands.ts @@ -875,7 +875,7 @@ const installBrowser = declareCommand({ ['only-shell']: z.boolean().optional().describe('Only install headless shell when installing Chromium'), ['no-shell']: z.boolean().optional().describe('Do not install Chromium headless shell'), }), - toolName: 'browser_install', + toolName: '', toolParams: () => ({}), }); diff --git a/packages/playwright-core/src/cli/daemon/daemon.ts b/packages/playwright-core/src/cli/daemon/daemon.ts index 89a25504a660a..d77a1d5940966 100644 --- a/packages/playwright-core/src/cli/daemon/daemon.ts +++ b/packages/playwright-core/src/cli/daemon/daemon.ts @@ -30,9 +30,8 @@ import { SocketConnection } from '../client/socketConnection'; import { commands } from './commands'; import { parseCommand } from './command'; +import type * as playwright from '../../..'; import type * as mcp from '../../mcp/exports'; -import type { FullConfig } from '../../mcp/browser/config'; -import type { BrowserContextFactory } from '../../mcp/browser/browserContextFactory'; import type { SessionConfig } from '../client/registry'; const daemonDebug = debug('pw:daemon'); @@ -48,9 +47,9 @@ async function socketExists(socketPath: string): Promise { } export async function startMcpDaemonServer( - mcpConfig: FullConfig, + config: mcp.ContextConfig, sessionConfig: SessionConfig, - contextFactory: BrowserContextFactory, + browserContext: playwright.BrowserContext, noShutdown?: boolean, ): Promise<() => Promise> { const { socketPath } = sessionConfig; @@ -65,18 +64,6 @@ export async function startMcpDaemonServer( } } - const cwd = url.pathToFileURL(process.cwd()).href; - const clientInfo = { - name: 'playwright-cli', - version: sessionConfig.version, - roots: [{ - uri: cwd, - name: 'cwd' - }], - timestamp: Date.now(), - }; - - const browserContext = mcpConfig.browser.isolated ? await contextFactory.createContext(clientInfo) : (await contextFactory.contexts(clientInfo))[0]; if (!noShutdown) { browserContext.on('close', () => { daemonDebug('browser closed, shutting down daemon'); @@ -84,8 +71,16 @@ export async function startMcpDaemonServer( }); } - const backend = new BrowserServerBackend(mcpConfig, browserContext, browserTools); - await backend.initialize(clientInfo); + const backend = new BrowserServerBackend(config, browserContext, browserTools); + await backend.initialize({ + name: 'playwright-cli', + version: sessionConfig.version, + roots: [{ + uri: url.pathToFileURL(process.cwd()).href, + name: 'cwd', + }], + timestamp: Date.now(), + }); await fs.mkdir(path.dirname(socketPath), { recursive: true }); diff --git a/packages/playwright-core/src/cli/daemon/program.ts b/packages/playwright-core/src/cli/daemon/program.ts index 0b261b1cdb333..e79269b1d9de0 100644 --- a/packages/playwright-core/src/cli/daemon/program.ts +++ b/packages/playwright-core/src/cli/daemon/program.ts @@ -17,6 +17,7 @@ /* eslint-disable no-console */ import fs from 'fs'; +import url from 'url'; import { startMcpDaemonServer } from './daemon'; import { setupExitWatchdog } from '../../mcp/browser/watchdog'; @@ -38,13 +39,26 @@ export function decorateCLICommand(command: Command, version: string) { setupExitWatchdog(); const sessionConfig = await fs.promises.readFile(options.daemonSession, 'utf-8').then(data => JSON.parse(data) as SessionConfig); + + const cwd = url.pathToFileURL(process.cwd()).href; + const clientInfo = { + name: 'playwright-cli', + version: sessionConfig.version, + roots: [{ + uri: cwd, + name: 'cwd' + }], + timestamp: Date.now(), + }; + const mcpConfig = await resolveCLIConfig(sessionConfig); - const browserContextFactory = contextFactory(mcpConfig); const extensionContextFactory = new ExtensionContextFactory(mcpConfig.browser.launchOptions.channel || 'chrome', mcpConfig.browser.userDataDir, mcpConfig.browser.launchOptions.executablePath); - + const browserContextFactory = contextFactory(mcpConfig); const cf = mcpConfig.extension ? extensionContextFactory : browserContextFactory; + try { - await startMcpDaemonServer(mcpConfig, sessionConfig, cf); + const browserContext = mcpConfig.browser.isolated ? await cf.createContext(clientInfo) : (await cf.contexts(clientInfo))[0]; + await startMcpDaemonServer(mcpConfig, sessionConfig, browserContext); console.log(`### Config`); console.log('```json'); console.log(JSON.stringify(mcpConfig, null, 2)); diff --git a/packages/playwright-core/src/client/disposable.ts b/packages/playwright-core/src/client/disposable.ts index 1c6856834f9eb..fc6b414d5ae01 100644 --- a/packages/playwright-core/src/client/disposable.ts +++ b/packages/playwright-core/src/client/disposable.ts @@ -69,6 +69,8 @@ export class DisposableStub implements Disposable { } } -export function disposeAll(disposables: Disposable[]) { - return Promise.all(disposables.map(d => d.dispose())); +export async function disposeAll(disposables: Disposable[]) { + const copy = [...disposables]; + disposables.length = 0; + await Promise.all(copy.map(d => d.dispose())); } diff --git a/packages/playwright-core/src/client/inspector.ts b/packages/playwright-core/src/client/inspector.ts index c59816888fef6..6c42f1e3363c3 100644 --- a/packages/playwright-core/src/client/inspector.ts +++ b/packages/playwright-core/src/client/inspector.ts @@ -26,7 +26,7 @@ export class Inspector extends EventEmitter implements api.Inspector { constructor(page: Page) { super(page._platform); this._page = page; - this._page._channel.on('screencastFrame', ({ data, width, height }) => this.emit('screencastframe', { data, width, height })); + this._page._channel.on('screencastFrame', ({ data }) => this.emit('screencastframe', { data })); } async pickLocator(): Promise { @@ -38,7 +38,7 @@ export class Inspector extends EventEmitter implements api.Inspector { await this._page._channel.cancelPickLocator({}); } - async startScreencast(options: { size?: { width: number, height: number } } = {}): Promise { + async startScreencast(options: { maxSize?: { width: number, height: number } } = {}): Promise { await this._page._channel.startScreencast(options); } diff --git a/packages/playwright-core/src/mcp/browser/browserContextFactory.ts b/packages/playwright-core/src/mcp/browser/browserContextFactory.ts index c3c9bc6ea8a29..4f4c620562c54 100644 --- a/packages/playwright-core/src/mcp/browser/browserContextFactory.ts +++ b/packages/playwright-core/src/mcp/browser/browserContextFactory.ts @@ -23,7 +23,7 @@ import * as playwright from '../../..'; import { registryDirectory } from '../../server/registry/index'; import { startTraceViewerServer } from '../../server'; import { testDebug } from '../log'; -import { outputDir, outputFile } from './config'; +import { outputDir, outputFile } from './context'; import { firstRootPath } from '../sdk/server'; import type { FullConfig } from './config'; @@ -255,13 +255,15 @@ function createHash(data: string): string { } async function computeTracesDir(config: FullConfig, clientInfo: ClientInfo): Promise { - return path.resolve(outputDir(config, clientInfo), 'traces'); + const cwd = firstRootPath(clientInfo); + return path.resolve(outputDir({ config, cwd }), 'traces'); } async function browserContextOptionsFromConfig(config: FullConfig, clientInfo: ClientInfo): Promise { const result = { ...config.browser.contextOptions }; if (config.saveVideo) { - const dir = await outputFile(config, clientInfo, `videos`, { origin: 'code' }); + const cwd = firstRootPath(clientInfo); + const dir = await outputFile({ config, cwd }, `videos`, { origin: 'code' }); result.recordVideo = { dir, size: config.saveVideo, @@ -310,5 +312,5 @@ function throwBrowserIsNotInstalledError(config: FullConfig): never { if (config.skillMode) throw new Error(`Browser "${channel}" is not installed. Run \`playwright-cli install-browser ${channel}\` to install`); else - throw new Error(`Browser "${channel}" is not installed. Either install it (likely) or change the config.`); + throw new Error(`Browser "${channel}" is not installed. Run \`npx @playwright/mcp install-browser ${channel}\` to install`); } diff --git a/packages/playwright-core/src/mcp/browser/browserServerBackend.ts b/packages/playwright-core/src/mcp/browser/browserServerBackend.ts index a8b158eab4280..5b005d5a0c21e 100644 --- a/packages/playwright-core/src/mcp/browser/browserServerBackend.ts +++ b/packages/playwright-core/src/mcp/browser/browserServerBackend.ts @@ -14,37 +14,39 @@ * limitations under the License. */ -import { FullConfig } from './config'; import { Context } from './context'; import { Response } from './response'; import { SessionLog } from './sessionLog'; import { toMcpTool } from '../sdk/tool'; import { logUnhandledError } from '../log'; +import { firstRootPath } from '../sdk/server'; +import type { ContextConfig } from './context'; import type * as playwright from '../../..'; import type { Tool } from './tools/tool'; import type * as mcpServer from '../sdk/server'; -import type { ServerBackend } from '../sdk/server'; +import type { ClientInfo, ServerBackend } from '../sdk/server'; export class BrowserServerBackend implements ServerBackend { private _tools: Tool[]; private _context: Context | undefined; private _sessionLog: SessionLog | undefined; - private _config: FullConfig; + private _config: ContextConfig; readonly browserContext: playwright.BrowserContext; - constructor(config: FullConfig, browserContext: playwright.BrowserContext, tools: Tool[]) { + constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[]) { this._config = config; this._tools = tools; this.browserContext = browserContext; } - async initialize(clientInfo: mcpServer.ClientInfo): Promise { - this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, clientInfo) : undefined; + async initialize(clientInfo: ClientInfo): Promise { + const cwd = firstRootPath(clientInfo); + this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, cwd) : undefined; this._context = new Context(this.browserContext, { config: this._config, sessionLog: this._sessionLog, - clientInfo, + cwd, }); } diff --git a/packages/playwright-core/src/mcp/browser/config.ts b/packages/playwright-core/src/mcp/browser/config.ts index 88e5b1bec3a81..639a5f8b2a188 100644 --- a/packages/playwright-core/src/mcp/browser/config.ts +++ b/packages/playwright-core/src/mcp/browser/config.ts @@ -16,18 +16,15 @@ import fs from 'fs'; import os from 'os'; -import path from 'path'; import { registry } from '../../server'; import { devices } from '../../..'; -import { dotenv, debug } from '../../utilsBundle'; +import { dotenv } from '../../utilsBundle'; import { configFromIniFile } from './configIni'; -import { firstRootPath } from '../sdk/server'; import type * as playwright from '../../..'; import type { Config, ToolCapability } from '../config'; -import type { ClientInfo } from '../sdk/server'; async function fileExistsAsync(resolved: string) { try { return (await fs.promises.stat(resolved)).isFile(); } catch { return false; } @@ -93,19 +90,7 @@ export const defaultConfig: FullConfig = { }, isolated: false, }, - console: { - level: 'info', - }, - network: { - allowedOrigins: undefined, - blockedOrigins: undefined, - }, server: {}, - saveTrace: false, - snapshot: { - mode: 'incremental', - output: 'stdout', - }, timeouts: { action: 5000, navigation: 60000, @@ -122,21 +107,7 @@ export type FullConfig = Config & { contextOptions: NonNullable; isolated: boolean; }, - console: { - level: 'error' | 'warning' | 'info' | 'debug'; - }, - network: NonNullable, - saveTrace: boolean; server: NonNullable, - snapshot: { - mode: 'incremental' | 'full' | 'none'; - output: 'stdout' | 'file'; - }, - timeouts: { - action: number; - navigation: number; - expect: number; - }, skillMode?: boolean; configFile?: string; }; @@ -316,7 +287,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config & { configF export function configFromEnv(): Config & { configFile?: string } { const options: CLIOptions = {}; - options.allowedHosts = commaSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_HOSTNAMES); + options.allowedHosts = commaSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_HOSTS); options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS); options.allowUnrestrictedFileAccess = envToBoolean(process.env.PLAYWRIGHT_MCP_ALLOW_UNRESTRICTED_FILE_ACCESS); options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS); @@ -378,45 +349,6 @@ export async function loadConfig(configFile: string | undefined): Promise { - const workspace = perCallWorkspaceDir ?? workspaceDir(clientInfo); - const resolvedName = path.resolve(workspace, fileName); - await checkFile(config, clientInfo, resolvedName, { origin: 'code' }); - return resolvedName; -} - -export function outputDir(config: FullConfig, clientInfo: ClientInfo): string { - if (config.outputDir) - return path.resolve(config.outputDir); - return path.resolve(firstRootPath(clientInfo), config.skillMode ? '.playwright-cli' : '.playwright-mcp'); -} - -export async function outputFile(config: FullConfig, clientInfo: ClientInfo, fileName: string, options: { origin: 'code' | 'llm' }): Promise { - const resolvedFile = path.resolve(outputDir(config, clientInfo), fileName); - await checkFile(config, clientInfo, resolvedFile, options); - await fs.promises.mkdir(path.dirname(resolvedFile), { recursive: true }); - debug('pw:mcp:file')(resolvedFile); - return resolvedFile; -} - -async function checkFile(config: FullConfig, clientInfo: ClientInfo, resolvedFilename: string, options: { origin: 'code' | 'llm' }) { - // Trust code. - if (options.origin === 'code') - return; - - // Trust llm to use valid characters in file names. - const output = outputDir(config, clientInfo); - const workspace = workspaceDir(clientInfo); - if (!resolvedFilename.startsWith(output) && !resolvedFilename.startsWith(workspace)) - throw new Error(`Resolved file path ${resolvedFilename} is outside of the output directory ${output} and workspace directory ${workspace}. Use relative file names to stay within the output directory.`); -} - function pickDefined(obj: T | undefined): Partial { return Object.fromEntries( Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined) diff --git a/packages/playwright-core/src/mcp/browser/context.ts b/packages/playwright-core/src/mcp/browser/context.ts index c439cc3a3673f..8f0a40755cdca 100644 --- a/packages/playwright-core/src/mcp/browser/context.ts +++ b/packages/playwright-core/src/mcp/browser/context.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import fs from 'fs'; import path from 'path'; import { disposeAll } from '../../client/disposable'; @@ -23,23 +24,42 @@ import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils'; import { selectors } from '../../..'; import { Tab } from './tab'; -import { outputFile, workspaceFile } from './config'; -import { allRootPaths, firstRootPath } from '../sdk/server'; import type * as playwright from '../../..'; -import type { FullConfig } from './config'; import type { SessionLog } from './sessionLog'; import type { Tracing } from '../../client/tracing'; import type { Disposable } from '../../client/disposable'; import type { BrowserContext } from '../../client/browserContext'; -import type { ClientInfo } from '../sdk/server'; +import type { Config } from '../config.d.ts'; const testDebug = debug('pw:mcp:test'); +export type ContextConfig = Pick & { + browser?: { + initScript?: string[]; + initPage?: string[]; + }; + skillMode?: boolean; + }; + type ContextOptions = { - config: FullConfig; - sessionLog: SessionLog | undefined; - clientInfo: ClientInfo; + config: ContextConfig; + sessionLog?: SessionLog; + cwd: string; }; export type RouteEntry = { @@ -62,14 +82,13 @@ export type FilenameTemplate = { type VideoParams = NonNullable[0]>; export class Context { - readonly config: FullConfig; + readonly config: ContextConfig; readonly sessionLog: SessionLog | undefined; readonly options: ContextOptions; private _rawBrowserContext: playwright.BrowserContext; private _browserContextPromise: Promise | undefined; private _tabs: Tab[] = []; private _currentTab: Tab | undefined; - private _clientInfo: ClientInfo; private _routes: RouteEntry[] = []; private _video: { allVideos: Set; @@ -84,12 +103,11 @@ export class Context { this.sessionLog = options.sessionLog; this.options = options; this._rawBrowserContext = browserContext; - this._clientInfo = options.clientInfo; testDebug('create context'); } async dispose() { - disposeAll(this._disposables); + await disposeAll(this._disposables); for (const tab of this._tabs) await tab.dispose(); this._tabs.length = 0; @@ -144,12 +162,12 @@ export class Context { } async workspaceFile(fileName: string, perCallWorkspaceDir: string | undefined): Promise { - return await workspaceFile(this.config, this._clientInfo, fileName, perCallWorkspaceDir); + return await workspaceFile(this.options, fileName, perCallWorkspaceDir); } async outputFile(template: FilenameTemplate, options: { origin: 'code' | 'llm' }): Promise { const baseName = template.suggestedFilename || `${template.prefix}-${(template.date ?? new Date()).toISOString().replace(/[:.]/g, '-')}${template.ext ? '.' + template.ext : ''}`; - return await outputFile(this.config, this._clientInfo, baseName, options); + return await outputFile(this.options, baseName, options); } async startVideoRecording(params: VideoParams) { @@ -261,7 +279,7 @@ export class Context { const browserContext = this._rawBrowserContext; if (!this.config.allowUnrestrictedFileAccess) { (browserContext as any)._setAllowedProtocols(['http:', 'https:', 'about:', 'data:']); - (browserContext as any)._setAllowedDirectories(allRootPaths(this._clientInfo)); + (browserContext as any)._setAllowedDirectories([this.options.cwd]); } await this._setupRequestInterception(browserContext); @@ -278,9 +296,8 @@ export class Context { }, }); } - const rootPath = firstRootPath(this._clientInfo); - for (const initScript of this.config.browser.initScript || []) - this._disposables.push(await browserContext.addInitScript({ path: path.resolve(rootPath, initScript) })); + for (const initScript of this.config.browser?.initScript || []) + this._disposables.push(await browserContext.addInitScript({ path: path.resolve(this.options.cwd, initScript) })); for (const page of browserContext.pages()) this._onPageCreated(page); @@ -315,3 +332,36 @@ function originOrHostGlob(originOrHost: string) { // Support for legacy host-only mode. return `*://${originOrHost}/**`; } + +export async function workspaceFile(options: ContextOptions, fileName: string, perCallWorkspaceDir?: string): Promise { + const workspace = perCallWorkspaceDir ?? options.cwd; + const resolvedName = path.resolve(workspace, fileName); + await checkFile(options, resolvedName, { origin: 'code' }); + return resolvedName; +} + +export function outputDir(options: ContextOptions): string { + if (options.config.outputDir) + return path.resolve(options.config.outputDir); + return path.resolve(options.cwd, options.config.skillMode ? '.playwright-cli' : '.playwright-mcp'); +} + +export async function outputFile(options: ContextOptions, fileName: string, flags: { origin: 'code' | 'llm' }): Promise { + const resolvedFile = path.resolve(outputDir(options), fileName); + await checkFile(options, resolvedFile, flags); + await fs.promises.mkdir(path.dirname(resolvedFile), { recursive: true }); + debug('pw:mcp:file')(resolvedFile); + return resolvedFile; +} + +async function checkFile(options: ContextOptions, resolvedFilename: string, flags: { origin: 'code' | 'llm' }) { + // Trust code. + if (flags.origin === 'code') + return; + + // Trust llm to use valid characters in file names. + const output = outputDir(options); + const workspace = options.cwd; + if (!resolvedFilename.startsWith(output) && !resolvedFilename.startsWith(workspace)) + throw new Error(`Resolved file path ${resolvedFilename} is outside of the output directory ${output} and workspace directory ${workspace}. Use relative file names to stay within the output directory.`); +} diff --git a/packages/playwright-core/src/mcp/browser/response.ts b/packages/playwright-core/src/mcp/browser/response.ts index aef12159245ad..e15f6ba8f2661 100644 --- a/packages/playwright-core/src/mcp/browser/response.ts +++ b/packages/playwright-core/src/mcp/browser/response.ts @@ -20,7 +20,6 @@ import path from 'path'; import { debug } from '../../utilsBundle'; import { renderModalStates, shouldIncludeMessage } from './tab'; import { scaleImageToFitMessage } from './tools/screenshot'; -import { firstRootPath } from '../sdk/server'; import type { TabHeader } from './tab'; import type { CallToolResult, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; @@ -59,7 +58,7 @@ export class Response { this._context = context; this.toolName = toolName; this.toolArgs = toolArgs; - this._clientWorkspace = relativeTo ?? firstRootPath(context.options.clientInfo); + this._clientWorkspace = relativeTo ?? context.options.cwd; } private _computRelativeTo(fileName: string): string { @@ -120,7 +119,7 @@ export class Response { } setIncludeSnapshot() { - this._includeSnapshot = this._context.config.snapshot.mode; + this._includeSnapshot = this._context.config.snapshot?.mode || 'incremental'; } setIncludeFullSnapshot(includeSnapshotFileName?: string) { @@ -222,8 +221,8 @@ export class Response { text.push(`- New console entries: ${tabSnapshot.consoleLink}`); if (tabSnapshot?.events.filter(event => event.type !== 'request').length) { for (const event of tabSnapshot.events) { - if (event.type === 'console' && this._context.config.outputMode !== 'file' && this._context.config.snapshot.mode !== 'none') { - if (shouldIncludeMessage(this._context.config.console.level, event.message.type)) + if (event.type === 'console' && this._context.config.outputMode !== 'file' && this._context.config.snapshot?.mode !== 'none') { + if (shouldIncludeMessage(this._context.config.console?.level, event.message.type)) text.push(`- ${trimMiddle(event.message.toString(), 100)}`); } else if (event.type === 'download-start') { text.push(`- Downloading file ${event.download.download.suggestedFilename()} ...`); diff --git a/packages/playwright-core/src/mcp/browser/sessionLog.ts b/packages/playwright-core/src/mcp/browser/sessionLog.ts index 8edae8fd02c89..8e213031d270b 100644 --- a/packages/playwright-core/src/mcp/browser/sessionLog.ts +++ b/packages/playwright-core/src/mcp/browser/sessionLog.ts @@ -17,11 +17,10 @@ import fs from 'fs'; import path from 'path'; -import { outputFile } from './config'; +import { outputFile } from './context'; import { parseResponse } from './response'; -import type { FullConfig } from './config'; -import type * as mcpServer from '../sdk/server'; +import type { ContextConfig } from './context'; export class SessionLog { private _folder: string; @@ -33,8 +32,8 @@ export class SessionLog { this._file = path.join(this._folder, 'session.md'); } - static async create(config: FullConfig, clientInfo: mcpServer.ClientInfo): Promise { - const sessionFolder = await outputFile(config, clientInfo, `session-${Date.now()}`, { origin: 'code' }); + static async create(config: ContextConfig, cwd: string): Promise { + const sessionFolder = await outputFile({ config, cwd }, `session-${Date.now()}`, { origin: 'code' }); await fs.promises.mkdir(sessionFolder, { recursive: true }); // eslint-disable-next-line no-console console.error(`Session: ${sessionFolder}`); diff --git a/packages/playwright-core/src/mcp/browser/tab.ts b/packages/playwright-core/src/mcp/browser/tab.ts index 7e31209505196..4a8cc2b72d83d 100644 --- a/packages/playwright-core/src/mcp/browser/tab.ts +++ b/packages/playwright-core/src/mcp/browser/tab.ts @@ -28,11 +28,12 @@ import { LogFile } from './logFile'; import { ModalState } from './tools/tool'; import { handleDialog } from './tools/dialogs'; import { uploadFile } from './tools/files'; +import { disposeAll } from '../../client/disposable'; + import type { Disposable } from '../../client/disposable'; -import type { Context } from './context'; +import type { Context, ContextConfig } from './context'; import type { Page } from '../../client/page'; import type { Locator } from '../../client/locator'; -import type { FullConfig } from './config'; const TabEvents = { modalState: 'modalState' @@ -103,9 +104,9 @@ export class Tab extends EventEmitter { private _recentEventEntries: EventEntry[] = []; private _consoleLog: LogFile; private _disposables: Disposable[]; - readonly actionTimeoutOptions: { timeout: number; }; - readonly navigationTimeoutOptions: { timeout: number; }; - readonly expectTimeoutOptions: { timeout: number; }; + readonly actionTimeoutOptions: { timeout?: number; }; + readonly navigationTimeoutOptions: { timeout?: number; }; + readonly expectTimeoutOptions: { timeout?: number; }; constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { super(); @@ -137,15 +138,13 @@ export class Tab extends EventEmitter { const wallTime = Date.now(); this._consoleLog = new LogFile(this.context, wallTime, 'console', 'Console'); this._initializedPromise = this._initialize(); - this.actionTimeoutOptions = { timeout: context.config.timeouts.action }; - this.navigationTimeoutOptions = { timeout: context.config.timeouts.navigation }; - this.expectTimeoutOptions = { timeout: context.config.timeouts.expect }; + this.actionTimeoutOptions = { timeout: context.config.timeouts?.action }; + this.navigationTimeoutOptions = { timeout: context.config.timeouts?.navigation }; + this.expectTimeoutOptions = { timeout: context.config.timeouts?.expect }; } async dispose() { - for (const disposable of this._disposables) - await disposable.dispose(); - this._disposables = []; + await disposeAll(this._disposables); this._consoleLog.stop(); } @@ -170,7 +169,7 @@ export class Tab extends EventEmitter { const requests = await this.page.requests().catch(() => []); for (const request of requests.filter(r => r.existingResponse() || r.failure())) this._requests.push(request); - for (const initPage of this.context.config.browser.initPage || []) { + for (const initPage of this.context.config.browser?.initPage || []) { try { const { default: func } = await import(url.pathToFileURL(initPage).href); await func({ page: this.page }); @@ -496,7 +495,7 @@ function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage { }; } -export function renderModalStates(config: FullConfig, modalStates: ModalState[]): string[] { +export function renderModalStates(config: ContextConfig, modalStates: ModalState[]): string[] { const result: string[] = []; if (modalStates.length === 0) result.push('- There is no modal state present'); @@ -509,9 +508,9 @@ type ConsoleMessageType = ReturnType; type ConsoleMessageLevel = 'error' | 'warning' | 'info' | 'debug'; const consoleMessageLevels: ConsoleMessageLevel[] = ['error', 'warning', 'info', 'debug']; -export function shouldIncludeMessage(thresholdLevel: ConsoleMessageLevel, type: ConsoleMessageType): boolean { +export function shouldIncludeMessage(thresholdLevel: ConsoleMessageLevel | undefined, type: ConsoleMessageType): boolean { const messageLevel = consoleLevelForMessageType(type); - return consoleMessageLevels.indexOf(messageLevel) <= consoleMessageLevels.indexOf(thresholdLevel); + return consoleMessageLevels.indexOf(messageLevel) <= consoleMessageLevels.indexOf(thresholdLevel || 'info'); } function consoleLevelForMessageType(type: ConsoleMessageType): ConsoleMessageLevel { diff --git a/packages/playwright-core/src/mcp/browser/tools.ts b/packages/playwright-core/src/mcp/browser/tools.ts index cae0bf3267740..eea07dc6b5d72 100644 --- a/packages/playwright-core/src/mcp/browser/tools.ts +++ b/packages/playwright-core/src/mcp/browser/tools.ts @@ -23,7 +23,6 @@ import dialogs from './tools/dialogs'; import evaluate from './tools/evaluate'; import files from './tools/files'; import form from './tools/form'; -import install from './tools/install'; import keyboard from './tools/keyboard'; import mouse from './tools/mouse'; import navigate from './tools/navigate'; @@ -42,7 +41,7 @@ import wait from './tools/wait'; import webstorage from './tools/webstorage'; import type { Tool } from './tools/tool'; -import type { FullConfig } from './config'; +import type { ContextConfig } from './context'; export const browserTools: Tool[] = [ ...common, @@ -54,7 +53,6 @@ export const browserTools: Tool[] = [ ...evaluate, ...files, ...form, - ...install, ...keyboard, ...mouse, ...navigate, @@ -73,6 +71,6 @@ export const browserTools: Tool[] = [ ...webstorage, ]; -export function filteredTools(config: FullConfig) { +export function filteredTools(config: Pick) { return browserTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)).filter(tool => !tool.skillOnly); } diff --git a/packages/playwright-core/src/mcp/browser/tools/install.ts b/packages/playwright-core/src/mcp/browser/tools/install.ts deleted file mode 100644 index 00a66635efe35..0000000000000 --- a/packages/playwright-core/src/mcp/browser/tools/install.ts +++ /dev/null @@ -1,60 +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 { fork } from 'child_process'; -import path from 'path'; - -import { z } from '../../../mcpBundle'; -import { defineTool } from './tool'; -import { renderTabsMarkdown } from '../response'; - -const install = defineTool({ - capability: 'core-install', - schema: { - name: 'browser_install', - title: 'Install the browser specified in the config', - description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.', - inputSchema: z.object({}), - type: 'action', - }, - - handle: async (context, params, response) => { - const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome'; - const cliPath = path.join(require.resolve('playwright-core/package.json'), '../cli.js'); - const child = fork(cliPath, ['install', channel], { - stdio: 'pipe', - }); - const output: string[] = []; - child.stdout?.on('data', data => output.push(data.toString())); - child.stderr?.on('data', data => output.push(data.toString())); - await new Promise((resolve, reject) => { - child.on('close', code => { - if (code === 0) - resolve(); - else - reject(new Error(`Failed to install browser: ${output.join('')}`)); - }); - }); - response.addTextResult(`Browser ${channel} installed.`); - const tabHeaders = await Promise.all(context.tabs().map(tab => tab.headerSnapshot())); - const result = renderTabsMarkdown(tabHeaders); - response.addTextResult(result.join('\n')); - }, -}); - -export default [ - install, -]; diff --git a/packages/playwright-core/src/mcp/exports.ts b/packages/playwright-core/src/mcp/exports.ts index 1e9b6bb529a3a..16b84f6c6e273 100644 --- a/packages/playwright-core/src/mcp/exports.ts +++ b/packages/playwright-core/src/mcp/exports.ts @@ -17,19 +17,14 @@ // SDK export * from './sdk/server'; export * from './sdk/tool'; -export * from './sdk/http'; export { browserTools } from './browser/tools'; export { BrowserServerBackend } from './browser/browserServerBackend'; -export { contextFactory, identityBrowserContextFactory } from './browser/browserContextFactory'; -export { defaultConfig, resolveConfig } from './browser/config'; export { parseResponse } from './browser/response'; export { Tab } from './browser/tab'; export { setupExitWatchdog } from './browser/watchdog'; -export type { BrowserContextFactory } from './browser/browserContextFactory'; -export type { FullConfig } from './browser/config'; export type { Tool as BrowserTool } from './browser/tools/tool'; export { logUnhandledError } from './log'; -export type { Config, ToolCapability } from './config'; +export type { ContextConfig } from './browser/context'; export { startMcpDaemonServer } from '../cli/daemon/daemon'; export { sessionConfigFromArgs } from '../cli/client/program'; export { createClientInfo } from '../cli/client/registry'; diff --git a/packages/playwright-core/src/mcp/program.ts b/packages/playwright-core/src/mcp/program.ts index d043d50d005a4..1ec90c6653412 100644 --- a/packages/playwright-core/src/mcp/program.ts +++ b/packages/playwright-core/src/mcp/program.ts @@ -140,3 +140,19 @@ export function decorateMCPCommand(command: Command, version: string) { await mcpServer.start(factory, config.server); }); } + +export function decorateMCPInstallBrowserCommand(command: Command, version: string) { + command + .description('ensure browsers necessary for this version of Playwright are installed') + .option('--with-deps', 'install system dependencies for browsers') + .option('--dry-run', 'do not execute installation, only print information') + .option('--list', 'prints list of browsers from all playwright installations') + .option('--force', 'force reinstall of already installed browsers') + .option('--only-shell', 'only install headless shell when installing chromium') + .option('--no-shell', 'do not install chromium headless shell') + .action(async options => { + const { program } = require('../program'); + const argv = process.argv.map(arg => arg === 'install-browser' ? 'install' : arg); + program.parse(argv); + }); +} diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index a7b44c1542a19..5a0f5b36bed94 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1231,8 +1231,6 @@ scheme.PageRouteEvent = tObject({ }); scheme.PageScreencastFrameEvent = tObject({ data: tBinary, - width: tInt, - height: tInt, }); scheme.PageWebSocketRouteEvent = tObject({ webSocketRoute: tChannel(['WebSocketRoute']), @@ -1550,7 +1548,7 @@ scheme.PagePickLocatorResult = tObject({ scheme.PageCancelPickLocatorParams = tOptional(tObject({})); scheme.PageCancelPickLocatorResult = tOptional(tObject({})); scheme.PageStartScreencastParams = tObject({ - size: tOptional(tObject({ + maxSize: tOptional(tObject({ width: tInt, height: tInt, })), diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 2f610da3f319c..9f56a3b9e5717 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -886,11 +886,11 @@ class FrameSession { this._client._sendMayFail('Page.screencastFrameAck', { sessionId: payload.sessionId }); }); const buffer = Buffer.from(payload.data, 'base64'); - this._page.emit(Page.Events.ScreencastFrame, { + this._page.screencast.onScreencastFrame({ buffer, frameSwapWallTime: payload.metadata.timestamp ? payload.metadata.timestamp * 1000 : Date.now(), - width: payload.metadata.deviceWidth, - height: payload.metadata.deviceHeight, + viewportWidth: payload.metadata.deviceWidth, + viewportHeight: payload.metadata.deviceHeight, }); } diff --git a/packages/playwright-core/src/server/devtoolsController.ts b/packages/playwright-core/src/server/devtoolsController.ts index 0a85d3498d577..ed431eea0ae76 100644 --- a/packages/playwright-core/src/server/devtoolsController.ts +++ b/packages/playwright-core/src/server/devtoolsController.ts @@ -28,6 +28,7 @@ import type { RegisteredListener } from '../utils'; import type { Transport } from './utils/httpServer'; import type { CRBrowser } from './chromium/crBrowser'; import type { ElementInfo } from '@recorder/recorderTypes'; +import type { ScreencastListener } from './screencast'; import type { DevToolsChannel, DevToolsChannelEvents, Tab } from '@devtools/devtoolsChannel'; export class DevToolsController { @@ -68,7 +69,7 @@ class DevToolsConnection implements Transport, DevToolsChannel { selectedPage: Page | null = null; private _lastFrameData: string | null = null; private _lastViewportSize: { width: number, height: number } | null = null; - private _pageListeners: RegisteredListener[] = []; + private _screencastFrameListener: ScreencastListener | null = null; private _contextListeners: RegisteredListener[] = []; private _recorderListeners: RegisteredListener[] = []; private _context: BrowserContext; @@ -240,9 +241,8 @@ class DevToolsConnection implements Transport, DevToolsChannel { return; if (this.selectedPage) { - eventsHelper.removeEventListeners(this._pageListeners); - this._pageListeners = []; - await this.selectedPage.screencast.stopScreencast(this); + await this.selectedPage.screencast.stopScreencast(this._screencastFrameListener!); + this._screencastFrameListener = null; } this.selectedPage = page; @@ -250,11 +250,8 @@ class DevToolsConnection implements Transport, DevToolsChannel { this._lastViewportSize = null; this._sendTabList(); - this._pageListeners.push( - eventsHelper.addEventListener(page, Page.Events.ScreencastFrame, frame => this._writeFrame(frame.buffer, frame.width, frame.height)) - ); - - await page.screencast.startScreencast(this, { width: 1280, height: 800, quality: 90 }); + this._screencastFrameListener = frame => this._writeFrame(frame.buffer, frame.viewportWidth, frame.viewportHeight); + await page.screencast.startScreencast(this._screencastFrameListener, { width: 1280, height: 800, quality: 90 }); } private async _deselectPage() { @@ -262,9 +259,9 @@ class DevToolsConnection implements Transport, DevToolsChannel { return; const promises = []; promises.push(this._cancelPicking()); - eventsHelper.removeEventListeners(this._pageListeners); - this._pageListeners = []; - promises.push(this.selectedPage.screencast.stopScreencast(this)); + const screencastFrameListener = this._screencastFrameListener!; + this._screencastFrameListener = null; + promises.push(this.selectedPage.screencast.stopScreencast(screencastFrameListener)); this.selectedPage = null; this._lastFrameData = null; this._lastViewportSize = null; diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 511b529e870bc..bbc9bd2146f56 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -37,6 +37,7 @@ import { Recorder } from '../recorder'; import { RecorderApp } from '../recorder/recorderApp'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; import { JSHandleDispatcher } from './jsHandleDispatcher'; +import { disposeAll } from '../disposable'; import type { ConsoleMessage } from '../console'; import type { Dialog } from '../dialog'; @@ -433,9 +434,7 @@ export class BrowserContextDispatcher extends Dispatcher {}); - for (const disposable of this._disposables) - disposable.dispose().catch(() => {}); - this._disposables = []; + disposeAll(this._disposables).catch(() => {}); if (this._routeWebSocketInitScript) WebSocketRouteDispatcher.uninstall(this.connection, this._context, this._routeWebSocketInitScript).catch(() => {}); this._routeWebSocketInitScript = undefined; diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index df6fb0e7540c5..14bd1b0aa0412 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -17,7 +17,6 @@ import { Page, Worker } from '../page'; import { Dispatcher } from './dispatcher'; import { parseError, serializeError } from '../errors'; -import { validateVideoSize } from '../browserContext'; import { ArtifactDispatcher } from './artifactDispatcher'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; import { FrameDispatcher } from './frameDispatcher'; @@ -31,6 +30,7 @@ import { SdkObject } from '../instrumentation'; import { deserializeURLMatch, urlMatches } from '../../utils/isomorphic/urlMatch'; import { PageAgentDispatcher } from './pageAgentDispatcher'; import { Recorder } from '../recorder'; +import { disposeAll } from '../disposable'; import type { Artifact } from '../artifact'; import type { BrowserContext } from '../browserContext'; @@ -47,6 +47,7 @@ import type * as channels from '@protocol/channels'; import type { Progress } from '@protocol/progress'; import type { URLMatch } from '../../utils/isomorphic/urlMatch'; import type { ScreencastFrame } from '../types'; +import type { ScreencastListener } from '../screencast'; export class PageDispatcher extends Dispatcher implements channels.PageChannel { _type_EventTarget = true; @@ -61,6 +62,7 @@ export class PageDispatcher extends Dispatcher(); private _jsCoverageActive = false; private _cssCoverageActive = false; + private _screencastListener: ScreencastListener | null = null; static from(parentScope: BrowserContextDispatcher, page: Page): PageDispatcher { return PageDispatcher.fromNullable(parentScope, page)!; @@ -109,7 +111,6 @@ export class PageDispatcher extends Dispatcher this._dispatchEvent('screencastFrame', { data: frame.buffer, width: frame.width, height: frame.height })); this.addObjectListener(Page.Events.EmulatedSizeChanged, () => this._dispatchEvent('viewportSizeChanged', { viewportSize: page.emulatedSize()?.viewport })); this.addObjectListener(Page.Events.FileChooser, (fileChooser: FileChooser) => this._dispatchEvent('fileChooser', { element: ElementHandleDispatcher.from(mainFrame, fileChooser.element()), @@ -364,12 +365,24 @@ export class PageDispatcher extends Dispatcher { - const size = validateVideoSize(params.size, this._page.emulatedSize()?.viewport); - await this._page.screencast.startScreencast(this, { quality: 90, width: size.width, height: size.height }); + if (this._screencastListener) + throw new Error('Screencast is already running'); + const size = params.maxSize || { width: 800, height: 800 }; + this._screencastListener = (frame: ScreencastFrame) => { + this._dispatchEvent('screencastFrame', { data: frame.buffer }); + }; + await this._page.screencast.startScreencast(this._screencastListener, { quality: 90, width: size.width, height: size.height }); } async stopScreencast(params: channels.PageStopScreencastParams, progress?: Progress): Promise { - return this._page.screencast.stopScreencast(this); + await this._stopScreencast(); + } + + private async _stopScreencast() { + const listener = this._screencastListener; + this._screencastListener = null; + if (listener) + await this._page.screencast.stopScreencast(listener); } async videoStart(params: channels.PageVideoStartParams, progress: Progress): Promise { @@ -425,9 +438,7 @@ export class PageDispatcher extends Dispatcher {}); - for (const disposable of this._disposables) - disposable.dispose().catch(() => {}); - this._disposables = []; + disposeAll(this._disposables).catch(() => {}); if (this._routeWebSocketInitScript) WebSocketRouteDispatcher.uninstall(this.connection, this._page, this._routeWebSocketInitScript).catch(() => {}); this._routeWebSocketInitScript = undefined; @@ -441,6 +452,7 @@ export class PageDispatcher extends Dispatcher {}); this._cssCoverageActive = false; + this._stopScreencast().catch(() => {}); } async setDockTile(params: channels.PageSetDockTileParams): Promise { diff --git a/packages/playwright-core/src/server/disposable.ts b/packages/playwright-core/src/server/disposable.ts index dd3a04e36a2a6..de64b8527fa75 100644 --- a/packages/playwright-core/src/server/disposable.ts +++ b/packages/playwright-core/src/server/disposable.ts @@ -33,3 +33,9 @@ export abstract class DisposableObject extends SdkObject implements Disposable { abstract dispose(): Promise; } + +export async function disposeAll(disposables: Disposable[]) { + const copy = [...disposables]; + disposables.length = 0; + await Promise.all(copy.map(d => d.dispose())); +} diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index f896a6196d7b9..ef3f39119044e 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -490,11 +490,11 @@ export class FFPage implements PageDelegate { }); const buffer = Buffer.from(event.data, 'base64'); - this._page.emit(Page.Events.ScreencastFrame, { + this._page.screencast.onScreencastFrame({ buffer, frameSwapWallTime: event.timestamp * 1000, // timestamp is in seconds, we need to convert to milliseconds. - width: event.deviceWidth, - height: event.deviceHeight, + viewportWidth: event.deviceWidth, + viewportHeight: event.deviceHeight, }); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 51a91df429bee..95cd244ebb996 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -131,7 +131,6 @@ const PageEvent = { FrameDetached: 'framedetached', InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument', LocatorHandlerTriggered: 'locatorhandlertriggered', - ScreencastFrame: 'screencastframe', WebSocket: 'websocket', Worker: 'worker', } as const; @@ -146,7 +145,6 @@ export type PageEventMap = { [PageEvent.FrameDetached]: [frame: frames.Frame]; [PageEvent.InternalFrameNavigatedToNewDocument]: [frame: frames.Frame]; [PageEvent.LocatorHandlerTriggered]: [uid: number]; - [PageEvent.ScreencastFrame]: [frame: types.ScreencastFrame]; [PageEvent.WebSocket]: [webSocket: network.WebSocket]; [PageEvent.Worker]: [worker: Worker]; }; diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts index 42c6f446408f6..6e59e42d6a358 100644 --- a/packages/playwright-core/src/server/screencast.ts +++ b/packages/playwright-core/src/server/screencast.ts @@ -15,7 +15,7 @@ */ import path from 'path'; -import { assert, createGuid, eventsHelper, RegisteredListener } from '../utils'; +import { assert, createGuid } from '../utils'; import { debugLogger } from '../utils'; import { VideoRecorder } from './videoRecorder'; import { Page } from './page'; @@ -24,17 +24,19 @@ import { validateVideoSize } from './browserContext'; import type * as types from './types'; +export type ScreencastListener = (frame: types.ScreencastFrame) => void; + export class Screencast { private _page: Page; private _videoRecorder: VideoRecorder | null = null; private _videoId: string | null = null; - private _screencastClients = new Set(); + private _listeners = new Set(); private _screencastOptions: { width: number, height: number, quality: number } | null = null; // Aiming at 25 fps by default - each frame is 40ms, but we give some slack with 35ms. // When throttling for tracing, 200ms between frames, except for 10 frames around the action. private _frameThrottler = new FrameThrottler(10, 35, 200); - private _frameListener: RegisteredListener | null = null; + private _videoFrameListener: ScreencastListener | null = null; constructor(page: Page) { this._page = page; @@ -44,9 +46,16 @@ export class Screencast { this._frameThrottler.dispose(); } - setOptions(options: { width: number, height: number, quality: number } | null) { - this._setOptions(options).catch(e => debugLogger.log('error', e)); - this._frameThrottler.setThrottlingEnabled(!!options); + startForTracing(listener: ScreencastListener) { + // If screencast is already running, use the same options, it's ok for tracing. + const options = this._screencastOptions || { width: 800, height: 800, quality: 90 }; + this.startScreencast(listener, options).catch(e => debugLogger.log('error', e)); + this._frameThrottler.setThrottlingEnabled(true); + } + + stopForTracing(listener: ScreencastListener) { + this.stopScreencast(listener).catch(e => debugLogger.log('error', e)); + this._frameThrottler.setThrottlingEnabled(false); } throttleFrameAck(ack: () => void) { @@ -81,7 +90,7 @@ export class Screencast { }; this._videoRecorder = new VideoRecorder(ffmpegPath, videoOptions); - this._frameListener = eventsHelper.addEventListener(this._page, Page.Events.ScreencastFrame, frame => this._videoRecorder!.writeFrame(frame.buffer, frame.frameSwapWallTime / 1000)); + this._videoFrameListener = frame => this._videoRecorder!.writeFrame(frame.buffer, frame.frameSwapWallTime / 1000); this._page.waitForInitializedOrError().then(p => { if (p instanceof Error) this.stopVideoRecording().catch(() => {}); @@ -93,7 +102,7 @@ export class Screencast { const videoId = this._videoId; assert(videoId); this._page.once(Page.Events.Close, () => this.stopVideoRecording().catch(() => {})); - await this.startScreencast(this._videoRecorder, { + await this.startScreencast(this._videoFrameListener!, { quality: 90, width: options.width, height: options.height, @@ -104,14 +113,13 @@ export class Screencast { async stopVideoRecording(): Promise { if (!this._videoId) return; - if (this._frameListener) - eventsHelper.removeEventListeners([this._frameListener]); - this._frameListener = null; + const videoFrameListener = this._videoFrameListener!; + this._videoFrameListener = null; const videoId = this._videoId; this._videoId = null; const videoRecorder = this._videoRecorder!; this._videoRecorder = null; - await this.stopScreencast(videoRecorder); + await this.stopScreencast(videoFrameListener); await videoRecorder.stop(); // Keep the video artifact in the map until encoding is fully finished, if the context // starts closing before the video is fully written to disk it will wait for it. @@ -133,20 +141,13 @@ export class Screencast { await this.stopVideoRecording(); } - private async _setOptions(options: { width: number, height: number, quality: number } | null): Promise { - if (options) - await this.startScreencast(this, options); - else - await this.stopScreencast(this); - } - - async startScreencast(client: unknown, options: { width: number, height: number, quality: number }) { + async startScreencast(listener: ScreencastListener, options: { width: number, height: number, quality: number }) { if (this._screencastOptions) { if (options.width !== this._screencastOptions.width || options.height !== this._screencastOptions.height || options.quality !== this._screencastOptions.quality) throw new Error(`Screencast is already running with different options (${this._screencastOptions.width}x${this._screencastOptions.height} quality=${this._screencastOptions.quality})`); } - this._screencastClients.add(client); - if (this._screencastClients.size === 1) { + this._listeners.add(listener); + if (this._listeners.size === 1) { this._screencastOptions = options; await this._page.delegate.startScreencast({ width: options.width, @@ -156,13 +157,18 @@ export class Screencast { } } - async stopScreencast(client: unknown) { - this._screencastClients.delete(client); - if (!this._screencastClients.size) { + async stopScreencast(listener: ScreencastListener) { + this._listeners.delete(listener); + if (!this._listeners.size) { this._screencastOptions = null; await this._page.delegate.stopScreencast(); } } + + onScreencastFrame(frame: types.ScreencastFrame) { + for (const listener of this._listeners) + listener(frame); + } } class FrameThrottler { diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 6888b688665cd..93d9ce5817fe6 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -49,6 +49,8 @@ import type * as har from '@trace/har'; import type { FrameSnapshot } from '@trace/snapshot'; import type * as trace from '@trace/trace'; import type { Progress } from '@protocol/progress'; +import type * as types from '../../types'; +import type { ScreencastListener } from '../../screencast'; const version: trace.VERSION = 8; @@ -74,13 +76,12 @@ type RecordingState = { groupStack: string[]; }; -const kScreencastOptions = { width: 800, height: 600, quality: 90 }; - export class Tracing extends SdkObject implements InstrumentationListener, SnapshotterDelegate, HarTracerDelegate { private _fs = new SerializedFS(); private _snapshotter?: Snapshotter; private _harTracer: HarTracer; private _screencastListeners: RegisteredListener[] = []; + private _pageScreencastListeners = new Map(); private _eventListeners: RegisteredListener[] = []; private _context: BrowserContext | APIRequestContext; // Note: state should only be touched inside API methods, but not inside trace operations. @@ -280,8 +281,13 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps eventsHelper.removeEventListeners(this._screencastListeners); if (!(this._context instanceof BrowserContext)) return; - for (const page of this._context.pages()) - page.screencast.setOptions(null); + for (const page of this._context.pages()) { + const listener = this._pageScreencastListeners.get(page); + if (listener) { + page.screencast.stopForTracing(listener); + this._pageScreencastListeners.delete(page); + } + } } private _allocateNewTraceFile(state: RecordingState) { @@ -592,26 +598,25 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps } private _startScreencastInPage(page: Page) { - page.screencast.setOptions(kScreencastOptions); const prefix = page.guid; - this._screencastListeners.push( - eventsHelper.addEventListener(page, Page.Events.ScreencastFrame, params => { - const suffix = params.timestamp || Date.now(); - const sha1 = `${prefix}-${suffix}.jpeg`; - const event: trace.ScreencastFrameTraceEvent = { - type: 'screencast-frame', - pageId: page.guid, - sha1, - width: params.width, - height: params.height, - timestamp: monotonicTime(), - frameSwapWallTime: params.frameSwapWallTime, - }; - // Make sure to write the screencast frame before adding a reference to it. - this._appendResource(sha1, params.buffer); - this._appendTraceEvent(event); - }), - ); + const listener = (params: types.ScreencastFrame) => { + const suffix = Date.now(); + const sha1 = `${prefix}-${suffix}.jpeg`; + const event: trace.ScreencastFrameTraceEvent = { + type: 'screencast-frame', + pageId: page.guid, + sha1, + width: params.viewportWidth, + height: params.viewportHeight, + timestamp: monotonicTime(), + frameSwapWallTime: params.frameSwapWallTime, + }; + // Make sure to write the screencast frame before adding a reference to it. + this._appendResource(sha1, params.buffer); + this._appendTraceEvent(event); + }; + this._pageScreencastListeners.set(page, listener); + page.screencast.startForTracing(listener); } private _appendTraceEvent(event: trace.TraceEvent) { diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts index a400152d7f8fb..1b28efb468602 100644 --- a/packages/playwright-core/src/server/types.ts +++ b/packages/playwright-core/src/server/types.ts @@ -52,8 +52,8 @@ export type VideoOptions = { export type ScreencastFrame = { buffer: Buffer, frameSwapWallTime: number, - width: number, - height: number, + viewportWidth: number, + viewportHeight: number, }; export type Credentials = { diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 9eb7695498180..a2c848e72010e 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -947,7 +947,7 @@ export class WKPage implements PageDelegate { this._pageProxySession.sendMayFail('Screencast.screencastFrameAck', { generation }); }); const buffer = Buffer.from(event.data, 'base64'); - this._page.emit(Page.Events.ScreencastFrame, { + this._page.screencast.onScreencastFrame({ buffer, frameSwapWallTime: event.timestamp // timestamp is in seconds, we need to convert to milliseconds. @@ -956,8 +956,8 @@ export class WKPage implements PageDelegate { // version that did not send timestamp. // TODO: remove this fallback when Debian 11 and Ubuntu 20.04 are EOL. : Date.now(), - width: event.deviceWidth, - height: event.deviceHeight, + viewportWidth: event.deviceWidth, + viewportHeight: event.deviceHeight, }); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 9227156952db2..3089d164b44a1 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -20445,7 +20445,7 @@ export interface Inspector { * console.log(`frame ${width}x${height}, jpeg size: ${data.length}`); * require('fs').writeFileSync('frame.jpg', data); * }); - * await inspector.startScreencast({ size: { width: 1280, height: 720 } }); + * await inspector.startScreencast({ maxSize: { width: 1200, height: 800 } }); * // ... perform actions ... * await inspector.stopScreencast(); * ``` @@ -20456,16 +20456,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20476,16 +20466,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20499,7 +20479,7 @@ export interface Inspector { * console.log(`frame ${width}x${height}, jpeg size: ${data.length}`); * require('fs').writeFileSync('frame.jpg', data); * }); - * await inspector.startScreencast({ size: { width: 1280, height: 720 } }); + * await inspector.startScreencast({ maxSize: { width: 1200, height: 800 } }); * // ... perform actions ... * await inspector.stopScreencast(); * ``` @@ -20510,16 +20490,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20530,16 +20500,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20550,16 +20510,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20573,7 +20523,7 @@ export interface Inspector { * console.log(`frame ${width}x${height}, jpeg size: ${data.length}`); * require('fs').writeFileSync('frame.jpg', data); * }); - * await inspector.startScreencast({ size: { width: 1280, height: 720 } }); + * await inspector.startScreencast({ maxSize: { width: 1200, height: 800 } }); * // ... perform actions ... * await inspector.stopScreencast(); * ``` @@ -20584,16 +20534,6 @@ export interface Inspector { * JPEG-encoded frame data. */ data: Buffer; - - /** - * Frame width in pixels. - */ - width: number; - - /** - * Frame height in pixels. - */ - height: number; }) => any): this; /** @@ -20630,7 +20570,7 @@ export interface Inspector { * inspector.on('screencastframe', ({ data, width, height }) => { * console.log(`frame ${width}x${height}, size: ${data.length}`); * }); - * await inspector.startScreencast({ size: { width: 800, height: 600 } }); + * await inspector.startScreencast({ maxSize: { width: 800, height: 600 } }); * // ... perform actions ... * await inspector.stopScreencast(); * ``` @@ -20639,16 +20579,17 @@ export interface Inspector { */ startScreencast(options?: { /** - * Optional dimensions for the screencast frames. If not specified, the current page viewport size is used. + * Maximum screencast frame dimensions. The output frame may be smaller to preserve the page aspect ratio. Defaults to + * 800×800. */ - size?: { + maxSize?: { /** - * Frame width in pixels. + * Max frame width in pixels. */ width: number; /** - * Frame height in pixels. + * Max frame height in pixels. */ height: number; }; diff --git a/packages/playwright/src/mcp/test/browserBackend.ts b/packages/playwright/src/mcp/test/browserBackend.ts index 7368f493e41ed..12b698f274cd7 100644 --- a/packages/playwright/src/mcp/test/browserBackend.ts +++ b/packages/playwright/src/mcp/test/browserBackend.ts @@ -18,7 +18,7 @@ import path from 'path'; import fs from 'fs'; import { createGuid } from 'playwright-core/lib/utils'; import * as mcp from 'playwright-core/lib/mcp/exports'; -import { defaultConfig, BrowserServerBackend, Tab, identityBrowserContextFactory, startMcpDaemonServer, sessionConfigFromArgs, createClientInfo } from 'playwright-core/lib/mcp/exports'; +import { BrowserServerBackend } from 'playwright-core/lib/mcp/exports'; import { stripAnsiEscapes } from '../../util'; @@ -46,7 +46,7 @@ export function createCustomMessageHandler(testInfo: TestInfoImpl, context: play if (data.initialize) { if (backend) throw new Error('MCP backend is already initialized'); - const config: mcp.FullConfig = { ...defaultConfig, capabilities: ['testing'] }; + const config: mcp.ContextConfig = { capabilities: ['testing'] }; const tools = mcp.filteredTools(config); backend = new BrowserServerBackend(config, context, tools); await backend.initialize(data.initialize.clientInfo); @@ -97,7 +97,7 @@ async function generatePausedMessage(testInfo: TestInfoImpl, context: playwright `- Page Title: ${await page.title()}`.trim() ); // Only print console errors when pausing on error, not when everything works as expected. - let console = testInfo.errors.length ? await Tab.collectConsoleMessages(page) : []; + let console = testInfo.errors.length ? await mcp.Tab.collectConsoleMessages(page) : []; console = console.filter(msg => msg.type === 'error'); if (console.length) { lines.push('- Console Messages:'); @@ -125,17 +125,17 @@ export async function runDaemonForContext(testInfo: TestInfoImpl, context: playw const outputDir = path.join(testInfo.artifactsDir(), '.playwright-mcp'); const sessionName = `test-worker-${createGuid().slice(0, 6)}`; - const clientInfo = createClientInfo(); - const sessionConfig = sessionConfigFromArgs(clientInfo, sessionName, { _: [] }); + const clientInfo = mcp.createClientInfo(); + const sessionConfig = mcp.sessionConfigFromArgs(clientInfo, sessionName, { _: [] }); const sessionConfigFile = path.resolve(clientInfo.daemonProfilesDir, `${sessionName}.session`); await fs.promises.mkdir(path.dirname(sessionConfigFile), { recursive: true }); await fs.promises.writeFile(sessionConfigFile, JSON.stringify(sessionConfig, null, 2)); - await startMcpDaemonServer({ - ...defaultConfig, + + await mcp.startMcpDaemonServer({ outputMode: 'file', - snapshot: { mode: 'full', output: 'file' }, + snapshot: { mode: 'full' }, outputDir, - }, sessionConfig, identityBrowserContextFactory(context), true /* noShutdown */); + }, sessionConfig, context, true /* noShutdown */); const lines = ['']; if (testInfo.errors.length) { diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index b941c06c8705d..5e8c20cf200d8 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2198,8 +2198,6 @@ export type PageRouteEvent = { }; export type PageScreencastFrameEvent = { data: Binary, - width: number, - height: number, }; export type PageWebSocketRouteEvent = { webSocketRoute: WebSocketRouteChannel, @@ -2682,13 +2680,13 @@ export type PageCancelPickLocatorParams = {}; export type PageCancelPickLocatorOptions = {}; export type PageCancelPickLocatorResult = void; export type PageStartScreencastParams = { - size?: { + maxSize?: { width: number, height: number, }, }; export type PageStartScreencastOptions = { - size?: { + maxSize?: { width: number, height: number, }, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 0f6c9a4d3244e..344101d69e5e6 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2086,7 +2086,7 @@ Page: title: Start screencast group: configuration parameters: - size: + maxSize: type: object? properties: width: int @@ -2202,8 +2202,6 @@ Page: screencastFrame: parameters: data: binary - width: int - height: int webSocketRoute: parameters: diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index 72e3966af0cc8..7dc0771616a57 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -436,7 +436,7 @@ for (const params of [ browserTest.fixme(params.id === 'fit' && browserName === 'webkit' && platform === 'linux', 'Image size is flaky'); browserTest.fixme(browserName === 'firefox' && !headless, 'Image size is different'); - const scale = Math.min(800 / params.width, 600 / params.height, 1); + const scale = Math.min(800 / params.width, 800 / params.height, 1); const previewWidth = params.width * scale; const previewHeight = params.height * scale; diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index 1a153ab89eda7..da6ddf7a8e91f 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -811,8 +811,8 @@ it.describe('screencast', () => { expect(isAlmostRed(pixel)).toBe(true); }); - it('video.start/stop twice', async ({ browser, browserName }, testInfo) => { - const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 }; + it('video.start/stop twice', async ({ browser }, testInfo) => { + const size = { width: 800, height: 800 }; const context = await browser.newContext({ viewport: size }); const page = await context.newPage(); @@ -853,13 +853,11 @@ it.describe('screencast', () => { await context.close(); }); - it('video.start should fail when another recording is in progress', async ({ browser }, testInfo) => { - const context = await browser.newContext(); - const page = await context.newPage(); + it('video.start should fail when another recording is in progress', async ({ page, trace }) => { + it.skip(trace === 'on', 'trace=on has different screencast image configuration'); await page.video().start(); const error = await page.video().start().catch(e => e); expect(error.message).toContain('Video is already being recorded'); - await context.close(); }); it('video.stop should fail when no recording is in progress', async ({ browser }, testInfo) => { @@ -870,10 +868,10 @@ it.describe('screencast', () => { await context.close(); }); - it('video.start should finish when page is closed', async ({ browser, browserName }, testInfo) => { + it('video.start should finish when page is closed', async ({ browser }, testInfo) => { const context = await browser.newContext(); const page = await context.newPage(); - await page.video().start(); + await page.video().start({ size: { width: 800, height: 800 } }); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await rafraf(page, 100); const videoPath = await page.video().path(); @@ -887,8 +885,8 @@ it.describe('screencast', () => { await context.close(); }); - it('empty video', async ({ browser, browserName }, testInfo) => { - const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 }; + it('empty video', async ({ browser }, testInfo) => { + const size = { width: 800, height: 800 }; const context = await browser.newContext({ viewport: size }); const page = await context.newPage(); await page.video().start({ size }); @@ -898,15 +896,16 @@ it.describe('screencast', () => { expectFrames(videoPath, size, isAlmostWhite); }); - it('inspector.startScreencast emits screencastframe events', async ({ browser, server }) => { - const size = { width: 500, height: 400 }; - const context = await browser.newContext({ viewport: size }); + it('inspector.startScreencast emits screencastframe events', async ({ browser, server, trace }) => { + it.skip(trace === 'on', 'trace=on has different screencast image configuration'); + const context = await browser.newContext({ viewport: { width: 1000, height: 400 } }); const page = await context.newPage(); - const frames: { data: Buffer, width: number, height: number }[] = []; + const frames: { data: Buffer }[] = []; page.inspector().on('screencastframe', frame => frames.push(frame)); - await page.inspector().startScreencast({ size }); + const maxSize = { width: 500, height: 400 }; + await page.inspector().startScreencast({ maxSize }); await page.goto(server.EMPTY_PAGE); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await rafraf(page, 100); @@ -917,49 +916,71 @@ it.describe('screencast', () => { // Each frame must be a valid JPEG (starts with FF D8) expect(frame.data[0]).toBe(0xff); expect(frame.data[1]).toBe(0xd8); - expect(frame.width).toBe(size.width); - expect(frame.height).toBe(size.height); + const { width, height } = jpegDimensions(frame.data); + // Frame should be scaled down to fit the maximum size. + expect(width).toBe(500); + expect(height).toBe(200); } await context.close(); }); - it('startScreencast throws when called with different options while running', async ({ browser }) => { + it('startScreencast throws if already running', async ({ browser, trace }) => { + it.skip(trace === 'on', 'trace=on enables screencast with different options'); + const size = { width: 500, height: 400 }; const context = await browser.newContext({ viewport: size }); const page = await context.newPage(); - await page.inspector().startScreencast({ size }); - await expect(page.inspector().startScreencast({ size: { width: 320, height: 240 } })).rejects.toThrow('Screencast is already running with different options'); + await page.inspector().startScreencast({ maxSize: size }); + await expect(page.inspector().startScreencast({ maxSize: { width: 320, height: 240 } })).rejects.toThrow('Screencast is already running'); await page.inspector().stopScreencast(); await context.close(); }); - it('startScreencast allows restart with different options after stop', async ({ browser }) => { + it('startScreencast allows restart with different options after stop', async ({ browser, trace }) => { + it.skip(trace === 'on', 'trace=on enables screencast with different options'); + const context = await browser.newContext({ viewport: { width: 500, height: 400 } }); const page = await context.newPage(); - await page.inspector().startScreencast({ size: { width: 500, height: 400 } }); + await page.inspector().startScreencast({ maxSize: { width: 500, height: 400 } }); await page.inspector().stopScreencast(); // Different options should succeed once the previous screencast is stopped. - await expect(page.inspector().startScreencast({ size: { width: 320, height: 240 } })).resolves.toBeUndefined(); - + await page.inspector().startScreencast({ maxSize: { width: 320, height: 240 } }); await page.inspector().stopScreencast(); await context.close(); }); - it('startScreencast throws when video recording is running with different params', async ({ browser }) => { + it('startScreencast throws when video recording is running with different params', async ({ browser, trace }) => { + it.skip(trace === 'on', 'trace=on enables screencast with different options'); + const videoSize = { width: 500, height: 400 }; const context = await browser.newContext({ viewport: videoSize }); const page = await context.newPage(); await page.video().start({ size: videoSize }); - await expect(page.inspector().startScreencast({ size: { width: 320, height: 240 } })).rejects.toThrow('Screencast is already running with different options'); + await expect(page.inspector().startScreencast({ maxSize: { width: 320, height: 240 } })).rejects.toThrow('Screencast is already running with different options'); await page.video().stop(); await context.close(); }); + + it('video.start does not emit screencastframe events', async ({ page, server, trace }) => { + it.skip(trace === 'on', 'trace=on enables screencast frame events'); + + const frames = []; + page.inspector().on('screencastframe', frame => frames.push(frame)); + + await page.video().start({ size: { width: 320, height: 240 } }); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => document.body.style.backgroundColor = 'red'); + await rafraf(page, 100); + await page.video().stop(); + + expect(frames).toHaveLength(0); + }); }); it('should saveAs video', async ({ browser }, testInfo) => { @@ -983,3 +1004,22 @@ it('should saveAs video', async ({ browser }, testInfo) => { await page.video().saveAs(saveAsPath); expect(fs.existsSync(saveAsPath)).toBeTruthy(); }); + +function jpegDimensions(buffer: Buffer): { width: number, height: number } { + let i = 2; // skip SOI marker (FF D8) + while (i < buffer.length - 8) { + if (buffer[i] !== 0xFF) + break; + const marker = buffer[i + 1]; + const segmentLength = buffer.readUInt16BE(i + 2); + // SOF markers: C0 (baseline), C2 (progressive), C1, C3, C5-C7, C9-CB, CD-CF + if ((marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) || + (marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF)) { + const height = buffer.readUInt16BE(i + 5); + const width = buffer.readUInt16BE(i + 7); + return { width, height }; + } + i += 2 + segmentLength; + } + throw new Error('Could not parse JPEG dimensions'); +} diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index b5272a0ea395a..2ab5c44af6123 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -30,7 +30,6 @@ test('test snapshot tool list', async ({ client }) => { 'browser_select_option', 'browser_type', 'browser_close', - 'browser_install', 'browser_navigate_back', 'browser_navigate', 'browser_network_requests', diff --git a/tests/mcp/generator.spec.ts b/tests/mcp/generator.spec.ts index 9e593fd0ff28e..81bac70222380 100644 --- a/tests/mcp/generator.spec.ts +++ b/tests/mcp/generator.spec.ts @@ -38,7 +38,6 @@ test('generator tools intent', async ({ startClient }) => { expect(toolsWithIntent).toContain('browser_fill_form'); expect(toolsWithIntent).toContain('browser_handle_dialog'); expect(toolsWithIntent).toContain('browser_hover'); - expect(toolsWithIntent).toContain('browser_install'); expect(toolsWithIntent).toContain('browser_mouse_click_xy'); expect(toolsWithIntent).toContain('browser_mouse_drag_xy'); expect(toolsWithIntent).toContain('browser_mouse_move_xy'); diff --git a/tests/mcp/install.spec.ts b/tests/mcp/install.spec.ts deleted file mode 100644 index feb8081225c05..0000000000000 --- a/tests/mcp/install.spec.ts +++ /dev/null @@ -1,26 +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 { test, expect } from './fixtures'; - -test('browser_install', async ({ client, mcpBrowser }) => { - test.skip(mcpBrowser !== 'chromium', 'Test only chromium'); - expect(await client.callTool({ - name: 'browser_install', - })).toHaveResponse({ - result: expect.stringContaining(`No open tabs`), - }); -});