5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
e35f0ac562 Fix race condition bug in session file check
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-07-21 03:32:24 +00:00
copilot-swe-agent[bot]
bb9de30ef9 Fix race condition in session file initialization by chaining file operations
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-07-21 02:14:40 +00:00
copilot-swe-agent[bot]
9588845cc3 Fix linting issues and finalize save-session implementation
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-07-21 01:02:43 +00:00
copilot-swe-agent[bot]
f00f78491a Implement --save-session functionality with session logging
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-07-21 00:56:15 +00:00
copilot-swe-agent[bot]
173637e1d2 Initial plan 2025-07-21 00:39:02 +00:00
6 changed files with 154 additions and 1 deletions

View File

@@ -164,6 +164,8 @@ Playwright MCP server supports following arguments. They can be provided in the
"http://myproxy:3128" or "socks5://myproxy:8080"
--save-trace Whether to save the Playwright Trace of the
session into the output directory.
--save-session Whether to save the session log with tool calls
and snapshots into the output directory.
--storage-state <path> path to the storage state file for isolated
sessions.
--user-agent <ua string> specify user agent string

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,8 @@ 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;
private _sessionFileInitialized: Promise<void> | undefined;
clientVersion: { name: string; version: string; } | undefined;
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
@@ -49,6 +53,8 @@ export class Context {
this.config = config;
this._browserContextFactory = browserContextFactory;
testDebug('create context');
if (this.config.saveSession)
this._sessionFileInitialized = this._initializeSessionFile();
}
clientSupportsImages(): boolean {
@@ -129,6 +135,50 @@ 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)
return;
// Ensure session file is initialized before proceeding
if (this._sessionFileInitialized)
await this._sessionFileInitialized;
// After initialization, session file should always be defined when saveSession is true
if (!this._sessionFile)
throw new Error('Session file not initialized despite saveSession being enabled');
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 +197,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 +207,23 @@ 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);
});