mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-26 08:32:26 +08:00
Implement --save-session functionality with session logging
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
This commit is contained in:
parent
173637e1d2
commit
f00f78491a
5
config.d.ts
vendored
5
config.d.ts
vendored
@ -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.
|
||||
*/
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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
78
tests/session.spec.ts
Normal 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);
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user