Skip to content

Commit c813e9c

Browse files
authored
feat(mcp): allow saving videos for sessions (#37531)
1 parent 72c62d8 commit c813e9c

File tree

10 files changed

+160
-41
lines changed

10 files changed

+160
-41
lines changed

packages/playwright/src/mcp/browser/browserContextFactory.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { outputFile } from './config';
2727
import { firstRootPath } from '../sdk/server';
2828

2929
import type { FullConfig } from './config';
30-
import type { LaunchOptions } from '../../../../playwright-core/src/client/types';
30+
import type { LaunchOptions, BrowserContextOptions } from '../../../../playwright-core/src/client/types';
3131
import type { ClientInfo } from '../sdk/server';
3232

3333
export function contextFactory(config: FullConfig): BrowserContextFactory {
@@ -42,8 +42,13 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
4242
return new PersistentContextFactory(config);
4343
}
4444

45+
export type BrowserContextFactoryResult = {
46+
browserContext: playwright.BrowserContext;
47+
close: (afterClose: () => Promise<void>) => Promise<void>;
48+
};
49+
4550
export interface BrowserContextFactory {
46-
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
51+
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<BrowserContextFactoryResult>;
4752
}
4853

4954
class BaseContextFactory implements BrowserContextFactory {
@@ -75,23 +80,27 @@ class BaseContextFactory implements BrowserContextFactory {
7580
throw new Error('Not implemented');
7681
}
7782

78-
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
83+
async createContext(clientInfo: ClientInfo): Promise<BrowserContextFactoryResult> {
7984
testDebug(`create browser context (${this._logName})`);
8085
const browser = await this._obtainBrowser(clientInfo);
8186
const browserContext = await this._doCreateContext(browser);
8287
await addInitScript(browserContext, this.config.browser.initScript);
83-
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
88+
return {
89+
browserContext,
90+
close: (afterClose: () => Promise<void>) => this._closeBrowserContext(browserContext, browser, afterClose)
91+
};
8492
}
8593

8694
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
8795
throw new Error('Not implemented');
8896
}
8997

90-
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
98+
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser, afterClose: () => Promise<void>) {
9199
testDebug(`close browser context (${this._logName})`);
92100
if (browser.contexts().length === 1)
93101
this._browserPromise = undefined;
94102
await browserContext.close().catch(logUnhandledError);
103+
await afterClose();
95104
if (browser.contexts().length === 0) {
96105
testDebug(`close browser (${this._logName})`);
97106
await browser.close().catch(logUnhandledError);
@@ -170,7 +179,7 @@ class PersistentContextFactory implements BrowserContextFactory {
170179
this.config = config;
171180
}
172181

173-
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
182+
async createContext(clientInfo: ClientInfo): Promise<BrowserContextFactoryResult> {
174183
await injectCdpPort(this.config.browser);
175184
testDebug('create browser context (persistent)');
176185
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo);
@@ -183,7 +192,7 @@ class PersistentContextFactory implements BrowserContextFactory {
183192

184193
const browserType = playwright[this.config.browser.browserName];
185194
for (let i = 0; i < 5; i++) {
186-
const launchOptions: LaunchOptions = {
195+
const launchOptions: LaunchOptions & BrowserContextOptions = {
187196
tracesDir,
188197
...this.config.browser.launchOptions,
189198
...this.config.browser.contextOptions,
@@ -197,7 +206,7 @@ class PersistentContextFactory implements BrowserContextFactory {
197206
try {
198207
const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions);
199208
await addInitScript(browserContext, this.config.browser.initScript);
200-
const close = () => this._closeBrowserContext(browserContext, userDataDir);
209+
const close = (afterClose: () => Promise<void>) => this._closeBrowserContext(browserContext, userDataDir, afterClose);
201210
return { browserContext, close };
202211
} catch (error: any) {
203212
if (error.message.includes('Executable doesn\'t exist'))
@@ -213,10 +222,11 @@ class PersistentContextFactory implements BrowserContextFactory {
213222
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
214223
}
215224

216-
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
225+
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string, afterClose: () => Promise<void>) {
217226
testDebug('close browser context (persistent)');
218227
testDebug('release user data dir', userDataDir);
219228
await browserContext.close().catch(() => {});
229+
await afterClose();
220230
this._userDataDirs.delete(userDataDir);
221231
testDebug('close browser context complete (persistent)');
222232
}
@@ -270,7 +280,7 @@ async function addInitScript(browserContext: playwright.BrowserContext, initScri
270280
}
271281

272282
export class SharedContextFactory implements BrowserContextFactory {
273-
private _contextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
283+
private _contextPromise: Promise<BrowserContextFactoryResult> | undefined;
274284
private _baseFactory: BrowserContextFactory;
275285
private static _instance: SharedContextFactory | undefined;
276286

@@ -313,6 +323,6 @@ export class SharedContextFactory implements BrowserContextFactory {
313323
if (!contextPromise)
314324
return;
315325
const { close } = await contextPromise;
316-
await close();
326+
await close(async () => {});
317327
}
318328
}

packages/playwright/src/mcp/browser/config.ts

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import type * as playwright from '../../../types/test';
2828
import type { Config, ToolCapability } from '../config';
2929
import type { ClientInfo } from '../sdk/server';
3030

31+
type ViewportSize = { width: number; height: number };
32+
3133
export type CLIOptions = {
3234
allowedOrigins?: string[];
3335
blockedOrigins?: string[];
@@ -53,14 +55,15 @@ export type CLIOptions = {
5355
proxyServer?: string;
5456
saveSession?: boolean;
5557
saveTrace?: boolean;
58+
saveVideo?: ViewportSize;
5659
secrets?: Record<string, string>;
5760
sharedBrowserContext?: boolean;
5861
storageState?: string;
5962
timeoutAction?: number;
6063
timeoutNavigation?: number;
6164
userAgent?: string;
6265
userDataDir?: string;
63-
viewportSize?: string;
66+
viewportSize?: ViewportSize;
6467
};
6568

6669
export const defaultConfig: FullConfig = {
@@ -127,6 +130,8 @@ async function validateConfig(config: FullConfig): Promise<void> {
127130
throw new Error(`Init script file does not exist: ${script}`);
128131
}
129132
}
133+
if (config.sharedBrowserContext && config.saveVideo)
134+
throw new Error('saveVideo is not supported when sharedBrowserContext is true');
130135
}
131136

132137
export function configFromCLIOptions(cliOptions: CLIOptions): Config {
@@ -183,16 +188,8 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
183188
if (cliOptions.userAgent)
184189
contextOptions.userAgent = cliOptions.userAgent;
185190

186-
if (cliOptions.viewportSize) {
187-
try {
188-
const [width, height] = cliOptions.viewportSize.split(',').map(n => +n);
189-
if (isNaN(width) || isNaN(height))
190-
throw new Error('bad values');
191-
contextOptions.viewport = { width, height };
192-
} catch (e) {
193-
throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
194-
}
195-
}
191+
if (cliOptions.viewportSize)
192+
contextOptions.viewport = cliOptions.viewportSize;
196193

197194
if (cliOptions.ignoreHttpsErrors)
198195
contextOptions.ignoreHTTPSErrors = true;
@@ -203,6 +200,14 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
203200
if (cliOptions.grantPermissions)
204201
contextOptions.permissions = cliOptions.grantPermissions;
205202

203+
if (cliOptions.saveVideo) {
204+
contextOptions.recordVideo = {
205+
// Videos are moved to output directory on saveAs.
206+
dir: tmpDir(),
207+
size: cliOptions.saveVideo,
208+
};
209+
}
210+
206211
const result: Config = {
207212
browser: {
208213
browserName,
@@ -225,6 +230,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
225230
},
226231
saveSession: cliOptions.saveSession,
227232
saveTrace: cliOptions.saveTrace,
233+
saveVideo: cliOptions.saveVideo,
228234
secrets: cliOptions.secrets,
229235
sharedBrowserContext: cliOptions.sharedBrowserContext,
230236
outputDir: cliOptions.outputDir,
@@ -266,13 +272,14 @@ function configFromEnv(): Config {
266272
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
267273
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
268274
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
275+
options.saveVideo = resolutionParser('--save-video', process.env.PLAYWRIGHT_MCP_SAVE_VIDEO);
269276
options.secrets = dotenvFileLoader(process.env.PLAYWRIGHT_MCP_SECRETS_FILE);
270277
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
271278
options.timeoutAction = numberParser(process.env.PLAYWRIGHT_MCP_TIMEOUT_ACTION);
272279
options.timeoutNavigation = numberParser(process.env.PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION);
273280
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
274281
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
275-
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
282+
options.viewportSize = resolutionParser('--viewport-size', process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
276283
return configFromCLIOptions(options);
277284
}
278285

@@ -287,27 +294,35 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
287294
}
288295
}
289296

290-
export async function outputFile(config: FullConfig, clientInfo: ClientInfo, fileName: string, options: { origin: 'code' | 'llm' | 'web' }): Promise<string> {
297+
function tmpDir(): string {
298+
return path.join(process.env.PW_TMPDIR_FOR_TEST ?? os.tmpdir(), 'playwright-mcp-output');
299+
}
300+
301+
export function outputDir(config: FullConfig, clientInfo: ClientInfo): string {
291302
const rootPath = firstRootPath(clientInfo);
292-
const outputDir = config.outputDir
303+
return config.outputDir
293304
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
294-
?? path.join(process.env.PW_TMPDIR_FOR_TEST ?? os.tmpdir(), 'playwright-mcp-output', String(clientInfo.timestamp));
305+
?? path.join(tmpDir(), String(clientInfo.timestamp));
306+
}
307+
308+
export async function outputFile(config: FullConfig, clientInfo: ClientInfo, fileName: string, options: { origin: 'code' | 'llm' | 'web' }): Promise<string> {
309+
const dir = outputDir(config, clientInfo);
295310

296311
// Trust code.
297312
if (options.origin === 'code')
298-
return path.resolve(outputDir, fileName);
313+
return path.resolve(dir, fileName);
299314

300315
// Trust llm to use valid characters in file names.
301316
if (options.origin === 'llm') {
302317
fileName = fileName.split('\\').join('/');
303-
const resolvedFile = path.resolve(outputDir, fileName);
304-
if (!resolvedFile.startsWith(path.resolve(outputDir) + path.sep))
318+
const resolvedFile = path.resolve(dir, fileName);
319+
if (!resolvedFile.startsWith(path.resolve(dir) + path.sep))
305320
throw new Error(`Resolved file path for ${fileName} is outside of the output directory`);
306321
return resolvedFile;
307322
}
308323

309324
// Do not trust web, at all.
310-
return path.join(outputDir, sanitizeForFilePath(fileName));
325+
return path.join(dir, sanitizeForFilePath(fileName));
311326
}
312327

313328
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
@@ -379,6 +394,27 @@ export function numberParser(value: string | undefined): number | undefined {
379394
return +value;
380395
}
381396

397+
export function resolutionParser(name: string, value: string | undefined): ViewportSize | undefined {
398+
if (!value)
399+
return undefined;
400+
if (value.includes('x')) {
401+
const [width, height] = value.split('x').map(v => +v);
402+
if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0)
403+
throw new Error(`Invalid resolution format: use ${name}="800x600"`);
404+
return { width, height };
405+
}
406+
407+
// Legacy format
408+
if (value.includes(',')) {
409+
const [width, height] = value.split(',').map(v => +v);
410+
if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0)
411+
throw new Error(`Invalid resolution format: use ${name}="800x600"`);
412+
return { width, height };
413+
}
414+
415+
throw new Error(`Invalid resolution format: use ${name}="800x600"`);
416+
}
417+
382418
export function headerParser(arg: string | undefined, previous?: Record<string, string>): Record<string, string> {
383419
if (!arg)
384420
return previous || {};

packages/playwright/src/mcp/browser/context.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,19 @@
1414
* limitations under the License.
1515
*/
1616

17+
import fs from 'fs';
18+
1719
import { debug } from 'playwright-core/lib/utilsBundle';
1820

1921
import { logUnhandledError } from '../log';
2022
import { Tab } from './tab';
2123
import { outputFile } from './config';
2224
import * as codegen from './codegen';
25+
import { dateAsFileName } from './tools/utils';
2326

2427
import type * as playwright from '../../../types/test';
2528
import type { FullConfig } from './config';
26-
import type { BrowserContextFactory } from './browserContextFactory';
29+
import type { BrowserContextFactory, BrowserContextFactoryResult } from './browserContextFactory';
2730
import type * as actions from './actions';
2831
import type { SessionLog } from './sessionLog';
2932
import type { Tracing } from '../../../../playwright-core/src/client/tracing';
@@ -42,7 +45,7 @@ export class Context {
4245
readonly config: FullConfig;
4346
readonly sessionLog: SessionLog | undefined;
4447
readonly options: ContextOptions;
45-
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
48+
private _browserContextPromise: Promise<BrowserContextFactoryResult> | undefined;
4649
private _browserContextFactory: BrowserContextFactory;
4750
private _tabs: Tab[] = [];
4851
private _currentTab: Tab | undefined;
@@ -163,7 +166,20 @@ export class Context {
163166
await promise.then(async ({ browserContext, close }) => {
164167
if (this.config.saveTrace)
165168
await browserContext.tracing.stop();
166-
await close();
169+
const videos = browserContext.pages().map(page => page.video()).filter(video => !!video);
170+
await close(async () => {
171+
for (const video of videos) {
172+
const name = await this.outputFile(dateAsFileName('webm'), { origin: 'code' });
173+
const path = await video.path();
174+
// video.saveAs() does not work for persistent contexts.
175+
try {
176+
if (fs.existsSync(path))
177+
await fs.promises.rename(path, name);
178+
} catch (e) {
179+
logUnhandledError(e);
180+
}
181+
}
182+
});
167183
});
168184
}
169185

@@ -202,7 +218,7 @@ export class Context {
202218
return this._browserContextPromise;
203219
}
204220

205-
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
221+
private async _setupBrowserContext(): Promise<BrowserContextFactoryResult> {
206222
if (this._closeBrowserContextPromise)
207223
throw new Error('Another browser context is being closed.');
208224
// TODO: move to the browser context factory to make it based on isolation mode.

packages/playwright/src/mcp/browser/tools/pdf.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const pdf = defineTabTool({
3535
},
3636

3737
handle: async (tab, params, response) => {
38-
const fileName = await tab.context.outputFile(params.filename ?? `page-${dateAsFileName()}.pdf`, { origin: 'llm' });
38+
const fileName = await tab.context.outputFile(params.filename ?? dateAsFileName('pdf'), { origin: 'llm' });
3939
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
4040
response.addResult(`Saved page as ${fileName}`);
4141
await tab.page.pdf({ path: fileName });

packages/playwright/src/mcp/browser/tools/screenshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const screenshot = defineTabTool({
5151

5252
handle: async (tab, params, response) => {
5353
const fileType = params.type || 'png';
54-
const fileName = await tab.context.outputFile(params.filename ?? `page-${dateAsFileName()}.${fileType}`, { origin: 'llm' });
54+
const fileName = await tab.context.outputFile(params.filename ?? dateAsFileName(fileType), { origin: 'llm' });
5555
const options: playwright.PageScreenshotOptions = {
5656
type: fileType,
5757
quality: fileType === 'png' ? undefined : 90,

packages/playwright/src/mcp/browser/tools/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (pag
8383
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
8484
}
8585

86-
export function dateAsFileName(): string {
86+
export function dateAsFileName(extension: string): string {
8787
const date = new Date();
88-
return date.toISOString().replace(/[:.]/g, '-');
88+
return `page-${date.toISOString().replace(/[:.]/g, '-')}.${extension}`;
8989
}

packages/playwright/src/mcp/config.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ export type Config = {
106106
*/
107107
saveTrace?: boolean;
108108

109+
/**
110+
* If specified, saves the Playwright video of the session into the output directory.
111+
*/
112+
saveVideo?: {
113+
width: number;
114+
height: number;
115+
};
116+
109117
/**
110118
* Reuse the same browser context between all connected HTTP clients.
111119
*/

0 commit comments

Comments
 (0)