From f48cd254d32ca8f98ad257cd74e75aa31a2f99b8 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 17 Mar 2026 23:59:29 +0000 Subject: [PATCH 1/3] feat(cli): running with --debug=cli allows attaching and debugging over cli (#39717) --- .../playwright-core/src/server/debugger.ts | 17 +++-- .../server/dispatchers/debuggerDispatcher.ts | 2 + .../src/skill/references/playwright-tests.md | 20 +++--- .../src/tools/backend/context.ts | 4 ++ .../src/tools/backend/devtools.ts | 65 +++++++++++++++++++ .../src/tools/backend/response.ts | 10 +++ .../src/tools/backend/tools.ts | 2 + .../src/tools/cli-client/session.ts | 2 +- .../src/tools/cli-daemon/commands.ts | 32 +++++++++ packages/playwright/src/common/config.ts | 2 +- packages/playwright/src/common/ipc.ts | 2 +- packages/playwright/src/index.ts | 6 +- .../playwright/src/mcp/test/browserBackend.ts | 38 ++++------- packages/playwright/src/program.ts | 31 +++++---- packages/playwright/src/worker/testInfo.ts | 2 +- tests/library/debugger.spec.ts | 2 +- tests/mcp/cli-session.spec.ts | 4 +- tests/mcp/cli-test.spec.ts | 51 +++++++++------ 18 files changed, 207 insertions(+), 85 deletions(-) create mode 100644 packages/playwright-core/src/tools/backend/devtools.ts diff --git a/packages/playwright-core/src/server/debugger.ts b/packages/playwright-core/src/server/debugger.ts index 63e27358029f9..a28d45e351bad 100644 --- a/packages/playwright-core/src/server/debugger.ts +++ b/packages/playwright-core/src/server/debugger.ts @@ -29,6 +29,7 @@ export class Debugger extends SdkObject implements InstrumentationListener { private _pauseAt: PauseAt = {}; private _pausedCallsMetadata = new Map void, sdkObject: SdkObject }>(); private _enabled = false; + private _pauseBeforeInputActions = false; // instead of inside input actions private _context: BrowserContext; static Events = { @@ -54,7 +55,7 @@ export class Debugger extends SdkObject implements InstrumentationListener { if (this._muted) return; const pauseOnPauseCall = this._enabled && metadata.method === 'pause'; - const pauseOnNextStep = !!this._pauseAt.next && shouldPauseBeforeStep(metadata); + const pauseOnNextStep = !!this._pauseAt.next && shouldPauseBeforeStep(metadata, this._pauseBeforeInputActions); const pauseOnLocation = !!this._pauseAt.location && matchesLocation(metadata, this._pauseAt.location); if (pauseOnPauseCall || pauseOnNextStep || pauseOnLocation) await this._pause(sdkObject, metadata); @@ -63,9 +64,7 @@ export class Debugger extends SdkObject implements InstrumentationListener { async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { if (this._muted) return; - const pauseOnNextStep = !!this._pauseAt.next; - const pauseOnLocation = !!this._pauseAt.location && matchesLocation(metadata, this._pauseAt.location); - if (pauseOnNextStep || pauseOnLocation) + if (!!this._pauseAt.next && !this._pauseBeforeInputActions) await this._pause(sdkObject, metadata); } @@ -94,6 +93,10 @@ export class Debugger extends SdkObject implements InstrumentationListener { this.emit(Debugger.Events.PausedStateChanged); } + setPauseBeforeInputActions() { + this._pauseBeforeInputActions = true; + } + setPauseAt(at: { next?: boolean, location?: { file: string, line?: number, column?: number } } = {}) { this._enabled = true; this._pauseAt = at; @@ -114,14 +117,14 @@ export class Debugger extends SdkObject implements InstrumentationListener { } function matchesLocation(metadata: CallMetadata, location: { file: string, line?: number, column?: number }): boolean { - return metadata.location?.file === location.file && + return !!metadata.location?.file.includes(location.file) && (location.line === undefined || metadata.location.line === location.line) && (location.column === undefined || metadata.location.column === location.column); } -function shouldPauseBeforeStep(metadata: CallMetadata): boolean { +function shouldPauseBeforeStep(metadata: CallMetadata, includeInputActions: boolean): boolean { if (metadata.internal) return false; const metainfo = methodMetainfo.get(metadata.type + '.' + metadata.method); - return !!metainfo?.pausesBeforeAction; + return !!metainfo?.pausesBeforeAction || (includeInputActions && !!metainfo?.pausesBeforeInput); } diff --git a/packages/playwright-core/src/server/dispatchers/debuggerDispatcher.ts b/packages/playwright-core/src/server/dispatchers/debuggerDispatcher.ts index e6aee360fe8e2..2cfc43fe8fd21 100644 --- a/packages/playwright-core/src/server/dispatchers/debuggerDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/debuggerDispatcher.ts @@ -36,6 +36,7 @@ export class DebuggerDispatcher extends Dispatcher { this._dispatchEvent('pausedStateChanged', { pausedDetails: this._serializePausedDetails() }); }); + this._dispatchEvent('pausedStateChanged', { pausedDetails: this._serializePausedDetails() }); } private _serializePausedDetails(): channels.DebuggerPausedStateChangedEvent['pausedDetails'] { @@ -50,6 +51,7 @@ export class DebuggerDispatcher extends Dispatcher { + this._object.setPauseBeforeInputActions(); this._object.setPauseAt(params); } diff --git a/packages/playwright-core/src/skill/references/playwright-tests.md b/packages/playwright-core/src/skill/references/playwright-tests.md index 1be8c70c24e91..47627c2a6a234 100644 --- a/packages/playwright-core/src/skill/references/playwright-tests.md +++ b/packages/playwright-core/src/skill/references/playwright-tests.md @@ -12,24 +12,28 @@ PLAYWRIGHT_HTML_OPEN=never npm run special-test-command # Debugging Playwright Tests -To debug a failing test, run it with Playwright as usual, but set `PWPAUSE=cli` environment variable. This command will pause the test at the point of failure, and print the debugging instructions. +To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions. **IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. -Once instructions are printed, use `playwright-cli` to explore the page. Debugging instructions include a browser name that should be used in `playwright-cli` to attach to the page under test. +Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page. ```bash # Run the test -PLAYWRIGHT_HTML_OPEN=never PWPAUSE=cli npx playwright test +PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli +# ... +# ... debugging instructions for "tw-abcdef" session ... # ... -# Explore the page and interact if needed -playwright-cli --session=test open --attach=test-worker-abcdef -playwright-cli --session=test snapshot -playwright-cli --session=test click e14 +# Attach to the test +playwright-cli attach tw-abcdef ``` -Keep the test running in the background while you explore and look for a fix. After fixing the test, stop the background test run. +Keep the test running in the background while you explore and look for a fix. +The test is paused at the start, so you should step over or pause at a particular location +where the problem is most likely to be. Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement. + +After fixing the test, stop the background test run. Rerun to check that test passes. diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 7eb31ab09d48b..ca27bb4c52c80 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -122,6 +122,10 @@ export class Context { await this.stopVideoRecording(); } + debugger() { + return this._rawBrowserContext.debugger; + } + tabs(): Tab[] { return this._tabs; } diff --git a/packages/playwright-core/src/tools/backend/devtools.ts b/packages/playwright-core/src/tools/backend/devtools.ts new file mode 100644 index 0000000000000..7b61b0d021f1f --- /dev/null +++ b/packages/playwright-core/src/tools/backend/devtools.ts @@ -0,0 +1,65 @@ +/** + * 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 { z } from '../../mcpBundle'; +import { defineTool } from './tool'; + +const resume = defineTool({ + capability: 'devtools', + + schema: { + name: 'browser_resume', + title: 'Resume paused script execution', + description: 'Resume script execution after it was paused. When called with step set to true, execution will pause again before the next action.', + inputSchema: z.object({ + step: z.boolean().optional().describe('When true, execution will pause again before the next action, allowing step-by-step debugging.'), + location: z.string().optional().describe('Pause execution at a specific :, e.g. "example.spec.ts:42".'), + }), + type: 'action', + }, + + handle: async (context, params, response) => { + const browserContext = await context.ensureBrowserContext(); + const pausedPromise = new Promise(resolve => { + const listener = () => { + if (browserContext.debugger.pausedDetails().length > 0) { + browserContext.debugger.off('pausedstatechanged', listener); + resolve(); + } + }; + browserContext.debugger.on('pausedstatechanged', listener); + }); + + let location; + if (params.location) { + const [file, lineStr] = params.location.split(':'); + if (lineStr) { + const line = Number(lineStr); + if (isNaN(line)) + throw new Error(`Invalid location "${params.location}", expected format is :, e.g. "example.spec.ts:42"`); + location = { file, line }; + } else { + location = { file: params.location }; + } + } + + await browserContext.debugger.setPauseAt({ next: !!params.step, location }); + await browserContext.debugger.resume(); + await pausedPromise; + }, +}); + +export default [resume]; diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index c3603481385d6..d7de715ea84c6 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -239,6 +239,14 @@ export class Response { } if (text.length) addSection('Events', text); + + const pausedDetails = this._context.debugger().pausedDetails(); + if (pausedDetails.length) { + addSection('Paused', [ + ...pausedDetails.map(call => `- ${call.title} at ${this._computRelativeTo(call.location.file)}${call.location.line ? ':' + call.location.line : ''}`), + '- Use any tools to explore and interact, resume by calling resume/step-over/pause-at', + ]); + } return sections; } } @@ -310,6 +318,7 @@ export function parseResponse(response: CallToolResult) { const snapshot = sections.get('Snapshot'); const events = sections.get('Events'); const modalState = sections.get('Modal state'); + const paused = sections.get('Paused'); const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, ''); const isError = response.isError; const attachments = response.content.length > 1 ? response.content.slice(1) : undefined; @@ -323,6 +332,7 @@ export function parseResponse(response: CallToolResult) { snapshot, events, modalState, + paused, isError, attachments, text, diff --git a/packages/playwright-core/src/tools/backend/tools.ts b/packages/playwright-core/src/tools/backend/tools.ts index fe292bd35e9c6..de8d3eda86ac9 100644 --- a/packages/playwright-core/src/tools/backend/tools.ts +++ b/packages/playwright-core/src/tools/backend/tools.ts @@ -20,6 +20,7 @@ import common from './common'; import config from './config'; import console from './console'; import cookies from './cookies'; +import devtools from './devtools'; import dialogs from './dialogs'; import evaluate from './evaluate'; import files from './files'; @@ -49,6 +50,7 @@ export const browserTools: Tool[] = [ ...config, ...console, ...cookies, + ...devtools, ...dialogs, ...evaluate, ...files, diff --git a/packages/playwright-core/src/tools/cli-client/session.ts b/packages/playwright-core/src/tools/cli-client/session.ts index 577b665a61245..c8b2a475b3856 100644 --- a/packages/playwright-core/src/tools/cli-client/session.ts +++ b/packages/playwright-core/src/tools/cli-client/session.ts @@ -198,7 +198,7 @@ export class Session { if (cliArgs['attach']) { console.log(`### Session \`${sessionName}\` created, attached to \`${cliArgs['attach']}\`.`); - console.log(`Run commands with: playwright --session=${sessionName} `); + console.log(`Run commands with: playwright-cli --session=${sessionName} `); } else { console.log(`### Browser \`${sessionName}\` opened with pid ${child.pid}.`); } diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index f2325e36a5014..f94a682d82436 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -815,6 +815,35 @@ const devtoolsShow = declareCommand({ toolParams: () => ({}), }); +const resume = declareCommand({ + name: 'resume', + description: 'Resume the test execution', + category: 'devtools', + args: z.object({}), + toolName: 'browser_resume', + toolParams: ({ step }) => ({ step }), +}); + +const stepOver = declareCommand({ + name: 'step-over', + description: 'Step over the next call in the test', + category: 'devtools', + args: z.object({}), + toolName: 'browser_resume', + toolParams: ({}) => ({ step: true }), +}); + +const pauseAt = declareCommand({ + name: 'pause-at', + description: 'Run the test up to a specific location and pause there', + category: 'devtools', + args: z.object({ + location: z.string().describe('Location to pause at. Format is :, e.g. "example.spec.ts:42".'), + }), + toolName: 'browser_resume', + toolParams: ({ location }) => ({ location }), +}); + // Sessions const sessionList = declareCommand({ @@ -992,6 +1021,9 @@ const commandsArray: AnyCommandSchema[] = [ videoStart, videoStop, devtoolsShow, + pauseAt, + resume, + stepOver, // session category sessionList, diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 5447f2b2f5cb2..a46d23ec54897 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -195,7 +195,7 @@ export class FullProjectInternal { snapshotDir: takeFirst(pathResolve(configDir, projectConfig.snapshotDir), pathResolve(configDir, config.snapshotDir), testDir), testIgnore: takeFirst(projectConfig.testIgnore, config.testIgnore, []), testMatch: takeFirst(projectConfig.testMatch, config.testMatch, '**/*.@(spec|test).{md,?(c|m)[jt]s?(x)}'), - timeout: takeFirst(configCLIOverrides.debug ? 0 : undefined, configCLIOverrides.timeout, projectConfig.timeout, config.timeout, defaultTimeout), + timeout: takeFirst(configCLIOverrides.debug === 'inspector' ? 0 : undefined, configCLIOverrides.timeout, projectConfig.timeout, config.timeout, defaultTimeout), use: mergeObjects(config.use, projectConfig.use, configCLIOverrides.use), dependencies: projectConfig.dependencies || [], teardown: projectConfig.teardown, diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index 21e465b9d7779..e1774d2b915cb 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -23,7 +23,7 @@ import type { ReporterDescription, TestInfoError, TestStatus } from '../../types import type { SerializedCompilationCache } from '../transform/compilationCache'; export type ConfigCLIOverrides = { - debug?: boolean; + debug?: 'inspector' | 'cli'; failOnFlakyTests?: boolean; forbidOnly?: boolean; fullyParallel?: boolean; diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 06bf458ad962e..3aa3db889a368 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -22,7 +22,7 @@ import { setBoxedStackPrefixes, createGuid, currentZone, debugMode, jsonStringif import { buildErrorContext } from './errorContext'; import { currentTestInfo } from './common/globals'; import { rootTestType } from './common/testType'; -import { createCustomMessageHandler, runDaemonForBrowser } from './mcp/test/browserBackend'; +import { createCustomMessageHandler, runDaemonForContext } from './mcp/test/browserBackend'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test'; import type { ContextReuseMode } from './common/config'; @@ -444,7 +444,7 @@ const playwrightFixtures: Fixtures = ({ if (!_reuseContext) { const { context, close } = await _contextFactory(); testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context); - testInfo._onDidFinishTestFunctionCallbacks.add(() => runDaemonForBrowser(testInfo, browser)); + await runDaemonForContext(testInfo, context); await use(context); await close(); return; @@ -452,7 +452,7 @@ const playwrightFixtures: Fixtures = ({ const context = await browserImpl._wrapApiCall(() => browserImpl._newContextForReuse(), { internal: true }); testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context); - testInfo._onDidFinishTestFunctionCallbacks.add(() => runDaemonForBrowser(testInfo, browser)); + await runDaemonForContext(testInfo, context); await use(context); const closeReason = testInfo.status === 'timedOut' ? 'Test timeout of ' + testInfo.timeout + 'ms exceeded.' : 'Test ended.'; await browserImpl._wrapApiCall(() => browserImpl._disconnectFromReusedContext(closeReason), { internal: true }); diff --git a/packages/playwright/src/mcp/test/browserBackend.ts b/packages/playwright/src/mcp/test/browserBackend.ts index bbe38b2ae8a16..36b5cbeba4461 100644 --- a/packages/playwright/src/mcp/test/browserBackend.ts +++ b/packages/playwright/src/mcp/test/browserBackend.ts @@ -110,31 +110,21 @@ async function generatePausedMessage(testInfo: TestInfoImpl, context: playwright return lines.join('\n'); } -export async function runDaemonForBrowser(testInfo: TestInfoImpl, browser: playwright.Browser): Promise { - if (process.env.PWPAUSE !== 'cli') - return; +export async function runDaemonForContext(testInfo: TestInfoImpl, context: playwright.BrowserContext) { + if (testInfo._configInternal.configCLIOverrides.debug !== 'cli') + return false; - const browserTitle = `test-worker-${createGuid().slice(0, 6)}`; - await (browser as Browser)._register(browserTitle, { workspaceDir: testInfo.project.testDir }); - - const lines = ['']; - if (testInfo.errors.length) { - lines.push(`### Paused on test error`); - for (const error of testInfo.errors) - lines.push(stripAnsiEscapes(error.message || '')); - } else { - lines.push(`### Paused at the end of the test`); - } - lines.push( - `### Debugging Instructions`, - `- Pick a session name, e.g. "test"`, - `- Run "playwright-cli --session= open --attach=${browserTitle}" to attach to this page`, - `- Use "playwright-cli --session=" to explore the page and fix the problem`, - `- Stop this test run when finished. Restart if needed.`, - ``, - ); + const sessionName = `tw-${createGuid().slice(0, 6)}`; + await (context.browser() as Browser)._register(sessionName, { workspaceDir: testInfo.project.testDir }); /* eslint-disable-next-line no-console */ - console.log(lines.join('\n')); - await new Promise(() => {}); + console.log([ + `### The test is currently paused at the start`, + ``, + `### Debugging Instructions`, + `- Run "playwright-cli attach ${sessionName}" to attach to this test`, + ].join('\n')); + + await context.debugger.setPauseAt({ next: true }); + return true; } diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index edeb679ed4d26..801c88bc98aea 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -283,13 +283,20 @@ async function mergeReports(reportDir: string | undefined, opts: { [key: string] } function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrides { + if (options.ui) { + options.debug = undefined; + options.trace = undefined; + } + const overrides: ConfigCLIOverrides = { + debug: options.debug, failOnFlakyTests: options.failOnFlakyTests ? true : undefined, forbidOnly: options.forbidOnly ? true : undefined, fullyParallel: options.fullyParallel ? true : undefined, globalTimeout: options.globalTimeout ? parseInt(options.globalTimeout, 10) : undefined, maxFailures: options.x ? 1 : (options.maxFailures ? parseInt(options.maxFailures, 10) : undefined), outputDir: options.output ? path.resolve(process.cwd(), options.output) : undefined, + pause: !!process.env.PWPAUSE, quiet: options.quiet ? options.quiet : undefined, repeatEach: options.repeatEach ? parseInt(options.repeatEach, 10) : undefined, retries: options.retries ? parseInt(options.retries, 10) : undefined, @@ -301,6 +308,9 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined, updateSnapshots: options.updateSnapshots, updateSourceMethod: options.updateSourceMethod, + use: { + trace: options.trace, + }, workers: options.workers, }; @@ -317,23 +327,12 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid }); } - if (options.headed || options.debug) - overrides.use = { headless: false }; - if (!options.ui && options.debug) { - overrides.debug = true; + if (options.headed) + overrides.use.headless = false; + if (options.debug === 'inspector') { + overrides.use.headless = false; process.env.PWDEBUG = '1'; } - if (!options.ui && options.trace) { - overrides.use = overrides.use || {}; - overrides.use.trace = options.trace; - } - if (process.env.PWPAUSE === 'cli') { - overrides.timeout = 0; - overrides.use = overrides.use || {}; - overrides.use.actionTimeout = 5000; - } else if (process.env.PWPAUSE) { - overrides.pause = true; - } if (overrides.tsconfig && !fs.existsSync(overrides.tsconfig)) throw new Error(`--tsconfig "${options.tsconfig}" does not exist`); @@ -403,7 +402,7 @@ const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries const testOptions: [string, { description: string, choices?: string[], preset?: string }][] = [ /* deprecated */ ['--browser ', { description: `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")` }], ['-c, --config ', { description: `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"` }], - ['--debug', { description: `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options` }], + ['--debug [mode]', { description: `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options`, choices: ['inspector', 'cli'], preset: 'inspector' }], ['--fail-on-flaky-tests', { description: `Fail if any test is flagged as flaky (default: false)` }], ['--forbid-only', { description: `Fail if test.only is called (default: false)` }], ['--fully-parallel', { description: `Run all tests in parallel (default: false)` }], diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 3dca10c7fac30..cd2f2b880c4eb 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -201,7 +201,7 @@ export class TestInfoImpl implements TestInfo { this.expectedStatus = test?.expectedStatus ?? 'skipped'; this._timeoutManager = new TimeoutManager(this.project.timeout); - if (configInternal.configCLIOverrides.debug) + if (configInternal.configCLIOverrides.debug === 'inspector') this._setIgnoreTimeouts(true); this.outputDir = (() => { diff --git a/tests/library/debugger.spec.ts b/tests/library/debugger.spec.ts index 0efd9d1f6a335..65d460cc50a83 100644 --- a/tests/library/debugger.spec.ts +++ b/tests/library/debugger.spec.ts @@ -73,7 +73,7 @@ it('should pause at location', async ({ context, server }) => { const line = +(() => { return new Error('').stack.match(/debugger.spec.ts:(\d+)/)[1]; })(); // Note: careful with the line offset below. - await dbg.setPauseAt({ location: { file: __filename, line: line + 4 } }); + await dbg.setPauseAt({ location: { file: 'debugger.spec', line: line + 4 } }); await page.content(); // should not pause here const clickPromise = page.click('div'); // should pause here await new Promise(resolve => dbg.once('pausedstatechanged', resolve)); diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index 081099bd2e0fc..e5292fc48397c 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -306,7 +306,7 @@ workspace1: await page.setContent('My Page'); const { output: openOutput } = await cli('attach', 'foobar'); expect(openOutput).toContain('### Session `foobar` created, attached to `foobar`.'); - expect(openOutput).toContain('Run commands with: playwright --session=foobar '); + expect(openOutput).toContain('Run commands with: playwright-cli --session=foobar '); const { output: listOutput } = await cli('list', '--all'); expect(listOutput).toBe(`### Browsers /: @@ -353,7 +353,7 @@ workspace1: await page.setContent('Alias Page'); const { output: openOutput } = await cli('attach', 'foobar', '--session=mybrowser'); expect(openOutput).toContain('### Session `mybrowser` created, attached to `foobar`.'); - expect(openOutput).toContain('Run commands with: playwright --session=mybrowser '); + expect(openOutput).toContain('Run commands with: playwright-cli --session=mybrowser '); await cli('-s', 'mybrowser', 'close'); }); diff --git a/tests/mcp/cli-test.spec.ts b/tests/mcp/cli-test.spec.ts index 05dbd653a09f2..74c762ccec02c 100644 --- a/tests/mcp/cli-test.spec.ts +++ b/tests/mcp/cli-test.spec.ts @@ -20,44 +20,55 @@ import { writeFiles } from './fixtures'; const testEntrypoint = path.join(__dirname, '../../packages/playwright-test/cli.js'); -test.skip('debug test and snapshot', async ({ cliEnv, cli, childProcess }) => { +test('debug test and snapshot', async ({ cliEnv, cli, childProcess }) => { await writeFiles({ 'subdir/a.test.ts': ` import { test, expect } from '@playwright/test'; test('example test', async ({ page }) => { await page.setContent('My Page'); - await expect(page.getByRole('button', { name: 'Missing' })).toBeVisible({ timeout: 1000 }); + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); + await page.setContent('My Page'); + await expect(page.getByRole('button', { name: 'Close' })).toBeVisible(); }); `, }); const testProcess = childProcess({ - command: [process.argv[0], testEntrypoint, 'test'], + command: [process.argv[0], testEntrypoint, 'test', '--debug=cli'], cwd: test.info().outputPath('subdir'), - env: { PWPAUSE: 'cli', ...cliEnv }, + env: cliEnv, }); - await testProcess.waitForOutput('playwright-cli --session= open --attach=test-worker'); + await testProcess.waitForOutput('playwright-cli attach'); - const match = testProcess.output.match(/--attach=([a-zA-Z0-9-_]+)/); - const browserName = match[1]; + const match = testProcess.output.match(/attach ([a-zA-Z0-9-_]+)/); + const session = match[1]; - const { output: openOutput } = await cli('open', `--session=test-session`, `--attach=${browserName}`); - expect(openOutput).toContain('My Page'); + const { output: listOutput1 } = await cli('list', '--all'); + expect(listOutput1).toContain('subdir'); + expect(listOutput1).toContain(`browser "${session}"`); - const listResult1 = await cli('list', '--all'); - expect(listResult1.exitCode).toBe(0); - expect(listResult1.output).toContain('test-session'); - expect(listResult1.output).toContain('subdir'); - expect(listResult1.output).toContain(`browser "${browserName}"`); + const { output: attachOutput } = await cli('attach', session); + expect(attachOutput).toContain('### Paused'); + expect(attachOutput).toContain(`- Set content at subdir${path.sep}a.test.ts:4`); - const snapshotResult = await cli(`--session=test-session`, 'snapshot'); - expect(snapshotResult.exitCode).toBe(0); + const { output: listOutput2 } = await cli('list', '--all'); + expect(listOutput2).toContain('/'); + expect(listOutput2).toContain(`- ${session}`); + + const { output: stepOutput } = await cli(`--session=${session}`, 'step-over'); + expect(stepOutput).toContain('### Paused'); + expect(stepOutput).toContain(`- Expect "toBeVisible" at subdir${path.sep}a.test.ts:5`); + + const snapshotResult = await cli(`--session=${session}`, 'snapshot'); expect(snapshotResult.snapshot).toContain('button "Submit"'); - await testProcess.kill('SIGINT'); + const { output: pauseAtOutput } = await cli(`--session=${session}`, 'pause-at', 'a.test.ts:7'); + expect(pauseAtOutput).toContain('### Paused'); + expect(pauseAtOutput).toContain(`- Expect "toBeVisible" at subdir${path.sep}a.test.ts:7`); + + await cli(`--session=${session}`, 'resume'); - const listResult2 = await cli('list'); - expect(listResult2.exitCode).toBe(0); - expect(listResult2.output).toContain('(no browsers)'); + const { output: listOutput3 } = await cli('list'); + expect(listOutput3).toContain('(no browsers)'); }); From d66ea843ef48510ca7957c816c2cd98a022e9cdd Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 17 Mar 2026 19:31:46 -0700 Subject: [PATCH 2/3] feat(cli): add trace CLI for inspecting trace files (#39735) --- .../playwright-dev/trace_system_guide.md | 929 ++++++++++++++++++ packages/playwright-core/src/cli/DEPS.list | 1 + packages/playwright-core/src/cli/program.ts | 3 + packages/playwright-core/src/server/DEPS.list | 4 +- .../playwright-core/src/server/localUtils.ts | 2 +- .../src/tools/cli-client/program.ts | 2 +- .../src/{ => tools/cli-client}/skill/SKILL.md | 0 .../skill/references/element-attributes.md | 0 .../skill/references/playwright-tests.md | 0 .../skill/references/request-mocking.md | 0 .../skill/references/running-code.md | 0 .../skill/references/session-management.md | 0 .../skill/references/storage-state.md | 0 .../skill/references/test-generation.md | 0 .../cli-client}/skill/references/tracing.md | 0 .../skill/references/video-recording.md | 0 .../playwright-core/src/tools/trace/DEPS.list | 3 + .../playwright-core/src/tools/trace/SKILL.md | 151 +++ .../src/tools/trace/traceCli.ts | 883 +++++++++++++++++ .../viewer => tools/trace}/traceParser.ts | 3 +- .../src/utils/isomorphic/trace/traceLoader.ts | 2 +- .../src/utils/isomorphic/trace/traceModel.ts | 8 +- .../isomorphic/{ => trace}/traceUtils.ts | 0 tests/config/utils.ts | 4 +- tests/mcp/trace-cli-fixtures.ts | 108 ++ tests/mcp/trace-cli.spec.ts | 183 ++++ utils/build/build.js | 8 +- 27 files changed, 2281 insertions(+), 13 deletions(-) create mode 100644 .claude/skills/playwright-dev/trace_system_guide.md rename packages/playwright-core/src/{ => tools/cli-client}/skill/SKILL.md (100%) rename packages/playwright-core/src/{ => tools/cli-client}/skill/references/element-attributes.md (100%) rename packages/playwright-core/src/{ => tools/cli-client}/skill/references/playwright-tests.md (100%) rename packages/playwright-core/src/{ => tools/cli-client}/skill/references/request-mocking.md (100%) rename packages/playwright-core/src/{ => tools/cli-client}/skill/references/running-code.md (100%) rename packages/playwright-core/src/{ => tools/cli-client}/skill/references/session-management.md (100%) rename packages/playwright-core/src/{ => tools/cli-client}/skill/references/storage-state.md (100%) rename packages/playwright-core/src/{ => tools/cli-client}/skill/references/test-generation.md (100%) rename packages/playwright-core/src/{ => tools/cli-client}/skill/references/tracing.md (100%) rename packages/playwright-core/src/{ => tools/cli-client}/skill/references/video-recording.md (100%) create mode 100644 packages/playwright-core/src/tools/trace/DEPS.list create mode 100644 packages/playwright-core/src/tools/trace/SKILL.md create mode 100644 packages/playwright-core/src/tools/trace/traceCli.ts rename packages/playwright-core/src/{server/trace/viewer => tools/trace}/traceParser.ts (96%) rename packages/playwright-core/src/utils/isomorphic/{ => trace}/traceUtils.ts (100%) create mode 100644 tests/mcp/trace-cli-fixtures.ts create mode 100644 tests/mcp/trace-cli.spec.ts diff --git a/.claude/skills/playwright-dev/trace_system_guide.md b/.claude/skills/playwright-dev/trace_system_guide.md new file mode 100644 index 0000000000000..ef6345e0daac3 --- /dev/null +++ b/.claude/skills/playwright-dev/trace_system_guide.md @@ -0,0 +1,929 @@ +# Playwright Trace System - Comprehensive Guide + +## 1. Overview + +The Playwright trace system is a comprehensive recording and visualization framework that captures: +- **Actions** (API calls, user interactions) +- **Network traffic** (HAR format) +- **Snapshots** (DOM snapshots at key moments) +- **Screencast frames** (video of page rendering) +- **Console messages** and events +- **Errors** and logs +- **Resources** (images, stylesheets, scripts, etc.) + +--- + +## 2. File Structure + +### packages/trace/src/ - Trace Type Definitions +Located in `/home/pfeldman/code/playwright/packages/trace/src/` + +**Key Files:** +- **trace.ts** - Core trace event type definitions +- **har.ts** - HTTP Archive format (network traffic) +- **snapshot.ts** - DOM snapshot data structures +- **DEPS.list** - Dependencies marker + +**File List:** +``` +trace/src/ +├── trace.ts (183 lines) - Main trace event types +├── har.ts (189 lines) - HAR format types +├── snapshot.ts (62 lines) - Snapshot data structures +└── DEPS.list - Dependencies file +``` + +--- + +## 3. Trace Event Types (trace.ts) + +### 3.1 Core Event Types + +**VERSION: 8** (Current format version) + +#### ContextCreatedTraceEvent +```typescript +type ContextCreatedTraceEvent = { + version: number, + type: 'context-options', + origin: 'testRunner' | 'library', + browserName: string, + channel?: string, + platform: string, + playwrightVersion?: string, + wallTime: number, // Milliseconds since epoch + monotonicTime: number, // Internal monotonic clock + title?: string, + options: BrowserContextEventOptions, + sdkLanguage?: Language, + testIdAttributeName?: string, + contextId?: string, + testTimeout?: number, +}; +``` + +#### BeforeActionTraceEvent +Emitted when an action starts: +```typescript +type BeforeActionTraceEvent = { + type: 'before', + callId: string, // Unique action identifier + startTime: number, // Monotonic time when action started + title?: string, // User-facing action name + class: string, // API class (e.g., 'Page', 'Frame') + method: string, // API method (e.g., 'click', 'goto') + params: Record, // Method parameters + stepId?: string, // Test step identifier + beforeSnapshot?: string, // "before@" + stack?: StackFrame[], // Call stack + pageId?: string, // Associated page ID + parentId?: string, // Parent action (for nested actions) + group?: string, // Action group (e.g., 'wait', 'click') +}; +``` + +#### InputActionTraceEvent +For input/pointer interactions: +```typescript +type InputActionTraceEvent = { + type: 'input', + callId: string, + inputSnapshot?: string, // "input@" + point?: Point, // Mouse/pointer coordinates +}; +``` + +#### AfterActionTraceEvent +Emitted when an action completes: +```typescript +type AfterActionTraceEvent = { + type: 'after', + callId: string, + endTime: number, // Monotonic time when action ended + afterSnapshot?: string, // "after@" + error?: SerializedError, // Error if action failed + attachments?: AfterActionTraceEventAttachment[], // Files, screenshots + annotations?: AfterActionTraceEventAnnotation[], // Custom annotations + result?: any, // Return value + point?: Point, // Final pointer position +}; +``` + +#### ActionTraceEvent (Composite) +Combines before, after, and input events: +```typescript +type ActionTraceEvent = { + type: 'action', +} & Omit + & Omit + & Omit; +``` + +#### Other Event Types + +**ScreencastFrameTraceEvent** - Video frame data +```typescript +type ScreencastFrameTraceEvent = { + type: 'screencast-frame', + pageId: string, + sha1: string, // Resource SHA1 + width: number, // Frame width + height: number, // Frame height + timestamp: number, // Frame timestamp + frameSwapWallTime?: number, +}; +``` + +**EventTraceEvent** - Browser events (dialog, navigation, etc.) +```typescript +type EventTraceEvent = { + type: 'event', + time: number, + class: string, // Event source class + method: string, // Event method + params: any, // Event parameters + pageId?: string, +}; +``` + +**ConsoleMessageTraceEvent** - Console output +```typescript +type ConsoleMessageTraceEvent = { + type: 'console', + time: number, + pageId?: string, + messageType: string, // 'log', 'error', 'warn', etc. + text: string, + args?: { preview: string, value: any }[], + location: { url: string, lineNumber: number, columnNumber: number }, +}; +``` + +**LogTraceEvent** - Action logs +```typescript +type LogTraceEvent = { + type: 'log', + callId: string, + time: number, + message: string, +}; +``` + +**ResourceSnapshotTraceEvent** - Network request +```typescript +type ResourceSnapshotTraceEvent = { + type: 'resource-snapshot', + snapshot: ResourceSnapshot, // HAR Entry +}; +``` + +**FrameSnapshotTraceEvent** - DOM snapshot +```typescript +type FrameSnapshotTraceEvent = { + type: 'frame-snapshot', + snapshot: FrameSnapshot, +}; +``` + +**StdioTraceEvent** - Process output (stdout/stderr) +```typescript +type StdioTraceEvent = { + type: 'stdout' | 'stderr', + timestamp: number, + text?: string, + base64?: string, // Binary output +}; +``` + +**ErrorTraceEvent** - Unhandled errors +```typescript +type ErrorTraceEvent = { + type: 'error', + message: string, + stack?: StackFrame[], +}; +``` + +--- + +## 4. HAR Format (har.ts) + +Follows HTTP Archive 1.2 specification. Key structure for network traffic: + +```typescript +type HARFile = { + log: Log, +}; + +type Log = { + version: string, + creator: Creator, + browser?: Browser, + pages?: Page[], + entries: Entry[], // Network requests +}; + +type Entry = { + pageref?: string, + startedDateTime: string, + time: number, // Total time (ms) + request: Request, + response: Response, + cache: Cache, + timings: Timings, + serverIPAddress?: string, + connection?: string, + // Custom Playwright fields: + _frameref?: string, + _monotonicTime?: number, + _serverPort?: number, + _securityDetails?: SecurityDetails, + _wasAborted?: boolean, + _wasFulfilled?: boolean, + _wasContinued?: boolean, + _apiRequest?: boolean, // True for fetch/axios requests +}; +``` + +--- + +## 5. Snapshot Format (snapshot.ts) + +### FrameSnapshot +```typescript +type FrameSnapshot = { + snapshotName?: string, + callId: string, // Associated action + pageId: string, + frameId: string, + frameUrl: string, + timestamp: number, + wallTime?: number, + collectionTime: number, // Time to capture + doctype?: string, + html: NodeSnapshot, // Encoded DOM tree + resourceOverrides: ResourceOverride[], // Inlined resources + viewport: { width: number, height: number }, + isMainFrame: boolean, +}; +``` + +### NodeSnapshot +Compact encoding of DOM tree: +```typescript +type NodeSnapshot = + TextNodeSnapshot | // string + SubtreeReferenceSnapshot | // [ [snapshotIndex, nodeIndex] ] + NodeNameAttributesChildNodesSnapshot; // [ name, attributes?, ...children ] +``` + +### ResourceOverride +Embeds resource data in snapshot: +```typescript +type ResourceOverride = { + url: string, + sha1?: string, // External resource SHA1 + ref?: number // Snapshot index reference +}; +``` + +--- + +## 6. Trace Storage Format + +### File Structure +When a trace is recorded, it creates this structure in the traces directory: + +``` +traces-dir/ +├── .trace # Main events (JSONL format) +├── .network # Network events (JSONL format) +├── -chunk1.trace # Additional chunks (if multiple) +├── .stacks # Stack trace metadata (optional) +└── resources/ + ├── # Resource files (images, etc.) + └── +``` + +### File Formats +- **`.trace` and `.network`**: JSONL (JSON Lines) - one event per line +- **`.zip`**: Optional archive containing all above files +- **`resources/`**: Binary blobs indexed by SHA1 hash + +### Live Trace Format +For live tracing (test runner): +``` +traces-dir/ +├── .json # Synthesized trace metadata +├── / + ├── events.jsonl + ├── network.jsonl + └── resources/ +``` + +--- + +## 7. Trace Recording (tracing.ts) + +Located: `/home/pfeldman/code/playwright/packages/playwright-core/src/server/trace/recorder/tracing.ts` + +### Tracing Class Architecture + +```typescript +export class Tracing extends SdkObject implements + InstrumentationListener, + SnapshotterDelegate, + HarTracerDelegate { + + // Recording state + private _state: RecordingState; + + // Components + private _snapshotter?: Snapshotter; // Captures DOM snapshots + private _harTracer: HarTracer; // Records network requests + private _screencastListeners: ... // Video recording + + // Methods + start(options: TracerOptions); + startChunk(progress, options); + stopChunk(progress, params); + stop(progress); +} +``` + +### What Gets Recorded + +**1. Before Action (`onBeforeCall`)** +- Action metadata: class, method, parameters +- Stack trace +- "before" DOM snapshot +- Associated page/frame IDs + +**2. Input Actions (`onBeforeInputAction`)** +- Pointer coordinates +- Input type +- Snapshot of input + +**3. Action Logs (`onCallLog`)** +- API log messages +- User-facing messages + +**4. After Action (`onAfterCall`)** +- Execution time +- Return value +- Error information (if failed) +- "after" DOM snapshot +- Attachments (screenshots, files) +- Annotations (custom data) + +**5. Network Traffic (`onEntryFinished`)** +- HTTP request/response details +- Headers, cookies, body +- Timing information +- Security details + +**6. Snapshots (`onFrameSnapshot`, `onSnapshotterBlob`)** +- Full DOM tree with inlined resources +- Viewport size +- Resource references + +**7. Console Messages (`onConsoleMessage`)** +- Message type (log, error, warn) +- Text content +- Arguments +- Source location + +**8. Events** +- Dialogs +- Page errors +- Navigation events +- Downloads + +**9. Screencast Frames** +- Video frames (if screenshots enabled) +- Frame dimensions +- Timestamps + +**10. Stdio/Errors** +- stdout/stderr output +- Unhandled errors +- Process events + +### Recording State +```typescript +type RecordingState = { + options: TracerOptions, + traceName: string, + networkFile: string, + traceFile: string, + tracesDir: string, + resourcesDir: string, + chunkOrdinal: number, + networkSha1s: Set, + traceSha1s: Set, + recording: boolean, + callIds: Set, + groupStack: string[], // For nested groups +}; +``` + +--- + +## 8. Trace Loading (traceLoader.ts) + +Located: `/home/pfeldman/code/playwright/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts` + +### TraceLoaderBackend Interface +```typescript +interface TraceLoaderBackend { + entryNames(): Promise; // List files in trace + hasEntry(entryName: string): Promise; + readText(entryName: string): Promise; // For JSONL + readBlob(entryName: string): Promise; // For resources + isLive(): boolean; // Is this a live/developing trace? +} +``` + +### Built-in Backends + +**ZipTraceLoaderBackend** (traceParser.ts) +- Loads `.trace.zip` files +- Uses ZipFile utility to read entries +- Converts file paths to file:// URLs + +### Load Process +```typescript +async load(backend: TraceLoaderBackend, unzipProgress) { + 1. Find .trace files (ordinals: "0", "1", etc.) + 2. For each ordinal: + a. Read ordinal.trace (events) + b. Read ordinal.network (network events) + c. Parse with TraceModernizer + d. Read ordinal.stacks (if exists) + e. Sort actions by startTime + 3. Terminate incomplete actions + 4. Finalize snapshot storage + 5. Build resource content-type map + 6. Push ContextEntry to contextEntries[] +} +``` + +### Output: ContextEntry[] +```typescript +type ContextEntry = { + origin: 'testRunner' | 'library', + startTime: number, // Min action startTime + endTime: number, // Max action endTime + browserName: string, + wallTime: number, + sdkLanguage?: Language, + testIdAttributeName?: string, + title?: string, + options: BrowserContextEventOptions, + pages: PageEntry[], // Screencast data + resources: ResourceSnapshot[], // HAR entries + actions: ActionEntry[], // Merged before/after events + events: EventTraceEvent[], + stdio: StdioTraceEvent[], + errors: ErrorTraceEvent[], + hasSource: boolean, + contextId: string, + testTimeout?: number, +}; +``` + +--- + +## 9. Trace Model (traceModel.ts) + +Located: `/home/pfeldman/code/playwright/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts` + +### TraceModel Class +High-level data model for trace viewer: + +```typescript +class TraceModel { + // Metadata + startTime: number; + endTime: number; + browserName: string; + channel?: string; + platform?: string; + playwrightVersion?: string; + wallTime?: number; + title?: string; + options: BrowserContextEventOptions; + sdkLanguage: Language; + testIdAttributeName?: string; + traceUri: string; // URL to trace + testTimeout?: number; + + // Data arrays + pages: PageEntry[]; // Page screencast data + actions: ActionTraceEventInContext[]; // All recorded actions + attachments: Attachment[]; // Screenshots, files + visibleAttachments: Attachment[]; // Non-private attachments + events: (EventTraceEvent | ConsoleMessageTraceEvent)[]; + stdio: StdioTraceEvent[]; + errors: ErrorTraceEvent[]; + resources: ResourceEntry[]; // Network resources + sources: Map; // Source code + errorDescriptors: ErrorDescription[]; // Parsed errors + + // Counters + actionCounters: Map; // Actions per group + hasSource: boolean; // Has source code available + hasStepData: boolean; // Has test runner data + + // Methods + createRelativeUrl(path: string): string; + failedAction(): ActionTraceEventInContext; + filteredActions(actionsFilter: ActionGroup[]): ActionTraceEventInContext[]; +} +``` + +### ActionTraceEventInContext +```typescript +type ActionTraceEventInContext = ActionEntry & { + context: ContextEntry, + group?: ActionGroup, // Added by TraceModel + log: { time: number, message: string }[], +}; +``` + +--- + +## 10. Trace Modernizer (traceModernizer.ts) + +Located: `/home/pfeldman/code/playwright/packages/playwright-core/src/utils/isomorphic/trace/traceModernizer.ts` + +### Version Support +- **Latest:** Version 8 +- **Supported:** Versions 3-8 +- Upgrades older traces to current format + +### TraceModernizer Class +```typescript +class TraceModernizer { + constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage); + + appendTrace(trace: string); // Parse JSONL trace lines + actions(): ActionEntry[]; // Get parsed actions + + private _modernize(event: any); // Upgrade event to latest version + private _innerAppendEvent(event: TraceEvent); // Process event +} +``` + +### How It Works +1. Parses JSONL (one JSON object per line) +2. Detects trace version from first `context-options` event +3. Applies version-specific upgrades using `_modernize_N_to_N+1()` functions +4. Consolidates before/after events into unified actions +5. Builds dependency graph for nested actions +6. Stores snapshots in SnapshotStorage + +--- + +## 11. Trace Viewer + +Located: `/home/pfeldman/code/playwright/packages/trace-viewer/src/` + +### Structure +``` +trace-viewer/src/ +├── index.tsx # Entry point +├── sw-main.ts # Service worker +└── ui/ + ├── workbench.tsx # Main UI component + ├── actionList.tsx # Action timeline + ├── callTab.tsx # Action details + ├── snapshotTab.tsx # DOM snapshot viewer + ├── networkTab.tsx # Network waterfall + ├── consoleTab.tsx # Console messages + ├── timeline.tsx # Time-based view + ├── filmStrip.tsx # Video frames + ├── logTab.tsx # Action logs + ├── attachmentsTab.tsx # Files, screenshots + ├── playbackControl.tsx # Video playback + └── [other tabs...] +``` + +### Data Flow +1. **Service Worker (`sw-main.ts`)** - Intercepts trace URL fetch +2. **Workbench Loader** - Loads trace via TraceLoader +3. **TraceModel** - Parses and indexes loaded data +4. **UI Components** - Display actions, snapshots, network, etc. +5. **Playback Control** - Synchronizes timeline with snapshots + +### Key Data Models +- **TraceModel** - Loaded and parsed trace data +- **ActionTraceEventInContext** - Single action with context +- **Attachment** - File or screenshot data +- **SourceModel** - Source code + errors + +--- + +## 12. CLI Commands + +Located: `/home/pfeldman/code/playwright/packages/playwright-core/src/cli/program.ts` + +### show-trace Command +```bash +playwright show-trace [trace] [options] + +Options: + -b, --browser Browser to use (chromium, firefox, webkit) + -h, --host Host to serve on + -p, --port Port to serve on (0 = any free port) + --stdin Accept trace URLs over stdin + +Examples: + $ show-trace + $ show-trace https://example.com/trace.zip + $ show-trace /path/to/trace.zip + $ show-trace /path/to/trace/dir +``` + +### Implementation (program.ts: 327-355) +```typescript +program + .command('show-trace [trace]') + .option('-b, --browser ', ..., 'chromium') + .option('-h, --host ', 'Host to serve trace on') + .option('-p, --port ', 'Port to serve trace on') + .option('--stdin', 'Accept trace URLs over stdin') + .description('show trace viewer') + .action(function(trace, options) { + const openOptions: TraceViewerServerOptions = { + host: options.host, + port: +options.port, + isServer: !!options.stdin, + }; + + if (options.port !== undefined || options.host !== undefined) + runTraceInBrowser(trace, openOptions); // Opens in browser tab + else + runTraceViewerApp(trace, options.browser, openOptions); // Opens in app window + }); +``` + +### Trace Viewer Server (traceViewer.ts) +```typescript +startTraceViewerServer(options?: TraceViewerServerOptions): Promise + // Routes: + // GET /trace/file?path= → Serve trace file + // GET /trace/file?path=.json → Synthesize trace metadata + // GET /trace/file?path=/... → Serve trace.dir contents + // GET /trace/ → Serve viewer assets + +runTraceViewerApp(traceUrl, browserName, options) + // Opens trace viewer in persistent browser context + +runTraceInBrowser(traceUrl, options) + // Opens trace viewer in browser tab (tab.open) +``` + +--- + +## 13. Data Available Per Action + +### Per-Action Data Structure +```typescript +ActionTraceEventInContext { + // Identifiers + callId: string; // Unique action ID + pageId?: string; // Associated page + parentId?: string; // Parent action (nested) + stepId?: string; // Test step ID + group?: ActionGroup; // Action category + + // Timing + startTime: number; // Monotonic time (milliseconds) + endTime: number; // When action completed + + // API Information + class: string; // Class name (Page, Frame, etc.) + method: string; // Method name (click, goto, etc.) + params: Record; // Input parameters + result?: any; // Return value + + // Code Location + stack?: StackFrame[]; // Call stack with file/line/column + title?: string; // User-facing name + + // Snapshots + beforeSnapshot?: string; // "before@" reference + inputSnapshot?: string; // "input@" reference + afterSnapshot?: string; // "after@" reference + + // Errors + error?: SerializedError; // Error message and stack + + // Logging + log: { time: number, message: string }[]; // Action logs + + // Attachments + attachments?: AfterActionTraceEventAttachment[]; + // { name, contentType, path?, sha1?, base64? } + + // Annotations + annotations?: AfterActionTraceEventAnnotation[]; + // { type, description? } + + // Interaction Details + point?: Point; // Pointer coordinates {x, y} + + // Reference + context: ContextEntry; // Associated browser context +} +``` + +### Snapshot Data +Each snapshot can be accessed via `TraceLoader.storage()`: +```typescript +FrameSnapshot { + callId: string, // Associated action + pageId: string, + frameId: string, + frameUrl: string, + html: NodeSnapshot, // Encoded DOM tree + resourceOverrides: [ // Embedded resources + { url, sha1?, ref? } + ], + viewport: { width, height }, + isMainFrame: boolean, + collectionTime: number, // ms to capture + timestamp: number, // Monotonic time + wallTime?: number, +} +``` + +### Network Data (HAR Entry) +```typescript +Entry { + request: { + method: string, // GET, POST, etc. + url: string, + httpVersion: string, + headers: Header[], + cookies: Cookie[], + queryString: { name, value }[], + postData?: { + mimeType: string, + params: Param[], + text: string, + _sha1?: string, // Reference to resources/ + }, + }, + response: { + status: number, // 200, 404, etc. + statusText: string, + headers: Header[], + cookies: Cookie[], + content: { + size: number, + mimeType: string, + text?: string, + _sha1?: string, // Reference to resources/ + compression?: number, + }, + redirectURL: string, + }, + timings: { // All in milliseconds + blocked?: number, + dns?: number, + connect?: number, + send: number, + wait: number, // Time to first byte + receive: number, + ssl?: number, + }, + time: number, // Total time + _monotonicTime?: number, // Monotonic timestamp + _wasFulfilled?: boolean, + _wasAborted?: boolean, + _apiRequest?: boolean, // fetch/axios +} +``` + +--- + +## 14. Quick Reference: Accessing Trace Data + +### In Trace Viewer +```typescript +// Load trace +const traceLoader = new TraceLoader(); +const backend = new ZipTraceLoaderBackend('trace.zip'); +await traceLoader.load(backend, (done, total) => {}); + +// Access context +const contextEntries = traceLoader.contextEntries; + +// Get trace model +const traceModel = new TraceModel(traceUri, contextEntries); + +// Iterate actions +for (const action of traceModel.actions) { + console.log(action.method); // e.g., "click" + console.log(action.params); // parameters + console.log(action.result); // return value + console.log(action.error); // error if failed + console.log(action.log); // log messages +} + +// Get snapshots +const snapshotStorage = traceLoader.storage(); +const snapshot = snapshotStorage.snapshotByName('before@'); + +// Get resource +const blob = await traceLoader.resourceForSha1(sha1); +``` + +### In Test Runner +```typescript +// Access via trace via browser context +const trace = await context.tracing.stop({ path: 'trace.zip' }); + +// Use server-side Tracing class +const tracing = new Tracing(context, tracesDir); +tracing.start({ snapshots: true, screenshots: true }); +// ... run test ... +await tracing.stopChunk(progress, { mode: 'archive' }); +await tracing.stop(progress); +``` + +--- + +## 15. Version Information + +### Trace Format Versions +- **Version 3**: Early format (~1.35) +- **Version 4**: Updates (~1.36) +- **Version 5**: Improvements (~1.37) +- **Version 6**: Major changes (~10/2023, ~1.40) +- **Version 7**: Further updates (~05/2024, ~1.45) +- **Version 8**: Current format (latest) + +### Compatibility +- Trace viewer automatically upgrades traces +- Newer viewer can read older traces +- Older viewer cannot read newer traces (TraceVersionError) + +--- + +## 16. Key Design Patterns + +### 1. Call ID Correlation +Every action uses a unique `callId` to correlate: +- Before event +- Input events +- Log messages +- After event +- Snapshots (before@callId, input@callId, after@callId) +- Attachments +- Network requests (indirect via timing) + +### 2. Lazy Loading +- Snapshots stored by SHA1 +- Resources fetched on demand +- JSONL format allows streaming + +### 3. Snapshot References +- Instead of storing full DOM repeatedly +- Later snapshots reference earlier ones: `[[snapshotIndex, nodeIndex]]` +- Resources inlined via `resourceOverrides` + +### 4. Dual Time Bases +- **wallTime**: Milliseconds since epoch (for display) +- **monotonicTime**: Internal monotonic clock (for correlation) + +### 5. Chunked Recording +- Tests can have multiple chunks +- Each chunk has separate `.trace` file +- Network resources preserved across chunks + +### 6. Grouping +- Actions can be grouped with `group()` / `groupEnd()` +- Used for test steps, fixtures +- Group tracking in `RecordingState.groupStack` + +--- + +## 17. File Reference Guide + +| File | Size | Purpose | +|------|------|---------| +| `trace/src/trace.ts` | 183 lines | Trace event types | +| `trace/src/har.ts` | 189 lines | Network HAR types | +| `trace/src/snapshot.ts` | 62 lines | Snapshot types | +| `playwright-core/.../tracing.ts` | 700+ lines | Recording engine | +| `playwright-core/.../traceParser.ts` | 62 lines | ZIP backend | +| `playwright-core/.../traceViewer.ts` | 288 lines | Viewer server | +| `playwright-core/.../traceLoader.ts` | 158 lines | Load traces | +| `playwright-core/.../traceModel.ts` | 300+ lines | Data model | +| `playwright-core/.../traceModernizer.ts` | 500+ lines | Version upgrades | +| `trace-viewer/src/index.tsx` | 48 lines | Viewer entry | +| `trace-viewer/src/ui/workbench.tsx` | Main UI | Display | + diff --git a/packages/playwright-core/src/cli/DEPS.list b/packages/playwright-core/src/cli/DEPS.list index 4d2f05e6dbd7c..d434054ab6b33 100644 --- a/packages/playwright-core/src/cli/DEPS.list +++ b/packages/playwright-core/src/cli/DEPS.list @@ -8,3 +8,4 @@ ../client/ ../server/trace/viewer/ ../tools/cli-client/program.ts +../tools/trace/traceCli.ts diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 159a8ae4cc7ce..ab7d0902abdbf 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -25,6 +25,7 @@ import { launchBrowserServer, printApiJson, runDriver, runServer } from './drive import { registry, writeDockerVersion } from '../server'; import { gracefullyProcessExitDoNotHang, isLikelyNpxGlobal, ManualPromise } from '../utils'; import { runTraceInBrowser, runTraceViewerApp } from '../server/trace/viewer/traceViewer'; +import { addTraceCommands } from '../tools/trace/traceCli'; import { assert, getPackageManagerExecCommand } from '../utils'; import { wrapInASCIIBox } from '../server/utils/ascii'; import { dotenv, program } from '../utilsBundle'; @@ -354,6 +355,8 @@ Examples: $ show-trace $ show-trace https://example.com/trace.zip`); +addTraceCommands(program, logErrorAndExit); + program .command('cli', { hidden: true }) .allowExcessArguments(true) diff --git a/packages/playwright-core/src/server/DEPS.list b/packages/playwright-core/src/server/DEPS.list index 92396bb7fe859..c991d24035722 100644 --- a/packages/playwright-core/src/server/DEPS.list +++ b/packages/playwright-core/src/server/DEPS.list @@ -2,12 +2,12 @@ ../generated/ ../protocol/ ../utils -../utils/isomorphic/ +../utils/isomorphic/** ../utilsBundle.ts ../zipBundle.ts ./ ./codegen/ -./isomorphic/ +./isomorphic ./har/ ./recorder/ ./registry/ diff --git a/packages/playwright-core/src/server/localUtils.ts b/packages/playwright-core/src/server/localUtils.ts index 2a1cf62d5b1fd..92a50efe301ad 100644 --- a/packages/playwright-core/src/server/localUtils.ts +++ b/packages/playwright-core/src/server/localUtils.ts @@ -23,7 +23,7 @@ import { HarBackend } from './harBackend'; import { ManualPromise } from '../utils/isomorphic/manualPromise'; import { ZipFile } from './utils/zipFile'; import { yauzl, yazl } from '../zipBundle'; -import { serializeClientSideCallMetadata } from '../utils/isomorphic/traceUtils'; +import { serializeClientSideCallMetadata } from '../utils/isomorphic/trace/traceUtils'; import { assert } from '../utils/isomorphic/assert'; import { removeFolders } from './utils/fileUtils'; diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts index 7996c5b48b0d6..e20a038b3b707 100644 --- a/packages/playwright-core/src/tools/cli-client/program.ts +++ b/packages/playwright-core/src/tools/cli-client/program.ts @@ -208,7 +208,7 @@ async function install(args: MinimistArgs) { console.log(`✅ Workspace initialized at \`${cwd}\`.`); if (args.skills) { - const skillSourceDir = path.join(__dirname, '../../skill'); + const skillSourceDir = path.join(__dirname, 'skill'); const skillDestDir = path.join(cwd, '.claude', 'skills', 'playwright-cli'); if (!fs.existsSync(skillSourceDir)) { diff --git a/packages/playwright-core/src/skill/SKILL.md b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md similarity index 100% rename from packages/playwright-core/src/skill/SKILL.md rename to packages/playwright-core/src/tools/cli-client/skill/SKILL.md diff --git a/packages/playwright-core/src/skill/references/element-attributes.md b/packages/playwright-core/src/tools/cli-client/skill/references/element-attributes.md similarity index 100% rename from packages/playwright-core/src/skill/references/element-attributes.md rename to packages/playwright-core/src/tools/cli-client/skill/references/element-attributes.md diff --git a/packages/playwright-core/src/skill/references/playwright-tests.md b/packages/playwright-core/src/tools/cli-client/skill/references/playwright-tests.md similarity index 100% rename from packages/playwright-core/src/skill/references/playwright-tests.md rename to packages/playwright-core/src/tools/cli-client/skill/references/playwright-tests.md diff --git a/packages/playwright-core/src/skill/references/request-mocking.md b/packages/playwright-core/src/tools/cli-client/skill/references/request-mocking.md similarity index 100% rename from packages/playwright-core/src/skill/references/request-mocking.md rename to packages/playwright-core/src/tools/cli-client/skill/references/request-mocking.md diff --git a/packages/playwright-core/src/skill/references/running-code.md b/packages/playwright-core/src/tools/cli-client/skill/references/running-code.md similarity index 100% rename from packages/playwright-core/src/skill/references/running-code.md rename to packages/playwright-core/src/tools/cli-client/skill/references/running-code.md diff --git a/packages/playwright-core/src/skill/references/session-management.md b/packages/playwright-core/src/tools/cli-client/skill/references/session-management.md similarity index 100% rename from packages/playwright-core/src/skill/references/session-management.md rename to packages/playwright-core/src/tools/cli-client/skill/references/session-management.md diff --git a/packages/playwright-core/src/skill/references/storage-state.md b/packages/playwright-core/src/tools/cli-client/skill/references/storage-state.md similarity index 100% rename from packages/playwright-core/src/skill/references/storage-state.md rename to packages/playwright-core/src/tools/cli-client/skill/references/storage-state.md diff --git a/packages/playwright-core/src/skill/references/test-generation.md b/packages/playwright-core/src/tools/cli-client/skill/references/test-generation.md similarity index 100% rename from packages/playwright-core/src/skill/references/test-generation.md rename to packages/playwright-core/src/tools/cli-client/skill/references/test-generation.md diff --git a/packages/playwright-core/src/skill/references/tracing.md b/packages/playwright-core/src/tools/cli-client/skill/references/tracing.md similarity index 100% rename from packages/playwright-core/src/skill/references/tracing.md rename to packages/playwright-core/src/tools/cli-client/skill/references/tracing.md diff --git a/packages/playwright-core/src/skill/references/video-recording.md b/packages/playwright-core/src/tools/cli-client/skill/references/video-recording.md similarity index 100% rename from packages/playwright-core/src/skill/references/video-recording.md rename to packages/playwright-core/src/tools/cli-client/skill/references/video-recording.md diff --git a/packages/playwright-core/src/tools/trace/DEPS.list b/packages/playwright-core/src/tools/trace/DEPS.list new file mode 100644 index 0000000000000..6549bd515b0a5 --- /dev/null +++ b/packages/playwright-core/src/tools/trace/DEPS.list @@ -0,0 +1,3 @@ +[*] +../../utils/isomorphic/** +../../server/utils/zipFile.ts diff --git a/packages/playwright-core/src/tools/trace/SKILL.md b/packages/playwright-core/src/tools/trace/SKILL.md new file mode 100644 index 0000000000000..b1360be633559 --- /dev/null +++ b/packages/playwright-core/src/tools/trace/SKILL.md @@ -0,0 +1,151 @@ +--- +name: playwright-trace +description: Inspect Playwright trace files from the command line — list actions, view requests, console, errors, snapshots and screenshots. +allowed-tools: Bash(npx:*) +--- + +# Playwright Trace CLI + +Inspect `.zip` trace files produced by Playwright tests without opening a browser. + +## Workflow + +1. Start with `trace info` to understand what's in the trace. +2. Use `trace actions` to see all actions with their action IDs. +3. Use `trace action ` to drill into a specific action — see parameters, logs, source location, and available snapshots. +4. Use `trace requests`, `trace console`, or `trace errors` for cross-cutting views. +5. Use `trace snapshot` or `trace screenshot` to extract visual state. + +## Commands + +### Overview + +```bash +# Trace metadata: browser, viewport, duration, action/error counts +npx playwright trace info +``` + +### Actions + +```bash +# List all actions as a tree with action IDs and timing +npx playwright trace actions + +# Filter by action title (regex, case-insensitive) +npx playwright trace actions --grep "click" + +# Only failed actions +npx playwright trace actions --errors-only +``` + +### Action details + +```bash +# Show full details for one action: params, result, logs, source, snapshots +npx playwright trace action +``` + +The `action` command displays available snapshot phases (before, input, after) and the exact command to extract them. + +### Requests + +```bash +# All network requests: method, status, URL, duration, size +npx playwright trace requests + +# Filter by URL pattern +npx playwright trace requests --grep "api" + +# Filter by HTTP method +npx playwright trace requests --method POST + +# Only failed requests (status >= 400) +npx playwright trace requests --failed +``` + +### Request details + +```bash +# Show full details for one request: headers, body, security +npx playwright trace request +``` + +### Console + +```bash +# All console messages and stdout/stderr +npx playwright trace console + +# Only errors +npx playwright trace console --errors-only + +# Only browser console (no stdout/stderr) +npx playwright trace console --browser + +# Only stdout/stderr (no browser console) +npx playwright trace console --stdio +``` + +### Errors + +```bash +# All errors with stack traces and associated actions +npx playwright trace errors +``` + +### Snapshots + +```bash +# Save DOM snapshot as HTML (tries input, then before, then after) +npx playwright trace snapshot -o snapshot.html + +# Save a specific phase +npx playwright trace snapshot --name before -o before.html +npx playwright trace snapshot --name after -o after.html + +# Serve snapshot on localhost with resources +npx playwright trace snapshot --serve +``` + +### Screenshots + +```bash +# Save the closest screencast frame for an action +npx playwright trace screenshot -o screenshot.png +``` + +### Attachments + +```bash +# List all trace attachments +npx playwright trace attachments + +# Extract an attachment by its number +npx playwright trace attachment 1 +npx playwright trace attachment 1 -o out.png +``` + +## Typical investigation + +```bash +# 1. What happened in this trace? +npx playwright trace info test-results/my-test/trace.zip + +# 2. What actions ran? +npx playwright trace actions test-results/my-test/trace.zip + +# 3. Which action failed? +npx playwright trace actions --errors-only test-results/my-test/trace.zip + +# 4. What went wrong? +npx playwright trace action test-results/my-test/trace.zip 12 + +# 5. What did the page look like? +npx playwright trace snapshot test-results/my-test/trace.zip 12 -o page.html + +# 6. Any relevant network failures? +npx playwright trace requests --failed test-results/my-test/trace.zip + +# 7. Any console errors? +npx playwright trace console --errors-only test-results/my-test/trace.zip +``` diff --git a/packages/playwright-core/src/tools/trace/traceCli.ts b/packages/playwright-core/src/tools/trace/traceCli.ts new file mode 100644 index 0000000000000..e08111364937e --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceCli.ts @@ -0,0 +1,883 @@ +/** + * 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. + */ + +/* eslint-disable no-console */ + +import fs from 'fs'; +import path from 'path'; + +import { TraceModel, buildActionTree } from '../../utils/isomorphic/trace/traceModel'; +import { TraceLoader } from '../../utils/isomorphic/trace/traceLoader'; +import { renderTitleForCall } from '../../utils/isomorphic/protocolFormatter'; +import { asLocatorDescription } from '../../utils/isomorphic/locatorGenerators'; +import { ZipTraceLoaderBackend } from './traceParser'; + +import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; +import type { Language } from '@isomorphic/locatorGenerators'; +import type { Command } from '../../utilsBundle'; + +export function addTraceCommands(program: Command, logErrorAndExit: (e: Error) => void) { + const traceCommand = program + .command('trace') + .description('inspect trace files from the command line'); + + traceCommand + .command('info ') + .description('show trace metadata') + .action(function(trace: string) { + traceInfo(trace).catch(logErrorAndExit); + }); + + traceCommand + .command('actions ') + .description('list actions in the trace') + .option('--grep ', 'filter actions by title pattern') + .option('--errors-only', 'only show failed actions') + .action(function(trace: string, options: { grep?: string, errorsOnly?: boolean }) { + traceActions(trace, options).catch(logErrorAndExit); + }); + + traceCommand + .command('action ') + .description('show details of a specific action') + .action(function(trace: string, actionId: string) { + traceAction(trace, actionId).catch(logErrorAndExit); + }); + + traceCommand + .command('requests ') + .description('show network requests') + .option('--grep ', 'filter by URL pattern') + .option('--method ', 'filter by HTTP method') + .option('--status ', 'filter by status code') + .option('--failed', 'only show failed requests (status >= 400)') + .action(function(trace: string, options: { grep?: string, method?: string, status?: string, failed?: boolean }) { + traceRequests(trace, options).catch(logErrorAndExit); + }); + + traceCommand + .command('request ') + .description('show details of a specific network request') + .action(function(trace: string, requestId: string) { + traceRequest(trace, requestId).catch(logErrorAndExit); + }); + + traceCommand + .command('console ') + .description('show console messages') + .option('--errors-only', 'only show errors') + .option('--warnings', 'show errors and warnings') + .option('--browser', 'only browser console messages') + .option('--stdio', 'only stdout/stderr') + .action(function(trace: string, options: { errorsOnly?: boolean, warnings?: boolean, browser?: boolean, stdio?: boolean }) { + traceConsole(trace, options).catch(logErrorAndExit); + }); + + traceCommand + .command('errors ') + .description('show errors with stack traces') + .action(function(trace: string) { + traceErrors(trace).catch(logErrorAndExit); + }); + + traceCommand + .command('snapshot ') + .description('save or serve DOM snapshot for an action') + .option('--name ', 'snapshot phase: before, input, or after', 'before') + .option('-o, --output ', 'output file path') + .option('--serve', 'serve snapshot on local HTTP server') + .option('--port ', 'port for serve mode') + .action(function(trace: string, actionId: string, options: { name?: string, output?: string, serve?: boolean, port?: number }) { + traceSnapshot(trace, actionId, options).catch(logErrorAndExit); + }); + + traceCommand + .command('screenshot ') + .description('save screencast screenshot for an action') + .option('-o, --output ', 'output file path') + .action(function(trace: string, actionId: string, options: { output?: string }) { + traceScreenshot(trace, actionId, options).catch(logErrorAndExit); + }); + + traceCommand + .command('attachments ') + .description('list trace attachments') + .action(function(trace: string) { + traceAttachments(trace).catch(logErrorAndExit); + }); + + traceCommand + .command('attachment ') + .description('extract a trace attachment by its number') + .option('-o, --output ', 'output file path') + .action(function(trace: string, attachmentId: string, options: { output?: string }) { + traceAttachment(trace, attachmentId, options).catch(logErrorAndExit); + }); + + traceCommand + .command('install-skill') + .description('install SKILL.md for LLM integration') + .action(function() { + installSkill().catch(logErrorAndExit); + }); +} + +export async function loadTrace(traceFile: string): Promise<{ model: TraceModel, loader: TraceLoader }> { + const filePath = path.resolve(traceFile); + if (!fs.existsSync(filePath)) + throw new Error(`Trace file not found: ${filePath}`); + const backend = new ZipTraceLoaderBackend(filePath); + const loader = new TraceLoader(); + await loader.load(backend, () => undefined); + return { model: new TraceModel(filePath, loader.contextEntries), loader }; +} + +export async function loadTraceModel(traceFile: string): Promise { + return (await loadTrace(traceFile)).model; +} + +function msToString(ms: number): string { + if (ms < 0 || !isFinite(ms)) + return '-'; + if (ms === 0) + return '0'; + if (ms < 1000) + return ms.toFixed(0) + 'ms'; + const seconds = ms / 1000; + if (seconds < 60) + return seconds.toFixed(1) + 's'; + const minutes = seconds / 60; + if (minutes < 60) + return minutes.toFixed(1) + 'm'; + const hours = minutes / 60; + if (hours < 24) + return hours.toFixed(1) + 'h'; + const days = hours / 24; + return days.toFixed(1) + 'd'; +} + +function bytesToString(bytes: number): string { + if (bytes < 0 || !isFinite(bytes)) + return '-'; + if (bytes === 0) + return '0'; + if (bytes < 1000) + return bytes.toFixed(0); + const kb = bytes / 1024; + if (kb < 1000) + return kb.toFixed(1) + 'K'; + const mb = kb / 1024; + if (mb < 1000) + return mb.toFixed(1) + 'M'; + const gb = mb / 1024; + return gb.toFixed(1) + 'G'; +} + +function formatTimestamp(ms: number, base: number): string { + const relative = ms - base; + if (relative < 0) + return '0:00.000'; + const totalMs = Math.floor(relative); + const minutes = Math.floor(totalMs / 60000); + const seconds = Math.floor((totalMs % 60000) / 1000); + const millis = totalMs % 1000; + return `${minutes}:${seconds.toString().padStart(2, '0')}.${millis.toString().padStart(3, '0')}`; +} + +function actionTitle(action: ActionTraceEventInContext, sdkLanguage?: Language): string { + return renderTitleForCall({ ...action, type: action.class }) || `${action.class}.${action.method}`; +} + +function actionLocator(action: ActionTraceEventInContext, sdkLanguage?: Language): string | undefined { + return action.params.selector ? asLocatorDescription(sdkLanguage || 'javascript', action.params.selector) : undefined; +} + +const cliOutputDir = '.playwright-cli'; + +async function saveOutputFile(fileName: string, content: string | Buffer, explicitOutput?: string): Promise { + let outFile: string; + if (explicitOutput) { + outFile = explicitOutput; + } else { + await fs.promises.mkdir(cliOutputDir, { recursive: true }); + outFile = path.join(cliOutputDir, fileName); + } + await fs.promises.writeFile(outFile, content); + return outFile; +} + +function padEnd(str: string, len: number): string { + return str.length >= len ? str : str + ' '.repeat(len - str.length); +} + +function padStart(str: string, len: number): string { + return str.length >= len ? str : ' '.repeat(len - str.length) + str; +} + +// ---- ordinal mapping ---- + +function buildOrdinalMap(model: TraceModel): { ordinalToCallId: Map, callIdToOrdinal: Map } { + const actions = model.actions.filter(a => a.group !== 'configuration'); + const { rootItem } = buildActionTree(actions); + const ordinalToCallId = new Map(); + const callIdToOrdinal = new Map(); + let ordinal = 1; + const visit = (item: ReturnType['rootItem']) => { + ordinalToCallId.set(ordinal, item.action.callId); + callIdToOrdinal.set(item.action.callId, ordinal); + ordinal++; + for (const child of item.children) + visit(child); + }; + for (const child of rootItem.children) + visit(child); + return { ordinalToCallId, callIdToOrdinal }; +} + +function resolveActionId(actionId: string, model: TraceModel): ActionTraceEventInContext | undefined { + const ordinal = parseInt(actionId, 10); + if (!isNaN(ordinal)) { + const { ordinalToCallId } = buildOrdinalMap(model); + const callId = ordinalToCallId.get(ordinal); + if (callId) + return model.actions.find(a => a.callId === callId); + } + return model.actions.find(a => a.callId === actionId); +} + +// ---- trace actions ---- + +export async function traceActions(traceFile: string, options: { grep?: string, errorsOnly?: boolean }) { + const model = await loadTraceModel(traceFile); + const lang = model.sdkLanguage; + const { callIdToOrdinal } = buildOrdinalMap(model); + const actions = filterActions(model.actions, options, lang); + + // Tree view + const { rootItem } = buildActionTree(actions); + console.log(` ${padStart('#', 4)} ${padEnd('Time', 9)} ${padEnd('Action', 55)} ${padStart('Duration', 8)}`); + console.log(` ${'─'.repeat(4)} ${'─'.repeat(9)} ${'─'.repeat(55)} ${'─'.repeat(8)}`); + const visit = (item: ReturnType['rootItem'], indent: string) => { + const action = item.action; + const ordinal = callIdToOrdinal.get(action.callId) ?? '?'; + const ts = formatTimestamp(action.startTime, model.startTime); + const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'running'; + const title = actionTitle(action as ActionTraceEventInContext, lang); + const locator = actionLocator(action as ActionTraceEventInContext, lang); + const error = action.error ? ' ✗' : ''; + const prefix = ` ${padStart(ordinal + '.', 4)} ${ts} ${indent}`; + console.log(`${prefix}${padEnd(title, Math.max(1, 55 - indent.length))} ${padStart(duration, 8)}${error}`); + if (locator) + console.log(`${' '.repeat(prefix.length)}${locator}`); + for (const child of item.children) + visit(child, indent + ' '); + }; + for (const child of rootItem.children) + visit(child, ''); +} + +function filterActions(actions: ActionTraceEventInContext[], options: { grep?: string, errorsOnly?: boolean }, lang?: Language): ActionTraceEventInContext[] { + let result = actions.filter(a => a.group !== 'configuration'); + if (options.grep) { + const pattern = new RegExp(options.grep, 'i'); + result = result.filter(a => pattern.test(actionTitle(a, lang)) || pattern.test(actionLocator(a, lang) || '')); + } + if (options.errorsOnly) + result = result.filter(a => !!a.error); + return result; +} + +// ---- trace action ---- + +export async function traceAction(traceFile: string, actionId: string) { + const model = await loadTraceModel(traceFile); + const lang = model.sdkLanguage; + const action = resolveActionId(actionId, model); + if (!action) { + console.error(`Action '${actionId}' not found. Use 'trace actions' to see available action IDs.`); + process.exitCode = 1; + return; + } + + const title = actionTitle(action, lang); + console.log(`\n ${title}\n`); + + // Time + console.log(' Time'); + console.log(` start: ${formatTimestamp(action.startTime, model.startTime)}`); + const duration = action.endTime ? msToString(action.endTime - action.startTime) : (action.error ? 'Timed Out' : 'Running'); + console.log(` duration: ${duration}`); + + // Parameters + const paramKeys = Object.keys(action.params).filter(name => name !== 'info'); + if (paramKeys.length) { + console.log('\n Parameters'); + for (const key of paramKeys) { + const value = formatParamValue(action.params[key]); + console.log(` ${key}: ${value}`); + } + } + + // Return value + if (action.result) { + console.log('\n Return value'); + for (const [key, value] of Object.entries(action.result)) + console.log(` ${key}: ${formatParamValue(value)}`); + + } + + // Error + if (action.error) { + console.log('\n Error'); + console.log(` ${action.error.message}`); + } + + // Logs + if (action.log.length) { + console.log('\n Log'); + for (const entry of action.log) { + const time = entry.time !== -1 ? formatTimestamp(entry.time, model.startTime) : ''; + console.log(` ${padEnd(time, 12)} ${entry.message}`); + } + } + + // Source + if (action.stack?.length) { + console.log('\n Source'); + for (const frame of action.stack.slice(0, 5)) { + const file = frame.file.replace(/.*[/\\](.*)/, '$1'); + console.log(` ${file}:${frame.line}:${frame.column}`); + } + } + + // Snapshots + const snapshots: string[] = []; + if (action.beforeSnapshot) + snapshots.push('before'); + if (action.inputSnapshot) + snapshots.push('input'); + if (action.afterSnapshot) + snapshots.push('after'); + if (snapshots.length) { + console.log('\n Snapshots'); + console.log(` available: ${snapshots.join(', ')}`); + console.log(` usage: npx playwright trace snapshot ${actionId} --name <${snapshots.join('|')}>`); + } + console.log(''); +} + +function formatParamValue(value: any): string { + if (value === undefined || value === null) + return String(value); + if (typeof value === 'string') + return `"${value}"`; + if (typeof value !== 'object') + return String(value); + if (value.guid) + return ''; + return JSON.stringify(value).slice(0, 1000); +} + +// ---- trace requests ---- + +export async function traceRequests(traceFile: string, options: { grep?: string, method?: string, status?: string, failed?: boolean }) { + const model = await loadTraceModel(traceFile); + + // Build indexed list with stable ordinals before filtering. + let indexed = model.resources.map((r, i) => ({ resource: r, ordinal: i + 1 })); + + if (options.grep) { + const pattern = new RegExp(options.grep, 'i'); + indexed = indexed.filter(({ resource: r }) => pattern.test(r.request.url)); + } + if (options.method) + indexed = indexed.filter(({ resource: r }) => r.request.method.toLowerCase() === options.method!.toLowerCase()); + if (options.status) { + const code = parseInt(options.status, 10); + indexed = indexed.filter(({ resource: r }) => r.response.status === code); + } + if (options.failed) + indexed = indexed.filter(({ resource: r }) => r.response.status >= 400 || r.response.status === -1); + + if (!indexed.length) { + console.log(' No network requests'); + return; + } + console.log(` ${padStart('#', 4)} ${padEnd('Method', 8)} ${padEnd('Status', 8)} ${padEnd('Name', 45)} ${padStart('Duration', 10)} ${padStart('Size', 8)} ${padEnd('Route', 10)}`); + console.log(` ${'─'.repeat(4)} ${'─'.repeat(8)} ${'─'.repeat(8)} ${'─'.repeat(45)} ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(10)}`); + + for (const { resource: r, ordinal } of indexed) { + let name: string; + try { + const url = new URL(r.request.url); + name = url.pathname.substring(url.pathname.lastIndexOf('/') + 1); + if (!name) + name = url.host; + if (url.search) + name += url.search; + } catch { + name = r.request.url; + } + if (name.length > 45) + name = name.substring(0, 42) + '...'; + + const status = r.response.status > 0 ? String(r.response.status) : 'ERR'; + const size = r.response._transferSize! > 0 ? r.response._transferSize! : r.response.bodySize; + const route = formatRouteStatus(r); + console.log(` ${padStart(ordinal + '.', 4)} ${padEnd(r.request.method, 8)} ${padEnd(status, 8)} ${padEnd(name, 45)} ${padStart(msToString(r.time), 10)} ${padStart(bytesToString(size), 8)} ${padEnd(route, 10)}`); + } +} + +// ---- trace request ---- + +export async function traceRequest(traceFile: string, requestId: string) { + const model = await loadTraceModel(traceFile); + const ordinal = parseInt(requestId, 10); + const resource = !isNaN(ordinal) && ordinal >= 1 && ordinal <= model.resources.length + ? model.resources[ordinal - 1] + : undefined; + + if (!resource) { + console.error(`Request '${requestId}' not found. Use 'trace requests' to see available request IDs.`); + process.exitCode = 1; + return; + } + + const r = resource; + const status = r.response.status > 0 ? `${r.response.status} ${r.response.statusText}` : 'ERR'; + const size = r.response._transferSize! > 0 ? r.response._transferSize! : r.response.bodySize; + + console.log(`\n ${r.request.method} ${r.request.url}\n`); + + // General + console.log(' General'); + console.log(` status: ${status}`); + console.log(` duration: ${msToString(r.time)}`); + console.log(` size: ${bytesToString(size)}`); + if (r.response.content.mimeType) + console.log(` type: ${r.response.content.mimeType}`); + const route = formatRouteStatus(r); + if (route) + console.log(` route: ${route}`); + if (r.serverIPAddress) + console.log(` server: ${r.serverIPAddress}${r._serverPort ? ':' + r._serverPort : ''}`); + if (r.response._failureText) + console.log(` error: ${r.response._failureText}`); + + // Request headers + if (r.request.headers.length) { + console.log('\n Request headers'); + for (const h of r.request.headers) + console.log(` ${h.name}: ${h.value}`); + } + + // Request body + if (r.request.postData) { + console.log('\n Request body'); + console.log(` type: ${r.request.postData.mimeType}`); + if (r.request.postData.text) { + const text = r.request.postData.text.length > 2000 + ? r.request.postData.text.substring(0, 2000) + '...' + : r.request.postData.text; + console.log(` ${text}`); + } + } + + // Response headers + if (r.response.headers.length) { + console.log('\n Response headers'); + for (const h of r.response.headers) + console.log(` ${h.name}: ${h.value}`); + } + + // Security + if (r._securityDetails) { + console.log('\n Security'); + if (r._securityDetails.protocol) + console.log(` protocol: ${r._securityDetails.protocol}`); + if (r._securityDetails.subjectName) + console.log(` subject: ${r._securityDetails.subjectName}`); + if (r._securityDetails.issuer) + console.log(` issuer: ${r._securityDetails.issuer}`); + } + + console.log(''); +} + +function formatRouteStatus(r: { _wasAborted?: boolean, _wasContinued?: boolean, _wasFulfilled?: boolean, _apiRequest?: boolean }): string { + if (r._wasAborted) + return 'aborted'; + if (r._wasContinued) + return 'continued'; + if (r._wasFulfilled) + return 'fulfilled'; + if (r._apiRequest) + return 'api'; + return ''; +} + +// ---- trace console ---- + +export async function traceConsole(traceFile: string, options: { errorsOnly?: boolean, warnings?: boolean, browser?: boolean, stdio?: boolean }) { + const model = await loadTraceModel(traceFile); + + type ConsoleItem = { + type: 'browser' | 'stdout' | 'stderr'; + level: string; + text: string; + location?: string; + timestamp: number; + }; + + const items: ConsoleItem[] = []; + + for (const event of model.events) { + if (event.type === 'console') { + if (options.stdio) + continue; + const level = event.messageType; + if (options.errorsOnly && level !== 'error') + continue; + if (options.warnings && level !== 'error' && level !== 'warning') + continue; + const url = event.location.url; + const filename = url ? url.substring(url.lastIndexOf('/') + 1) : ''; + items.push({ + type: 'browser', + level, + text: event.text, + location: `${filename}:${event.location.lineNumber}`, + timestamp: event.time, + }); + } + if (event.type === 'event' && event.method === 'pageError') { + if (options.stdio) + continue; + const error = event.params.error; + items.push({ + type: 'browser', + level: 'error', + text: error?.error?.message || String(error?.value || ''), + timestamp: event.time, + }); + } + } + + for (const event of model.stdio) { + if (options.browser) + continue; + if (options.errorsOnly && event.type !== 'stderr') + continue; + if (options.warnings && event.type !== 'stderr') + continue; + let text = ''; + if (event.text) + text = event.text.trim(); + if (event.base64) + text = Buffer.from(event.base64, 'base64').toString('utf-8').trim(); + if (!text) + continue; + items.push({ + type: event.type as 'stdout' | 'stderr', + level: event.type === 'stderr' ? 'error' : 'info', + text, + timestamp: event.timestamp, + }); + } + + items.sort((a, b) => a.timestamp - b.timestamp); + + if (!items.length) { + console.log(' No console entries'); + return; + } + + for (const item of items) { + const ts = formatTimestamp(item.timestamp, model.startTime); + const source = item.type === 'browser' ? '[browser]' : `[${item.type}]`; + const level = padEnd(item.level, 8); + const location = item.location ? ` ${item.location}` : ''; + console.log(` ${ts} ${padEnd(source, 10)} ${level} ${item.text}${location}`); + } +} + +// ---- trace errors ---- + +export async function traceErrors(traceFile: string) { + const model = await loadTraceModel(traceFile); + const lang = model.sdkLanguage; + + if (!model.errorDescriptors.length) { + console.log(' No errors'); + return; + } + + for (const error of model.errorDescriptors) { + if (error.action) { + const title = actionTitle(error.action, lang); + console.log(`\n ✗ ${title}`); + } else { + console.log(`\n ✗ Error`); + } + + if (error.stack?.length) { + const frame = error.stack[0]; + const file = frame.file.replace(/.*[/\\](.*)/, '$1'); + console.log(` at ${file}:${frame.line}:${frame.column}`); + } + console.log(''); + const indented = error.message.split('\n').map(l => ` ${l}`).join('\n'); + console.log(indented); + } + console.log(''); +} + +// ---- trace snapshot ---- + +export async function traceSnapshot(traceFile: string, actionId: string, options: { name?: string, output?: string, serve?: boolean, port?: number }) { + const { model, loader } = await loadTrace(traceFile); + + const action = resolveActionId(actionId, model); + if (!action) { + console.error(`Action '${actionId}' not found.`); + process.exitCode = 1; + return; + } + + const pageId = action.pageId; + if (!pageId) { + console.error(`Action '${actionId}' has no associated page.`); + process.exitCode = 1; + return; + } + + const callId = action.callId; + const storage = loader.storage(); + + let snapshotName: string | undefined; + let renderer; + if (options.name) { + snapshotName = options.name; + renderer = storage.snapshotByName(pageId, `${snapshotName}@${callId}`); + } else { + for (const candidate of ['input', 'before', 'after']) { + renderer = storage.snapshotByName(pageId, `${candidate}@${callId}`); + if (renderer) { + snapshotName = candidate; + break; + } + } + } + + if (!renderer || !snapshotName) { + console.error(`No snapshot found for action '${actionId}'.`); + process.exitCode = 1; + return; + } + + const snapshotKey = `${snapshotName}@${callId}`; + + const rendered = renderer.render(); + const defaultName = `snapshot-${actionId}-${snapshotName}.html`; + + if (options.serve) { + const { SnapshotServer } = require('../../utils/isomorphic/trace/snapshotServer') as typeof import('../../utils/isomorphic/trace/snapshotServer'); + const { HttpServer } = require('../../server/utils/httpServer') as typeof import('../../server/utils/httpServer'); + + const snapshotServer = new SnapshotServer(storage, sha1 => loader.resourceForSha1(sha1)); + const httpServer = new HttpServer(); + + httpServer.routePrefix('/snapshot', (request, response) => { + const url = new URL('http://localhost' + request.url!); + const searchParams = url.searchParams; + searchParams.set('name', snapshotKey); + const snapshotResponse = snapshotServer.serveSnapshot(pageId, searchParams, '/snapshot'); + response.statusCode = snapshotResponse.status; + snapshotResponse.headers.forEach((value, key) => response.setHeader(key, value)); + snapshotResponse.text().then(text => response.end(text)); + return true; + }); + + httpServer.routePrefix('/', (request, response) => { + response.statusCode = 302; + response.setHeader('Location', '/snapshot'); + response.end(); + return true; + }); + + await httpServer.start({ preferredPort: options.port || 0 }); + console.log(`Snapshot served at ${httpServer.urlPrefix('human-readable')}`); + return; + } + + const outFile = await saveOutputFile(defaultName, rendered.html, options.output); + console.log(` Snapshot saved to ${outFile}`); +} + +// ---- trace screenshot ---- + +export async function traceScreenshot(traceFile: string, actionId: string, options: { output?: string }) { + const { model, loader } = await loadTrace(traceFile); + + const action = resolveActionId(actionId, model); + if (!action) { + console.error(`Action '${actionId}' not found.`); + process.exitCode = 1; + return; + } + + const pageId = action.pageId; + if (!pageId) { + console.error(`Action '${actionId}' has no associated page.`); + process.exitCode = 1; + return; + } + + const callId = action.callId; + const storage = loader.storage(); + const snapshotNames = ['input', 'before', 'after']; + let sha1: string | undefined; + for (const name of snapshotNames) { + const renderer = storage.snapshotByName(pageId, `${name}@${callId}`); + sha1 = renderer?.closestScreenshot(); + if (sha1) + break; + } + + if (!sha1) { + console.error(`No screenshot found for action '${actionId}'.`); + process.exitCode = 1; + return; + } + + const blob = await loader.resourceForSha1(sha1); + if (!blob) { + console.error(`Screenshot resource not found.`); + process.exitCode = 1; + return; + } + + const defaultName = `screenshot-${actionId}.png`; + const buffer = Buffer.from(await blob.arrayBuffer()); + const outFile = await saveOutputFile(defaultName, buffer, options.output); + console.log(` Screenshot saved to ${outFile}`); +} + +// ---- trace attachments ---- + +export async function traceAttachments(traceFile: string) { + const model = await loadTraceModel(traceFile); + + if (!model.attachments.length) { + console.log(' No attachments'); + return; + } + const { callIdToOrdinal } = buildOrdinalMap(model); + console.log(` ${padStart('#', 4)} ${padEnd('Name', 40)} ${padEnd('Content-Type', 30)} ${padEnd('Action', 8)}`); + console.log(` ${'─'.repeat(4)} ${'─'.repeat(40)} ${'─'.repeat(30)} ${'─'.repeat(8)}`); + for (let i = 0; i < model.attachments.length; i++) { + const a = model.attachments[i]; + const actionOrdinal = callIdToOrdinal.get(a.callId); + console.log(` ${padStart((i + 1) + '.', 4)} ${padEnd(a.name, 40)} ${padEnd(a.contentType, 30)} ${padEnd(actionOrdinal !== undefined ? String(actionOrdinal) : a.callId, 8)}`); + } +} + +// ---- trace attachment ---- + +export async function traceAttachment(traceFile: string, attachmentId: string, options: { output?: string }) { + const { model, loader } = await loadTrace(traceFile); + + const ordinal = parseInt(attachmentId, 10); + const attachment = !isNaN(ordinal) && ordinal >= 1 && ordinal <= model.attachments.length + ? model.attachments[ordinal - 1] + : undefined; + + if (!attachment) { + console.error(`Attachment '${attachmentId}' not found. Use 'trace attachments' to see available attachments.`); + process.exitCode = 1; + return; + } + + let content: Buffer | undefined; + if (attachment.sha1) { + const blob = await loader.resourceForSha1(attachment.sha1); + if (blob) + content = Buffer.from(await blob.arrayBuffer()); + } else if (attachment.base64) { + content = Buffer.from(attachment.base64, 'base64'); + } + + if (!content) { + console.error(`Could not extract attachment content.`); + process.exitCode = 1; + return; + } + + const outFile = await saveOutputFile(attachment.name, content, options.output); + console.log(` Attachment saved to ${outFile}`); +} + +// ---- trace info ---- + +export async function traceInfo(traceFile: string) { + const model = await loadTraceModel(traceFile); + + const info = { + browser: model.browserName || 'unknown', + platform: model.platform || 'unknown', + playwrightVersion: model.playwrightVersion || 'unknown', + title: model.title || '', + duration: msToString(model.endTime - model.startTime), + durationMs: model.endTime - model.startTime, + startTime: model.wallTime ? new Date(model.wallTime).toISOString() : 'unknown', + viewport: model.options.viewport ? `${model.options.viewport.width}x${model.options.viewport.height}` : 'default', + actions: model.actions.length, + pages: model.pages.length, + network: model.resources.length, + errors: model.errorDescriptors.length, + attachments: model.attachments.length, + consoleMessages: model.events.filter(e => e.type === 'console').length, + }; + + console.log(''); + console.log(` Browser: ${info.browser}`); + console.log(` Platform: ${info.platform}`); + console.log(` Playwright: ${info.playwrightVersion}`); + if (info.title) + console.log(` Title: ${info.title}`); + console.log(` Duration: ${info.duration}`); + console.log(` Start time: ${info.startTime}`); + console.log(` Viewport: ${info.viewport}`); + console.log(` Actions: ${info.actions}`); + console.log(` Pages: ${info.pages}`); + console.log(` Network: ${info.network} requests`); + console.log(` Errors: ${info.errors}`); + console.log(` Attachments: ${info.attachments}`); + console.log(` Console: ${info.consoleMessages} messages`); + console.log(''); +} + +// ---- install-skill ---- + +async function installSkill() { + const cwd = process.cwd(); + const skillSource = path.join(__dirname, 'SKILL.md'); + const destDir = path.join(cwd, '.claude', 'playwright-trace'); + await fs.promises.mkdir(destDir, { recursive: true }); + const destFile = path.join(destDir, 'SKILL.md'); + await fs.promises.copyFile(skillSource, destFile); + console.log(`✅ Skill installed to \`${path.relative(cwd, destFile)}\`.`); +} diff --git a/packages/playwright-core/src/server/trace/viewer/traceParser.ts b/packages/playwright-core/src/tools/trace/traceParser.ts similarity index 96% rename from packages/playwright-core/src/server/trace/viewer/traceParser.ts rename to packages/playwright-core/src/tools/trace/traceParser.ts index d3cc2bab8afab..f37fa1872443f 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceParser.ts +++ b/packages/playwright-core/src/tools/trace/traceParser.ts @@ -15,7 +15,8 @@ */ import url from 'url'; -import { ZipFile } from '../../utils/zipFile'; +import { ZipFile } from '../../server/utils/zipFile'; + import type { TraceLoaderBackend } from '@isomorphic/trace/traceLoader'; export class ZipTraceLoaderBackend implements TraceLoaderBackend { diff --git a/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts index 1f3fc7359ff2b..5c9a1e3e22465 100644 --- a/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { parseClientSideCallMetadata } from '@isomorphic/traceUtils'; +import { parseClientSideCallMetadata } from './traceUtils'; import { SnapshotStorage } from './snapshotStorage'; import { TraceModernizer } from './traceModernizer'; diff --git a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts index 0ca3b802ba4c5..c4bb0faccb8a0 100644 --- a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts @@ -14,15 +14,15 @@ * limitations under the License. */ -import { getActionGroup, renderTitleForCall } from '@isomorphic/protocolFormatter'; +import { getActionGroup, renderTitleForCall } from '../protocolFormatter'; -import type { Language } from '@isomorphic/locatorGenerators'; +import type { Language } from '../locatorGenerators'; import type { ResourceSnapshot } from '@trace/snapshot'; import type * as trace from '@trace/trace'; import type { ActionTraceEvent } from '@trace/trace'; -import type { ActionEntry, ContextEntry, PageEntry } from '@isomorphic/trace/entries'; +import type { ActionEntry, ContextEntry, PageEntry } from '../trace/entries'; import type { StackFrame } from '@protocol/channels'; -import type { ActionGroup } from '@isomorphic/protocolFormatter'; +import type { ActionGroup } from '../protocolFormatter'; const contextSymbol = Symbol('context'); const nextInContextSymbol = Symbol('nextInContext'); diff --git a/packages/playwright-core/src/utils/isomorphic/traceUtils.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceUtils.ts similarity index 100% rename from packages/playwright-core/src/utils/isomorphic/traceUtils.ts rename to packages/playwright-core/src/utils/isomorphic/trace/traceUtils.ts diff --git a/tests/config/utils.ts b/tests/config/utils.ts index b712f9ef8167d..bca0c5cd527d7 100644 --- a/tests/config/utils.ts +++ b/tests/config/utils.ts @@ -17,12 +17,12 @@ import type { Locator, Frame, Page } from 'playwright-core'; import { ZipFile } from '../../packages/playwright-core/lib/server/utils/zipFile'; import type { StackFrame } from '../../packages/protocol/src/channels'; -import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils'; +import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/trace/traceUtils'; import { TraceLoader } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceLoader'; import { TraceModel } from '../../packages/playwright-core/src/utils/isomorphic/trace/traceModel'; import type { ActionTraceEvent, TraceEvent } from '@trace/trace'; import { renderTitleForCall } from '../../packages/playwright-core/lib/utils/isomorphic/protocolFormatter'; -import { ZipTraceLoaderBackend } from '../../packages/playwright-core/lib/server/trace/viewer/traceParser'; +import { ZipTraceLoaderBackend } from '../../packages/playwright-core/lib/tools/trace/traceParser'; import type { SnapshotStorage } from '../../packages/playwright-core/src/utils/isomorphic/trace/snapshotStorage'; export type BoundingBox = Awaited>; diff --git a/tests/mcp/trace-cli-fixtures.ts b/tests/mcp/trace-cli-fixtures.ts new file mode 100644 index 0000000000000..88b8788545a6e --- /dev/null +++ b/tests/mcp/trace-cli-fixtures.ts @@ -0,0 +1,108 @@ +/** + * 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 as baseTest, expect } from './fixtures'; +import { chromium } from 'playwright-core'; + +export { expect }; + +type TraceCliWorkerFixtures = { + traceFile: string; +}; + +type TraceCliFixtures = { + runTraceCli: (args: string[]) => Promise<{ stdout: string, stderr: string, exitCode: number | null }>; +}; + +export const test = baseTest + .extend<{}, TraceCliWorkerFixtures>({ + traceFile: [async ({ __servers }, use, workerInfo) => { + const server = __servers.server; + // Record a trace with various actions for testing. + const browser = await chromium.launch(); + const context = await browser.newContext({ viewport: { width: 800, height: 600 } }); + await context.tracing.start({ screenshots: true, snapshots: true }); + + const page = await context.newPage(); + server.setContent('/', ` + + Test Page + +

Hello World

+ + + Go to page 2 + + + `, 'text/html'); + + server.setContent('/page2', ` + + Page 2 +

Page 2

+ + `, 'text/html'); + + // Navigate + await page.goto(server.PREFIX); + + // Click + await page.locator('#btn').click(); + + // Fill + await page.locator('#search').fill('test query'); + + // Console messages + await page.evaluate(() => { + console.log('info message'); + console.warn('warning message'); + console.error('error message'); + }); + + // Navigate to another page + await page.locator('a').click(); + await page.waitForURL('**/page2'); + + await page.close(); + const tmpDir = path.join(workerInfo.project.outputDir, 'pw-trace-cli-' + workerInfo.workerIndex); + const tracePath = path.join(tmpDir, 'trace.zip'); + await context.tracing.stop({ path: tracePath }); + await browser.close(); + + await use(tracePath); + + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }, { scope: 'worker' }], + }) + .extend({ + runTraceCli: async ({ childProcess }, use) => { + await use(async (args: string[]) => { + const cliPath = path.resolve(__dirname, '../../packages/playwright-core/cli.js'); + const child = childProcess({ + command: [process.execPath, cliPath, 'trace', ...args], + }); + await child.exited; + return { + stdout: child.stdout.trim(), + stderr: child.stderr.trim(), + exitCode: await child.exitCode, + }; + }); + }, + }); diff --git a/tests/mcp/trace-cli.spec.ts b/tests/mcp/trace-cli.spec.ts new file mode 100644 index 0000000000000..cb4e99e9c1297 --- /dev/null +++ b/tests/mcp/trace-cli.spec.ts @@ -0,0 +1,183 @@ +/** + * 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 { test, expect } from './trace-cli-fixtures'; + +test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Chrome-only'); + +test('trace info shows metadata', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['info', traceFile]); + expect(exitCode).toBe(0); + expect(stdout).toContain('Browser:'); + expect(stdout).toContain('chromium'); + expect(stdout).toContain('Viewport:'); + expect(stdout).toContain('800x600'); + expect(stdout).toContain('Actions:'); + expect(stdout).toContain('Pages:'); + expect(stdout).toContain('Network:'); +}); + +test('trace actions shows actions with ordinals', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['actions', traceFile]); + expect(exitCode).toBe(0); + // Should have ordinal numbers + expect(stdout).toMatch(/^\s+\d+\.\s/m); + // Should have formatted action titles + expect(stdout).toContain('Navigate'); + expect(stdout).toContain('Click'); + expect(stdout).toContain('Fill'); +}); + +test('trace actions --grep filters actions', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['actions', '--grep', 'Click', traceFile]); + expect(exitCode).toBe(0); + expect(stdout).toContain('Click'); + expect(stdout).not.toContain('Navigate'); + expect(stdout).not.toContain('Fill'); +}); + +test('trace action displays action details', async ({ traceFile, runTraceCli }) => { + // First get an action ordinal from list + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Navigate', traceFile]); + const match = listOutput.match(/^\s+(\d+)\.\s/m); + expect(match).toBeTruthy(); + + const { stdout, exitCode } = await runTraceCli(['action', traceFile, match![1]]); + expect(exitCode).toBe(0); + expect(stdout).toContain('Navigate'); + expect(stdout).toContain('Time'); + expect(stdout).toContain('start:'); + expect(stdout).toContain('duration:'); + expect(stdout).toContain('Parameters'); +}); + +test('trace action reports available snapshots', async ({ traceFile, runTraceCli }) => { + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Click', traceFile]); + const match = listOutput.match(/^\s+(\d+)\.\s/m); + expect(match).toBeTruthy(); + + const { stdout, exitCode } = await runTraceCli(['action', traceFile, match![1]]); + expect(exitCode).toBe(0); + expect(stdout).toContain('Snapshots'); + expect(stdout).toContain('available:'); +}); + +test('trace action with invalid action ID', async ({ traceFile, runTraceCli }) => { + const { stderr, exitCode } = await runTraceCli(['action', traceFile, '999999']); + expect(exitCode).toBe(1); + expect(stderr).toContain('not found'); +}); + +test('trace requests shows requests with ordinals', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['requests', traceFile]); + expect(exitCode).toBe(0); + expect(stdout).toContain('Method'); + expect(stdout).toContain('Status'); + expect(stdout).toContain('GET'); + expect(stdout).toMatch(/\d+\./); +}); + +test('trace requests --method filters', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['requests', '--method', 'GET', traceFile]); + expect(exitCode).toBe(0); + expect(stdout).toContain('GET'); + expect(stdout).not.toContain('POST'); +}); + +test('trace request shows details', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['request', traceFile, '1']); + expect(exitCode).toBe(0); + expect(stdout).toContain('General'); + expect(stdout).toContain('status:'); + expect(stdout).toContain('Request headers'); + expect(stdout).toContain('Response headers'); +}); + +test('trace request with invalid ID', async ({ traceFile, runTraceCli }) => { + const { stderr, exitCode } = await runTraceCli(['request', traceFile, '999999']); + expect(exitCode).toBe(1); + expect(stderr).toContain('not found'); +}); + +test('trace console shows messages', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['console', traceFile]); + expect(exitCode).toBe(0); + expect(stdout).toContain('info message'); + expect(stdout).toContain('warning message'); + expect(stdout).toContain('error message'); +}); + +test('trace console --errors-only', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['console', '--errors-only', traceFile]); + expect(exitCode).toBe(0); + expect(stdout).toContain('error message'); + expect(stdout).not.toContain('info message'); +}); + +test('trace errors', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['errors', traceFile]); + expect(exitCode).toBe(0); + // Our test trace may or may not have errors, just verify it doesn't crash + expect(stdout).toBeTruthy(); +}); + +test('trace snapshot saves HTML file', async ({ traceFile, runTraceCli }, testInfo) => { + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Navigate', traceFile]); + const match = listOutput.match(/^\s+(\d+)\.\s/m); + expect(match).toBeTruthy(); + + const outPath = testInfo.outputPath('test-snapshot.html'); + const { stdout, exitCode } = await runTraceCli(['snapshot', traceFile, match![1], '-o', outPath]); + expect(exitCode).toBe(0); + expect(stdout).toContain('Snapshot saved to'); + expect(fs.existsSync(outPath)).toBe(true); + const html = fs.readFileSync(outPath, 'utf-8'); + expect(html.toLowerCase()).toContain(' { + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Click', traceFile]); + const match = listOutput.match(/^\s+(\d+)\.\s/m); + expect(match).toBeTruthy(); + + const outPath = testInfo.outputPath('before-snapshot.html'); + const { stdout, exitCode } = await runTraceCli(['snapshot', '--name', 'before', traceFile, match![1], '-o', outPath]); + expect(exitCode).toBe(0); + expect(stdout).toContain('Snapshot saved to'); +}); + +test('trace screenshot saves image file', async ({ traceFile, runTraceCli }, testInfo) => { + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Navigate', traceFile]); + const match = listOutput.match(/^\s+(\d+)\.\s/m); + expect(match).toBeTruthy(); + + const outPath = testInfo.outputPath('test-screenshot.png'); + const { stdout, exitCode } = await runTraceCli(['screenshot', traceFile, match![1], '-o', outPath]); + // Screenshot may or may not be available depending on timing + if (exitCode === 0) { + expect(stdout).toContain('Screenshot saved to'); + expect(fs.existsSync(outPath)).toBe(true); + } +}); + +test('trace attachments lists attachments', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['attachments', traceFile]); + expect(exitCode).toBe(0); + // Our test trace has no attachments, just verify it doesn't crash + expect(stdout).toBeTruthy(); +}); diff --git a/utils/build/build.js b/utils/build/build.js index 5ce3f6bd36403..8c848e42fa8be 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -648,7 +648,13 @@ copyFiles.push({ }); copyFiles.push({ - files: 'packages/playwright-core/src/skill/**/*.md', + files: 'packages/playwright-core/src/tools/cli-client/skill/**/*.md', + from: 'packages/playwright-core/src', + to: 'packages/playwright-core/lib', +}); + +copyFiles.push({ + files: 'packages/playwright-core/src/tools/trace/SKILL.md', from: 'packages/playwright-core/src', to: 'packages/playwright-core/lib', }); From 5e69420508e58301bdb61036cd8fe0af8f228bda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:28:21 -0700 Subject: [PATCH 3/3] chore(deps): bump fast-xml-parser from 5.4.1 to 5.5.6 in /utils/flakiness-dashboard (#39737) --- utils/flakiness-dashboard/package-lock.json | 35 ++++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/utils/flakiness-dashboard/package-lock.json b/utils/flakiness-dashboard/package-lock.json index 84c700b232969..33a9f54dfdcd8 100644 --- a/utils/flakiness-dashboard/package-lock.json +++ b/utils/flakiness-dashboard/package-lock.json @@ -444,21 +444,24 @@ } }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", "funding": [ { "type": "github", @@ -467,7 +470,8 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { @@ -657,6 +661,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/run-applescript": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",