Implement --save-session functionality with session logging

Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-07-21 00:56:15 +00:00
parent 173637e1d2
commit f00f78491a
5 changed files with 146 additions and 1 deletions

5
config.d.ts vendored
View File

@ -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.
*/

View File

@ -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);

View File

@ -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<string, unknown>, 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<string, unknown> | 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

View File

@ -47,6 +47,7 @@ program
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', '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>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')

78
tests/session.spec.ts Normal file
View File

@ -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);
});