mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-27 00:52:27 +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;
|
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.
|
* The directory to save output files.
|
||||||
*/
|
*/
|
||||||
|
@ -44,6 +44,7 @@ export type CLIOptions = {
|
|||||||
proxyBypass?: string;
|
proxyBypass?: string;
|
||||||
proxyServer?: string;
|
proxyServer?: string;
|
||||||
saveTrace?: boolean;
|
saveTrace?: boolean;
|
||||||
|
saveSession?: boolean;
|
||||||
storageState?: string;
|
storageState?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
@ -191,6 +192,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
|
|||||||
blockedOrigins: cliOptions.blockedOrigins,
|
blockedOrigins: cliOptions.blockedOrigins,
|
||||||
},
|
},
|
||||||
saveTrace: cliOptions.saveTrace,
|
saveTrace: cliOptions.saveTrace,
|
||||||
|
saveSession: cliOptions.saveSession,
|
||||||
outputDir: cliOptions.outputDir,
|
outputDir: cliOptions.outputDir,
|
||||||
imageResponses: cliOptions.imageResponses,
|
imageResponses: cliOptions.imageResponses,
|
||||||
};
|
};
|
||||||
@ -221,6 +223,7 @@ function configFromEnv(): Config {
|
|||||||
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
|
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
|
||||||
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
|
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
|
||||||
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
|
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.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
|
||||||
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
|
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
|
||||||
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
|
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
|
||||||
|
@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
import { ManualPromise } from './manualPromise.js';
|
import { ManualPromise } from './manualPromise.js';
|
||||||
@ -42,6 +44,7 @@ export class Context {
|
|||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||||
private _pendingAction: PendingAction | undefined;
|
private _pendingAction: PendingAction | undefined;
|
||||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
|
private _sessionFile: string | undefined;
|
||||||
clientVersion: { name: string; version: string; } | undefined;
|
clientVersion: { name: string; version: string; } | undefined;
|
||||||
|
|
||||||
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
||||||
@ -49,6 +52,9 @@ export class Context {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
this._browserContextFactory = browserContextFactory;
|
this._browserContextFactory = browserContextFactory;
|
||||||
testDebug('create context');
|
testDebug('create context');
|
||||||
|
if (this.config.saveSession) {
|
||||||
|
void this._initializeSessionFile();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clientSupportsImages(): boolean {
|
clientSupportsImages(): boolean {
|
||||||
@ -129,6 +135,43 @@ export class Context {
|
|||||||
return await this.listTabsMarkdown();
|
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) {
|
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
||||||
// Tab management is done outside of the action() call.
|
// Tab management is done outside of the action() call.
|
||||||
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
|
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
|
||||||
@ -147,6 +190,8 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tab = this.currentTabOrDie();
|
const tab = this.currentTabOrDie();
|
||||||
|
let snapshotFile: string | undefined;
|
||||||
|
|
||||||
// TODO: race against modal dialogs to resolve clicks.
|
// TODO: race against modal dialogs to resolve clicks.
|
||||||
const actionResult = await this._raceAgainstModalDialogs(async () => {
|
const actionResult = await this._raceAgainstModalDialogs(async () => {
|
||||||
try {
|
try {
|
||||||
@ -155,11 +200,24 @@ export class Context {
|
|||||||
else
|
else
|
||||||
return await action?.() ?? undefined;
|
return await action?.() ?? undefined;
|
||||||
} finally {
|
} finally {
|
||||||
if (captureSnapshot && !this._javaScriptBlocked())
|
if (captureSnapshot && !this._javaScriptBlocked()) {
|
||||||
await tab.captureSnapshot();
|
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[] = [];
|
const result: string[] = [];
|
||||||
result.push(`### Ran Playwright code
|
result.push(`### Ran Playwright code
|
||||||
\`\`\`js
|
\`\`\`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-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('--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-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('--storage-state <path>', 'path to the storage state file for isolated sessions.')
|
||||||
.option('--user-agent <ua string>', 'specify user agent string')
|
.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.')
|
.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