From f00f78491a6e49c3ed097514d322b654fce1f623 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 21 Jul 2025 00:56:15 +0000 Subject: [PATCH] Implement --save-session functionality with session logging Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com> --- config.d.ts | 5 +++ src/config.ts | 3 ++ src/context.ts | 60 ++++++++++++++++++++++++++++++++- src/program.ts | 1 + tests/session.spec.ts | 78 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 tests/session.spec.ts diff --git a/config.d.ts b/config.d.ts index 024c29a..7df4c11 100644 --- a/config.d.ts +++ b/config.d.ts @@ -90,6 +90,11 @@ export type Config = { */ saveTrace?: boolean; + /** + * Whether to save the session log with tool calls and snapshots into the output directory. + */ + saveSession?: boolean; + /** * The directory to save output files. */ diff --git a/src/config.ts b/src/config.ts index 994cd4f..d72fffc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,7 @@ export type CLIOptions = { proxyBypass?: string; proxyServer?: string; saveTrace?: boolean; + saveSession?: boolean; storageState?: string; userAgent?: string; userDataDir?: string; @@ -191,6 +192,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config { blockedOrigins: cliOptions.blockedOrigins, }, saveTrace: cliOptions.saveTrace, + saveSession: cliOptions.saveSession, outputDir: cliOptions.outputDir, imageResponses: cliOptions.imageResponses, }; @@ -221,6 +223,7 @@ function configFromEnv(): Config { options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS); options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER); options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE); + options.saveSession = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_SESSION); options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE); options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT); options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR); diff --git a/src/context.ts b/src/context.ts index 3a19872..4df378f 100644 --- a/src/context.ts +++ b/src/context.ts @@ -16,6 +16,8 @@ import debug from 'debug'; import * as playwright from 'playwright'; +import * as fs from 'fs'; +import * as path from 'path'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; import { ManualPromise } from './manualPromise.js'; @@ -42,6 +44,7 @@ export class Context { private _modalStates: (ModalState & { tab: Tab })[] = []; private _pendingAction: PendingAction | undefined; private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; + private _sessionFile: string | undefined; clientVersion: { name: string; version: string; } | undefined; constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) { @@ -49,6 +52,9 @@ export class Context { this.config = config; this._browserContextFactory = browserContextFactory; testDebug('create context'); + if (this.config.saveSession) { + void this._initializeSessionFile(); + } } clientSupportsImages(): boolean { @@ -129,6 +135,43 @@ export class Context { return await this.listTabsMarkdown(); } + private async _initializeSessionFile() { + if (!this.config.saveSession) + return; + + const timestamp = new Date().toISOString(); + const fileName = `session${timestamp}.yml`; + this._sessionFile = await outputFile(this.config, fileName); + + // Initialize empty session file + await fs.promises.writeFile(this._sessionFile, '# Session log started at ' + timestamp + '\n', 'utf8'); + } + + private async _logSessionEntry(toolName: string, params: Record, snapshotFile?: string) { + if (!this.config.saveSession || !this._sessionFile) + return; + + const entry = [ + `- ${toolName}:`, + ' params:', + ]; + + // Add parameters with proper YAML indentation + for (const [key, value] of Object.entries(params)) { + const yamlValue = typeof value === 'string' ? value : JSON.stringify(value); + entry.push(` ${key}: ${yamlValue}`); + } + + // Add snapshot reference if provided + if (snapshotFile) { + entry.push(` snapshot: ${path.basename(snapshotFile)}`); + } + + entry.push(''); // Empty line for readability + + await fs.promises.appendFile(this._sessionFile, entry.join('\n') + '\n', 'utf8'); + } + async run(tool: Tool, params: Record | undefined) { // Tab management is done outside of the action() call. const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {})); @@ -147,6 +190,8 @@ export class Context { } const tab = this.currentTabOrDie(); + let snapshotFile: string | undefined; + // TODO: race against modal dialogs to resolve clicks. const actionResult = await this._raceAgainstModalDialogs(async () => { try { @@ -155,11 +200,24 @@ export class Context { else return await action?.() ?? undefined; } finally { - if (captureSnapshot && !this._javaScriptBlocked()) + if (captureSnapshot && !this._javaScriptBlocked()) { await tab.captureSnapshot(); + // Save snapshot to file if session logging is enabled + if (this.config.saveSession && tab.hasSnapshot()) { + const timestamp = new Date().toISOString(); + const snapshotFileName = `${timestamp}.snapshot.yaml`; + snapshotFile = await outputFile(this.config, snapshotFileName); + await fs.promises.writeFile(snapshotFile, tab.snapshotOrDie().text(), 'utf8'); + } + } } }); + // Log session entry if enabled + if (this.config.saveSession) { + await this._logSessionEntry(tool.schema.name, params || {}, snapshotFile); + } + const result: string[] = []; result.push(`### Ran Playwright code \`\`\`js diff --git a/src/program.ts b/src/program.ts index cfe0d5b..5c1cca2 100644 --- a/src/program.ts +++ b/src/program.ts @@ -47,6 +47,7 @@ program .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.') + .option('--save-session', 'Whether to save the session log with tool calls and snapshots into the output directory.') .option('--storage-state ', 'path to the storage state file for isolated sessions.') .option('--user-agent ', 'specify user agent string') .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') diff --git a/tests/session.spec.ts b/tests/session.spec.ts new file mode 100644 index 0000000..5adea55 --- /dev/null +++ b/tests/session.spec.ts @@ -0,0 +1,78 @@ +/** + * 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 fs from 'fs'; +import path from 'path'; + +import { test, expect } from './fixtures.js'; + +test('check that session is saved', async ({ startClient, server, mcpMode }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + + const { client } = await startClient({ + args: ['--save-session', `--output-dir=${outputDir}`], + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`Navigate to http://localhost`); + + // Check that session file exists + const files = fs.readdirSync(outputDir); + const sessionFiles = files.filter(f => f.startsWith('session') && f.endsWith('.yml')); + expect(sessionFiles.length).toBe(1); + + // Check session file content + const sessionContent = fs.readFileSync(path.join(outputDir, sessionFiles[0]), 'utf8'); + expect(sessionContent).toContain('- browser_navigate:'); + expect(sessionContent).toContain('params:'); + expect(sessionContent).toContain('url: ' + server.HELLO_WORLD); + expect(sessionContent).toContain('snapshot:'); +}); + +test('check that session includes multiple tool calls', async ({ startClient, server, mcpMode }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + + const { client } = await startClient({ + args: ['--save-session', `--output-dir=${outputDir}`], + }); + + // Navigate to a page + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + // Take a snapshot + await client.callTool({ + name: 'browser_snapshot', + arguments: {}, + }); + + // Check that session file exists and contains both calls + const files = fs.readdirSync(outputDir); + const sessionFiles = files.filter(f => f.startsWith('session') && f.endsWith('.yml')); + expect(sessionFiles.length).toBe(1); + + const sessionContent = fs.readFileSync(path.join(outputDir, sessionFiles[0]), 'utf8'); + expect(sessionContent).toContain('- browser_navigate:'); + expect(sessionContent).toContain('- browser_snapshot:'); + + // Check that snapshot files exist + const snapshotFiles = files.filter(f => f.includes('snapshot.yaml')); + expect(snapshotFiles.length).toBeGreaterThan(0); +}); \ No newline at end of file