feat(trace): allow saving trajectory as trace (#426)

This commit is contained in:
Pavel Feldman
2025-05-14 18:08:44 -07:00
committed by GitHub
parent fea50e6840
commit aa6ac51f92
13 changed files with 140 additions and 40 deletions

View File

@@ -44,6 +44,7 @@ export type CLIOptions = {
port?: number;
proxyBypass?: string;
proxyServer?: string;
saveTrace?: boolean;
storageState?: string;
userAgent?: string;
userDataDir?: string;
@@ -67,7 +68,7 @@ const defaultConfig: FullConfig = {
allowedOrigins: undefined,
blockedOrigins: undefined,
},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output'),
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
};
type BrowserUserConfig = NonNullable<Config['browser']>;
@@ -91,7 +92,8 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
const cliOverrides = await configFromCLIOptions(cliOptions);
const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
// Derive artifact output directory from config.outputDir
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
if (result.saveTrace)
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
return result;
}
@@ -189,6 +191,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,
},
saveTrace: cliOptions.saveTrace,
outputDir: cliOptions.outputDir,
};
@@ -262,7 +265,6 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
network: {
...pickDefined(base.network),
...pickDefined(overrides.network),
},
outputDir: overrides.outputDir ?? base.outputDir ?? defaultConfig.outputDir,
};
}
} as FullConfig;
}

View File

@@ -21,7 +21,7 @@ import path from 'node:path';
import * as playwright from 'playwright';
import { waitForCompletion } from './tools/utils.js';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js';
import { outputFile } from './config.js';
@@ -112,7 +112,7 @@ export class Context {
const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i];
const title = await tab.page.title();
const title = await tab.title();
const url = tab.page.url();
const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
@@ -149,7 +149,7 @@ export class Context {
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
try {
if (waitForNetwork)
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
else
actionResult = await racingAction?.() ?? undefined;
} finally {
@@ -193,7 +193,7 @@ ${code.join('\n')}
result.push(
`- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.page.title()}`
`- Page Title: ${await tab.title()}`
);
if (captureSnapshot && tab.hasSnapshot())
@@ -213,10 +213,14 @@ ${code.join('\n')}
}
async waitForTimeout(time: number) {
if (this._currentTab && !this._javaScriptBlocked())
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
else
if (!this._currentTab || this._javaScriptBlocked()) {
await new Promise(f => setTimeout(f, time));
return;
}
await callOnPageNoTrace(this._currentTab.page, page => {
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
});
}
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
@@ -288,6 +292,8 @@ ${code.join('\n')}
this._browserContextPromise = undefined;
await promise.then(async ({ browserContext, browser }) => {
if (this.config.saveTrace)
await browserContext.tracing.stop();
await browserContext.close().then(async () => {
await browser?.close();
}).catch(() => {});
@@ -324,6 +330,14 @@ ${code.join('\n')}
for (const page of browserContext.pages())
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
if (this.config.saveTrace) {
await browserContext.tracing.start({
name: 'trace',
screenshots: false,
snapshots: true,
sources: false,
});
}
return { browser, browserContext };
}
@@ -394,9 +408,5 @@ async function createUserDataDir(browserConfig: FullConfig['browser']) {
return result;
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

View File

@@ -15,6 +15,11 @@
*/
import * as playwright from 'playwright';
import { callOnPageNoTrace } from './tools/utils.js';
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
};
export class PageSnapshot {
private _page: playwright.Page;
@@ -35,11 +40,11 @@ export class PageSnapshot {
}
private async _build() {
const yamlDocument = await (this._page as any)._snapshotForAI();
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
this._text = [
`- Page Snapshot`,
'```yaml',
yamlDocument.toString({ indentSeq: false }).trim(),
snapshot,
'```',
].join('\n');
}

View File

@@ -44,6 +44,7 @@ program
.option('--port <port>', 'port to listen on for SSE transport.')
.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('--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.')

View File

@@ -19,6 +19,7 @@ import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot.js';
import type { Context } from './context.js';
import { callOnPageNoTrace } from './tools/utils.js';
export class Tab {
readonly context: Context;
@@ -61,10 +62,18 @@ export class Tab {
this._onPageClose(this);
}
async title(): Promise<string> {
return await callOnPageNoTrace(this.page, page => page.title());
}
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
}
async navigate(url: string) {
this._clearCollectedArtifacts();
const downloadEvent = this.page.waitForEvent('download').catch(() => {});
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (_e: unknown) {
@@ -85,7 +94,7 @@ export class Tab {
}
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
await this.waitForLoadState('load', { timeout: 5000 });
}
hasSnapshot(): boolean {

View File

@@ -16,8 +16,9 @@
import type * as playwright from 'playwright';
import type { Context } from '../context.js';
import type { Tab } from '../tab.js';
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>();
let frameNavigated = false;
let waitCallback: () => void = () => {};
@@ -36,9 +37,7 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
frameNavigated = true;
dispose();
clearTimeout(timeout);
void frame.waitForLoadState('load').then(() => {
waitCallback();
});
void tab.waitForLoadState('load').then(waitCallback);
};
const onTimeout = () => {
@@ -46,15 +45,15 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
waitCallback();
};
page.on('request', requestListener);
page.on('requestfinished', requestFinishedListener);
page.on('framenavigated', frameNavigateListener);
tab.page.on('request', requestListener);
tab.page.on('requestfinished', requestFinishedListener);
tab.page.on('framenavigated', frameNavigateListener);
const timeout = setTimeout(onTimeout, 10000);
const dispose = () => {
page.off('request', requestListener);
page.off('requestfinished', requestFinishedListener);
page.off('framenavigated', frameNavigateListener);
tab.page.off('request', requestListener);
tab.page.off('requestfinished', requestFinishedListener);
tab.page.off('framenavigated', frameNavigateListener);
clearTimeout(timeout);
};
@@ -79,5 +78,9 @@ export function sanitizeForFilePath(s: string) {
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
return (locator as any)._frame._wrapApiCall(() => (locator as any)._generateLocatorString(), true);
}
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
return await (page as any)._wrapApiCall(() => callback(page), true);
}