From 6ee674b6c35b7b001353f003484866e3df8755a4 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 17 Mar 2026 15:15:44 -0700 Subject: [PATCH] feat(cli): add trace CLI for inspecting trace files --- .../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', });