diff --git a/packages/playwright-core/src/tools/trace/DEPS.list b/packages/playwright-core/src/tools/trace/DEPS.list index 6549bd515b0a5..b510c2bac935b 100644 --- a/packages/playwright-core/src/tools/trace/DEPS.list +++ b/packages/playwright-core/src/tools/trace/DEPS.list @@ -1,3 +1,13 @@ [*] ../../utils/isomorphic/** ../../server/utils/zipFile.ts +./*.ts + +[traceSnapshot.ts] +../../.. +../../utils +../backend/browserBackend.ts +../backend/tools.ts +../cli-daemon/command.ts +../cli-daemon/commands.ts +../cli-client/minimist.ts diff --git a/packages/playwright-core/src/tools/trace/SKILL.md b/packages/playwright-core/src/tools/trace/SKILL.md index b1360be633559..8e4dd44332a5c 100644 --- a/packages/playwright-core/src/tools/trace/SKILL.md +++ b/packages/playwright-core/src/tools/trace/SKILL.md @@ -10,39 +10,41 @@ Inspect `.zip` trace files produced by Playwright tests without opening a browse ## Workflow -1. Start with `trace info` to understand what's in the trace. +1. Start with `trace open ` to extract the trace and see its metadata. 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. +5. Use `trace snapshot ` to get the DOM snapshot, or run a browser command against it. + +All commands after `open` operate on the currently opened trace — no need to pass the trace file again. Opening a new trace replaces the previous one. ## Commands -### Overview +### Open a trace ```bash -# Trace metadata: browser, viewport, duration, action/error counts -npx playwright trace info +# Extract trace and show metadata: browser, viewport, duration, action/error counts +npx playwright trace open ``` ### Actions ```bash # List all actions as a tree with action IDs and timing -npx playwright trace actions +npx playwright trace actions # Filter by action title (regex, case-insensitive) -npx playwright trace actions --grep "click" +npx playwright trace actions --grep "click" # Only failed actions -npx playwright trace actions --errors-only +npx playwright trace actions --errors-only ``` ### Action details ```bash # Show full details for one action: params, result, logs, source, snapshots -npx playwright trace action +npx playwright trace action ``` The `action` command displays available snapshot phases (before, input, after) and the exact command to extract them. @@ -51,101 +53,111 @@ The `action` command displays available snapshot phases (before, input, after) a ```bash # All network requests: method, status, URL, duration, size -npx playwright trace requests +npx playwright trace requests # Filter by URL pattern -npx playwright trace requests --grep "api" +npx playwright trace requests --grep "api" # Filter by HTTP method -npx playwright trace requests --method POST +npx playwright trace requests --method POST # Only failed requests (status >= 400) -npx playwright trace requests --failed +npx playwright trace requests --failed ``` ### Request details ```bash # Show full details for one request: headers, body, security -npx playwright trace request +npx playwright trace request ``` ### Console ```bash # All console messages and stdout/stderr -npx playwright trace console +npx playwright trace console # Only errors -npx playwright trace console --errors-only +npx playwright trace console --errors-only # Only browser console (no stdout/stderr) -npx playwright trace console --browser +npx playwright trace console --browser # Only stdout/stderr (no browser console) -npx playwright trace console --stdio +npx playwright trace console --stdio ``` ### Errors ```bash # All errors with stack traces and associated actions -npx playwright trace errors +npx playwright trace errors ``` ### Snapshots +The `snapshot` command loads the DOM snapshot for an action into a headless browser and runs a single browser command against it. Without a browser command, it returns the accessibility snapshot. + ```bash -# Save DOM snapshot as HTML (tries input, then before, then after) -npx playwright trace snapshot -o snapshot.html +# Get the accessibility snapshot (default) +npx playwright trace snapshot -# Save a specific phase -npx playwright trace snapshot --name before -o before.html -npx playwright trace snapshot --name after -o after.html +# Use a specific phase +npx playwright trace snapshot --name before -# Serve snapshot on localhost with resources -npx playwright trace snapshot --serve -``` +# Run eval to query the DOM +npx playwright trace snapshot -- eval "document.title" +npx playwright trace snapshot -- eval "document.querySelector('#error').textContent" -### Screenshots +# Eval on a specific element ref (from the snapshot) +npx playwright trace snapshot -- eval "el => el.getAttribute('data-testid')" e5 -```bash -# Save the closest screencast frame for an action -npx playwright trace screenshot -o screenshot.png +# Take a screenshot of the snapshot +npx playwright trace snapshot -- screenshot + +# Redirect output to a file +npx playwright trace snapshot -- eval "document.body.outerHTML" > page.html +npx playwright trace snapshot -- screenshot > screenshot.png ``` +Only three browser commands are useful on a frozen snapshot: `snapshot`, `eval`, and `screenshot`. + ### Attachments ```bash # List all trace attachments -npx playwright 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 +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 +# 1. Open the trace and see what's inside +npx playwright trace open test-results/my-test/trace.zip # 2. What actions ran? -npx playwright trace actions test-results/my-test/trace.zip +npx playwright trace actions # 3. Which action failed? -npx playwright trace actions --errors-only test-results/my-test/trace.zip +npx playwright trace actions --errors-only # 4. What went wrong? -npx playwright trace action test-results/my-test/trace.zip 12 +npx playwright trace action 12 + +# 5. What did the page look like at that moment? +npx playwright trace snapshot 12 -# 5. What did the page look like? -npx playwright trace snapshot test-results/my-test/trace.zip 12 -o page.html +# 6. Query the DOM for more detail +npx playwright trace snapshot 12 -- eval "document.querySelector('.error-message').textContent" -# 6. Any relevant network failures? -npx playwright trace requests --failed test-results/my-test/trace.zip +# 7. Any relevant network failures? +npx playwright trace requests --failed -# 7. Any console errors? -npx playwright trace console --errors-only test-results/my-test/trace.zip +# 8. Any console errors? +npx playwright trace console --errors-only ``` diff --git a/packages/playwright-core/src/tools/trace/installSkill.ts b/packages/playwright-core/src/tools/trace/installSkill.ts new file mode 100644 index 0000000000000..4194a639f67f5 --- /dev/null +++ b/packages/playwright-core/src/tools/trace/installSkill.ts @@ -0,0 +1,30 @@ +/** + * 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'; + +export 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/tools/trace/traceActions.ts b/packages/playwright-core/src/tools/trace/traceActions.ts new file mode 100644 index 0000000000000..d19964e3fc8b8 --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceActions.ts @@ -0,0 +1,155 @@ +/** + * 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 { buildActionTree } from '../../utils/isomorphic/trace/traceModel'; +import { asLocatorDescription } from '../../utils/isomorphic/locatorGenerators'; +import { loadTrace, formatTimestamp, actionTitle } from './traceUtils'; +import { msToString } from '../../utils/isomorphic/formatUtils'; + +import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; +import type { Language } from '@isomorphic/locatorGenerators'; + +export async function traceActions(options: { grep?: string, errorsOnly?: boolean }) { + const trace = await loadTrace(); + const actions = filterActions(trace.model.actions, options); + + // Tree view + const { rootItem } = buildActionTree(actions); + console.log(` ${'#'.padStart(4)} ${'Time'.padEnd(9)} ${'Action'.padEnd(55)} ${'Duration'.padStart(8)}`); + console.log(` ${'─'.repeat(4)} ${'─'.repeat(9)} ${'─'.repeat(55)} ${'─'.repeat(8)}`); + const visit = (item: ReturnType['rootItem'], indent: string) => { + const action = item.action; + const ordinal = trace.callIdToOrdinal.get(action.callId) ?? '?'; + const ts = formatTimestamp(action.startTime, trace.model.startTime); + const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'running'; + const title = actionTitle(action as ActionTraceEventInContext); + const locator = actionLocator(action as ActionTraceEventInContext); + const error = action.error ? ' ✗' : ''; + const prefix = ` ${(ordinal + '.').padStart(4)} ${ts} ${indent}`; + console.log(`${prefix}${title.padEnd(Math.max(1, 55 - indent.length))} ${duration.padStart(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 }): 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)) || pattern.test(actionLocator(a) || '')); + } + if (options.errorsOnly) + result = result.filter(a => !!a.error); + return result; +} + +function actionLocator(action: ActionTraceEventInContext, sdkLanguage?: Language): string | undefined { + return action.params.selector ? asLocatorDescription(sdkLanguage || 'javascript', action.params.selector) : undefined; +} + +export async function traceAction(actionId: string) { + const trace = await loadTrace(); + const action = trace.resolveActionId(actionId); + if (!action) { + console.error(`Action '${actionId}' not found. Use 'trace actions' to see available action IDs.`); + process.exitCode = 1; + return; + } + + const title = actionTitle(action); + console.log(`\n ${title}\n`); + + // Time + console.log(' Time'); + console.log(` start: ${formatTimestamp(action.startTime, trace.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, trace.model.startTime) : ''; + console.log(` ${time.padEnd(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); +} diff --git a/packages/playwright-core/src/tools/trace/traceAttachments.ts b/packages/playwright-core/src/tools/trace/traceAttachments.ts new file mode 100644 index 0000000000000..5c8b51de95aef --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceAttachments.ts @@ -0,0 +1,68 @@ +/** + * 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 { loadTrace, saveOutputFile } from './traceUtils'; + +export async function traceAttachments() { + const trace = await loadTrace(); + + if (!trace.model.attachments.length) { + console.log(' No attachments'); + return; + } + console.log(` ${'#'.padStart(4)} ${'Name'.padEnd(40)} ${'Content-Type'.padEnd(30)} ${'Action'.padEnd(8)}`); + console.log(` ${'─'.repeat(4)} ${'─'.repeat(40)} ${'─'.repeat(30)} ${'─'.repeat(8)}`); + for (let i = 0; i < trace.model.attachments.length; i++) { + const a = trace.model.attachments[i]; + const actionOrdinal = trace.callIdToOrdinal.get(a.callId); + console.log(` ${((i + 1) + '.').padStart(4)} ${a.name.padEnd(40)} ${a.contentType.padEnd(30)} ${(actionOrdinal !== undefined ? String(actionOrdinal) : a.callId).padEnd(8)}`); + } +} + +export async function traceAttachment(attachmentId: string, options: { output?: string }) { + const trace = await loadTrace(); + + const ordinal = parseInt(attachmentId, 10); + const attachment = !isNaN(ordinal) && ordinal >= 1 && ordinal <= trace.model.attachments.length + ? trace.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 trace.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}`); +} diff --git a/packages/playwright-core/src/tools/trace/traceCli.ts b/packages/playwright-core/src/tools/trace/traceCli.ts index f51ece2e534a5..6dca36453a262 100644 --- a/packages/playwright-core/src/tools/trace/traceCli.ts +++ b/packages/playwright-core/src/tools/trace/traceCli.ts @@ -14,20 +14,16 @@ * limitations under the License. */ -/* eslint-disable no-console */ +import { traceOpen } from './traceOpen'; +import { traceActions, traceAction } from './traceActions'; +import { traceRequests, traceRequest } from './traceRequests'; +import { traceConsole } from './traceConsole'; +import { traceErrors } from './traceErrors'; +import { traceSnapshot } from './traceSnapshot'; +import { traceScreenshot } from './traceScreenshot'; +import { traceAttachments, traceAttachment } from './traceAttachments'; +import { installSkill } from './installSkill'; -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 { msToString, bytesToString } from '../../utils/isomorphic/formatUtils'; -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) { @@ -36,812 +32,108 @@ export function addTraceCommands(program: Command, logErrorAndExit: (e: Error) = .description('inspect trace files from the command line'); traceCommand - .command('info ') - .description('show trace metadata') - .action(function(trace: string) { - traceInfo(trace).catch(logErrorAndExit); + .command('open ') + .description('extract trace file for inspection') + .action((trace: string) => { + traceOpen(trace).catch(logErrorAndExit); }); traceCommand - .command('actions ') + .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); + .action((options: { grep?: string, errorsOnly?: boolean }) => { + traceActions(options).catch(logErrorAndExit); }); traceCommand - .command('action ') + .command('action ') .description('show details of a specific action') - .action(function(trace: string, actionId: string) { - traceAction(trace, actionId).catch(logErrorAndExit); + .action((actionId: string) => { + traceAction(actionId).catch(logErrorAndExit); }); traceCommand - .command('requests ') + .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); + .action((options: { grep?: string, method?: string, status?: string, failed?: boolean }) => { + traceRequests(options).catch(logErrorAndExit); }); traceCommand - .command('request ') + .command('request ') .description('show details of a specific network request') - .action(function(trace: string, requestId: string) { - traceRequest(trace, requestId).catch(logErrorAndExit); + .action((requestId: string) => { + traceRequest(requestId).catch(logErrorAndExit); }); traceCommand - .command('console ') + .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); + .action((options: { errorsOnly?: boolean, warnings?: boolean, browser?: boolean, stdio?: boolean }) => { + traceConsole(options).catch(logErrorAndExit); }); traceCommand - .command('errors ') + .command('errors') .description('show errors with stack traces') - .action(function(trace: string) { - traceErrors(trace).catch(logErrorAndExit); + .action(() => { + traceErrors().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); + .command('snapshot ') + .description('run a playwright-cli command against a DOM snapshot') + .option('--name ', 'snapshot phase: before, input, or after') + .option('--serve', 'serve snapshot on localhost and keep running') + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async (actionId: string, options: { name?: string, serve?: boolean }, cmd: Command) => { + try { + // Collect everything after '--' as the browser command. + const browserArgs = cmd.args.slice(1); + await traceSnapshot(actionId, { ...options, browserArgs }); + } catch (e) { + logErrorAndExit(e as Error); + } }); traceCommand - .command('screenshot ') + .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); + .action((actionId: string, options: { output?: string }) => { + traceScreenshot(actionId, options).catch(logErrorAndExit); }); traceCommand - .command('attachments ') + .command('attachments') .description('list trace attachments') - .action(function(trace: string) { - traceAttachments(trace).catch(logErrorAndExit); + .action(() => { + traceAttachments().catch(logErrorAndExit); }); traceCommand - .command('attachment ') + .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); + .action((attachmentId: string, options: { output?: string }) => { + traceAttachment(attachmentId, options).catch(logErrorAndExit); }); traceCommand .command('install-skill') .description('install SKILL.md for LLM integration') - .action(function() { + .action(() => { 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 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/tools/trace/traceConsole.ts b/packages/playwright-core/src/tools/trace/traceConsole.ts new file mode 100644 index 0000000000000..d90879d86db70 --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceConsole.ts @@ -0,0 +1,103 @@ +/** + * 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 { loadTrace, formatTimestamp } from './traceUtils'; + +export async function traceConsole(options: { errorsOnly?: boolean, warnings?: boolean, browser?: boolean, stdio?: boolean }) { + const trace = await loadTrace(); + const model = trace.model; + + 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 = item.level.padEnd(8); + const location = item.location ? ` ${item.location}` : ''; + console.log(` ${ts} ${source.padEnd(10)} ${level} ${item.text}${location}`); + } +} diff --git a/packages/playwright-core/src/tools/trace/traceErrors.ts b/packages/playwright-core/src/tools/trace/traceErrors.ts new file mode 100644 index 0000000000000..f699de9d53d36 --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceErrors.ts @@ -0,0 +1,48 @@ +/** + * 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 { loadTrace, actionTitle } from './traceUtils'; + +export async function traceErrors() { + const trace = await loadTrace(); + const model = trace.model; + + if (!model.errorDescriptors.length) { + console.log(' No errors'); + return; + } + + for (const error of model.errorDescriptors) { + if (error.action) { + const title = actionTitle(error.action); + 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(''); +} diff --git a/packages/playwright-core/src/tools/trace/traceOpen.ts b/packages/playwright-core/src/tools/trace/traceOpen.ts new file mode 100644 index 0000000000000..d45cf4e939264 --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceOpen.ts @@ -0,0 +1,64 @@ +/** + * 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 { openTrace, loadTrace } from './traceUtils'; +import { msToString } from '../../utils/isomorphic/formatUtils'; + +export async function traceOpen(traceFile: string) { + await openTrace(traceFile); + await traceInfo(); +} + +async function traceInfo() { + const trace = await loadTrace(); + const model = trace.model; + + 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(''); +} diff --git a/packages/playwright-core/src/tools/trace/traceParser.ts b/packages/playwright-core/src/tools/trace/traceParser.ts index f37fa1872443f..a56896b559fb2 100644 --- a/packages/playwright-core/src/tools/trace/traceParser.ts +++ b/packages/playwright-core/src/tools/trace/traceParser.ts @@ -14,50 +14,71 @@ * limitations under the License. */ -import url from 'url'; +import fs from 'fs'; +import path from 'path'; import { ZipFile } from '../../server/utils/zipFile'; import type { TraceLoaderBackend } from '@isomorphic/trace/traceLoader'; -export class ZipTraceLoaderBackend implements TraceLoaderBackend { - private _zipFile: ZipFile; - private _traceFile: string; +export class DirTraceLoaderBackend implements TraceLoaderBackend { + private _dir: string; - constructor(traceFile: string) { - this._traceFile = traceFile; - this._zipFile = new ZipFile(traceFile); + constructor(dir: string) { + this._dir = dir; } isLive() { return false; } - traceURL() { - return url.pathToFileURL(this._traceFile).toString(); - } - async entryNames(): Promise { - return await this._zipFile.entries(); + const entries: string[] = []; + const walk = async (dir: string, prefix: string) => { + const items = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const item of items) { + if (item.isDirectory()) + await walk(path.join(dir, item.name), prefix ? `${prefix}/${item.name}` : item.name); + else + entries.push(prefix ? `${prefix}/${item.name}` : item.name); + } + }; + await walk(this._dir, ''); + return entries; } async hasEntry(entryName: string): Promise { - const entries = await this.entryNames(); - return entries.includes(entryName); + try { + await fs.promises.access(path.join(this._dir, entryName)); + return true; + } catch { + return false; + } } async readText(entryName: string): Promise { try { - const buffer = await this._zipFile.read(entryName); - return buffer.toString('utf-8'); + return await fs.promises.readFile(path.join(this._dir, entryName), 'utf-8'); } catch { } } async readBlob(entryName: string): Promise { try { - const buffer = await this._zipFile.read(entryName); + const buffer = await fs.promises.readFile(path.join(this._dir, entryName)); return new Blob([new Uint8Array(buffer)]); } catch { } } } + +export async function extractTrace(traceFile: string, outDir: string): Promise { + const zipFile = new ZipFile(traceFile); + const entries = await zipFile.entries(); + for (const entry of entries) { + const outPath = path.join(outDir, entry); + await fs.promises.mkdir(path.dirname(outPath), { recursive: true }); + const buffer = await zipFile.read(entry); + await fs.promises.writeFile(outPath, buffer); + } + zipFile.close(); +} diff --git a/packages/playwright-core/src/tools/trace/traceRequests.ts b/packages/playwright-core/src/tools/trace/traceRequests.ts new file mode 100644 index 0000000000000..c4141930e091e --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceRequests.ts @@ -0,0 +1,175 @@ +/** + * 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 { loadTrace } from './traceUtils'; +import { msToString } from '../../utils/isomorphic/formatUtils'; + +export async function traceRequests(options: { grep?: string, method?: string, status?: string, failed?: boolean }) { + const trace = await loadTrace(); + const model = trace.model; + + // 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)} ${'Method'.padEnd(8)} ${'Status'.padEnd(8)} ${'Name'.padEnd(45)} ${'Duration'.padStart(10)} ${'Size'.padStart(8)} ${'Route'.padEnd(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(` ${(ordinal + '.').padStart(4)} ${r.request.method.padEnd(8)} ${status.padEnd(8)} ${name.padEnd(45)} ${msToString(r.time).padStart(10)} ${bytesToString(size).padStart(8)} ${route.padEnd(10)}`); + } +} + +// ---- trace request ---- + +export async function traceRequest(requestId: string) { + const trace = await loadTrace(); + const model = trace.model; + 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 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 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 ''; +} diff --git a/packages/playwright-core/src/tools/trace/traceScreenshot.ts b/packages/playwright-core/src/tools/trace/traceScreenshot.ts new file mode 100644 index 0000000000000..0d7a9a62ba0f7 --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceScreenshot.ts @@ -0,0 +1,66 @@ +/** + * 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 { loadTrace, saveOutputFile } from './traceUtils'; + +export async function traceScreenshot(actionId: string, options: { output?: string }) { + const trace = await loadTrace(); + + const action = trace.resolveActionId(actionId); + 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 = trace.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 trace.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}`); +} diff --git a/packages/playwright-core/src/tools/trace/traceSnapshot.ts b/packages/playwright-core/src/tools/trace/traceSnapshot.ts new file mode 100644 index 0000000000000..3e806b2f8e78d --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceSnapshot.ts @@ -0,0 +1,149 @@ +/** + * 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 { TraceLoader } from '../../utils/isomorphic/trace/traceLoader'; +import { BrowserBackend } from '../backend/browserBackend'; +import { browserTools } from '../backend/tools'; +import * as playwright from '../../..'; +import { gracefullyCloseAll } from '../../utils'; +import { parseCommand } from '../cli-daemon/command'; +import { minimist } from '../cli-client/minimist'; +import { commands } from '../cli-daemon/commands'; +import { loadTrace } from './traceUtils'; + +import type { SnapshotStorage } from '@isomorphic/trace/snapshotStorage'; + +export async function traceSnapshot(actionId: string, options: { name?: string, serve?: boolean, browserArgs?: string[] }): Promise { + const trace = await loadTrace(); + + const action = trace.resolveActionId(actionId); + 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 = trace.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 server = await serveTraceSnapshot(storage, trace.loader, pageId, snapshotKey); + + if (options.serve) { + console.log(`Serving snapshot at ${server.url}`); + await new Promise(() => {}); + return; + } + + await runCommandOnSnapshot(server, options.browserArgs || []); +} + +async function serveTraceSnapshot(storage: SnapshotStorage, loader: TraceLoader, pageId: string, snapshotKey: string): Promise<{ url: string, stop: () => Promise }> { + 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: any, response: any) => { + 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: string, key: string) => response.setHeader(key, value)); + snapshotResponse.text().then((text: string) => response.end(text)); + return true; + }); + + httpServer.routePrefix('/', (_request: any, response: any) => { + response.statusCode = 302; + response.setHeader('Location', '/snapshot'); + response.end(); + return true; + }); + + await httpServer.start({ preferredPort: 0 }); + return { url: httpServer.urlPrefix('human-readable'), stop: () => httpServer.stop() }; +} + +async function runCommandOnSnapshot(server: { url: string, stop: () => Promise }, browserArgs: string[]) { + const browser = await playwright.chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(server.url); + + const backend = new BrowserBackend({ + snapshot: { mode: 'full' }, + outputMode: 'file', + skillMode: true, + }, context, browserTools); + await backend.initialize({ cwd: process.cwd() }); + + try { + if (!browserArgs.length) + browserArgs = ['snapshot']; + const args = minimist(browserArgs, { string: ['_'] }); + const command = commands[args._[0]]; + if (!command) + throw new Error(`Unknown command: ${args._[0]}`); + const { toolName, toolParams } = parseCommand(command, args as Record & { _: string[] }); + const result = await backend.callTool(toolName, toolParams); + const text = result.content[0]?.type === 'text' ? result.content[0].text : undefined; + if (text) + console.log(text); + if (result.isError) { + console.error('Command failed.'); + process.exitCode = 1; + } + } catch (e) { + console.error((e as Error).message); + process.exitCode = 1; + } finally { + await server.stop().catch(e => console.error(e)); + await gracefullyCloseAll(); + } +} diff --git a/packages/playwright-core/src/tools/trace/traceUtils.ts b/packages/playwright-core/src/tools/trace/traceUtils.ts new file mode 100644 index 0000000000000..9dae3b2e4f8c8 --- /dev/null +++ b/packages/playwright-core/src/tools/trace/traceUtils.ts @@ -0,0 +1,123 @@ +/** + * 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 { TraceModel, buildActionTree } from '../../utils/isomorphic/trace/traceModel'; +import { TraceLoader } from '../../utils/isomorphic/trace/traceLoader'; +import { renderTitleForCall } from '../../utils/isomorphic/protocolFormatter'; +import { DirTraceLoaderBackend, extractTrace } from './traceParser'; + +import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; + +const traceDir = path.join('.playwright-cli', 'trace'); +const cliOutputDir = '.playwright-cli'; + +export class LoadedTrace { + readonly model: TraceModel; + readonly loader: TraceLoader; + readonly ordinalToCallId: Map; + readonly callIdToOrdinal: Map; + + constructor(model: TraceModel, loader: TraceLoader, ordinals: { ordinalToCallId: Map, callIdToOrdinal: Map }) { + this.model = model; + this.loader = loader; + this.ordinalToCallId = ordinals.ordinalToCallId; + this.callIdToOrdinal = ordinals.callIdToOrdinal; + } + + resolveActionId(actionId: string): ActionTraceEventInContext | undefined { + const ordinal = parseInt(actionId, 10); + if (!isNaN(ordinal)) { + const callId = this.ordinalToCallId.get(ordinal); + if (callId) + return this.model.actions.find(a => a.callId === callId); + } + return this.model.actions.find(a => a.callId === actionId); + } +} + +function ensureTraceOpen(): string { + if (!fs.existsSync(traceDir)) + throw new Error(`No trace opened. Run 'npx playwright trace open ' first.`); + return traceDir; +} + +export async function openTrace(traceFile: string) { + const filePath = path.resolve(traceFile); + if (!fs.existsSync(filePath)) + throw new Error(`Trace file not found: ${filePath}`); + if (fs.existsSync(traceDir)) + await fs.promises.rm(traceDir, { recursive: true }); + await fs.promises.mkdir(traceDir, { recursive: true }); + await extractTrace(filePath, traceDir); +} + +export async function loadTrace(): Promise { + const dir = ensureTraceOpen(); + const backend = new DirTraceLoaderBackend(dir); + const loader = new TraceLoader(); + await loader.load(backend, () => undefined); + const model = new TraceModel(dir, loader.contextEntries); + return new LoadedTrace(model, loader, buildOrdinalMap(model)); +} + +export 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')}`; +} + +export function actionTitle(action: ActionTraceEventInContext): string { + return renderTitleForCall({ ...action, type: action.class }) || `${action.class}.${action.method}`; +} + +export 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 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 }; +} diff --git a/tests/config/utils.ts b/tests/config/utils.ts index bca0c5cd527d7..ffa2a0f0fbf17 100644 --- a/tests/config/utils.ts +++ b/tests/config/utils.ts @@ -22,7 +22,7 @@ import { TraceLoader } from '../../packages/playwright-core/src/utils/isomorphic 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/tools/trace/traceParser'; +import { DirTraceLoaderBackend, extractTrace } from '../../packages/playwright-core/lib/tools/trace/traceParser'; import type { SnapshotStorage } from '../../packages/playwright-core/src/utils/isomorphic/trace/snapshotStorage'; export type BoundingBox = Awaited>; @@ -166,10 +166,12 @@ export async function parseTraceRaw(file: string): Promise<{ events: any[], reso } export async function parseTrace(file: string): Promise<{ snapshots: SnapshotStorage, model: TraceModel }> { - const backend = new ZipTraceLoaderBackend(file); + const dir = file + '.extracted'; + await extractTrace(file, dir); + const backend = new DirTraceLoaderBackend(dir); const loader = new TraceLoader(); await loader.load(backend, () => {}); - return { model: new TraceModel(file, loader.contextEntries), snapshots: loader.storage() }; + return { model: new TraceModel(dir, loader.contextEntries), snapshots: loader.storage() }; } export async function parseHar(file: string): Promise> { diff --git a/tests/mcp/trace-cli-fixtures.ts b/tests/mcp/trace-cli-fixtures.ts index 88b8788545a6e..73bd30ee2a33f 100644 --- a/tests/mcp/trace-cli-fixtures.ts +++ b/tests/mcp/trace-cli-fixtures.ts @@ -16,6 +16,7 @@ import fs from 'fs'; import path from 'path'; +import { execFileSync } from 'child_process'; import { test as baseTest, expect } from './fixtures'; import { chromium } from 'playwright-core'; @@ -24,6 +25,7 @@ export { expect }; type TraceCliWorkerFixtures = { traceFile: string; + traceCwd: string; }; type TraceCliFixtures = { @@ -89,13 +91,24 @@ export const test = baseTest await fs.promises.rm(tmpDir, { recursive: true, force: true }); }, { scope: 'worker' }], + + traceCwd: [async ({ traceFile }, use, workerInfo) => { + // Create a working directory and open the trace once for all tests. + const cwd = path.join(workerInfo.project.outputDir, 'pw-trace-cwd-' + workerInfo.workerIndex); + await fs.promises.mkdir(cwd, { recursive: true }); + const cliPath = path.resolve(__dirname, '../../packages/playwright-core/cli.js'); + execFileSync(process.execPath, [cliPath, 'trace', 'open', traceFile], { cwd }); + await use(cwd); + await fs.promises.rm(cwd, { recursive: true, force: true }); + }, { scope: 'worker' }], }) .extend({ - runTraceCli: async ({ childProcess }, use) => { + runTraceCli: async ({ childProcess, traceCwd }, 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], + cwd: traceCwd, }); await child.exited; return { diff --git a/tests/mcp/trace-cli.spec.ts b/tests/mcp/trace-cli.spec.ts index cb4e99e9c1297..9f6d12e4c01d4 100644 --- a/tests/mcp/trace-cli.spec.ts +++ b/tests/mcp/trace-cli.spec.ts @@ -20,8 +20,8 @@ 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]); +test('trace open shows metadata', async ({ traceFile, runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['open', traceFile]); expect(exitCode).toBe(0); expect(stdout).toContain('Browser:'); expect(stdout).toContain('chromium'); @@ -32,8 +32,8 @@ test('trace info shows metadata', async ({ traceFile, runTraceCli }) => { expect(stdout).toContain('Network:'); }); -test('trace actions shows actions with ordinals', async ({ traceFile, runTraceCli }) => { - const { stdout, exitCode } = await runTraceCli(['actions', traceFile]); +test('trace actions shows actions with ordinals', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['actions']); expect(exitCode).toBe(0); // Should have ordinal numbers expect(stdout).toMatch(/^\s+\d+\.\s/m); @@ -43,21 +43,21 @@ test('trace actions shows actions with ordinals', async ({ traceFile, runTraceCl expect(stdout).toContain('Fill'); }); -test('trace actions --grep filters actions', async ({ traceFile, runTraceCli }) => { - const { stdout, exitCode } = await runTraceCli(['actions', '--grep', 'Click', traceFile]); +test('trace actions --grep filters actions', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['actions', '--grep', 'Click']); 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 }) => { +test('trace action displays action details', async ({ runTraceCli }) => { // First get an action ordinal from list - const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Navigate', traceFile]); + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Navigate']); const match = listOutput.match(/^\s+(\d+)\.\s/m); expect(match).toBeTruthy(); - const { stdout, exitCode } = await runTraceCli(['action', traceFile, match![1]]); + const { stdout, exitCode } = await runTraceCli(['action', match![1]]); expect(exitCode).toBe(0); expect(stdout).toContain('Navigate'); expect(stdout).toContain('Time'); @@ -66,25 +66,25 @@ test('trace action displays action details', async ({ traceFile, runTraceCli }) expect(stdout).toContain('Parameters'); }); -test('trace action reports available snapshots', async ({ traceFile, runTraceCli }) => { - const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Click', traceFile]); +test('trace action reports available snapshots', async ({ runTraceCli }) => { + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Click']); const match = listOutput.match(/^\s+(\d+)\.\s/m); expect(match).toBeTruthy(); - const { stdout, exitCode } = await runTraceCli(['action', traceFile, match![1]]); + const { stdout, exitCode } = await runTraceCli(['action', 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']); +test('trace action with invalid action ID', async ({ runTraceCli }) => { + const { stderr, exitCode } = await runTraceCli(['action', '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]); +test('trace requests shows requests with ordinals', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['requests']); expect(exitCode).toBe(0); expect(stdout).toContain('Method'); expect(stdout).toContain('Status'); @@ -92,15 +92,15 @@ test('trace requests shows requests with ordinals', async ({ traceFile, runTrace expect(stdout).toMatch(/\d+\./); }); -test('trace requests --method filters', async ({ traceFile, runTraceCli }) => { - const { stdout, exitCode } = await runTraceCli(['requests', '--method', 'GET', traceFile]); +test('trace requests --method filters', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['requests', '--method', 'GET']); 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']); +test('trace request shows details', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['request', '1']); expect(exitCode).toBe(0); expect(stdout).toContain('General'); expect(stdout).toContain('status:'); @@ -108,66 +108,61 @@ test('trace request shows details', async ({ traceFile, runTraceCli }) => { expect(stdout).toContain('Response headers'); }); -test('trace request with invalid ID', async ({ traceFile, runTraceCli }) => { - const { stderr, exitCode } = await runTraceCli(['request', traceFile, '999999']); +test('trace request with invalid ID', async ({ runTraceCli }) => { + const { stderr, exitCode } = await runTraceCli(['request', '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]); +test('trace console shows messages', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['console']); 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]); +test('trace console --errors-only', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['console', '--errors-only']); 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]); +test('trace errors', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['errors']); 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]); +test('trace snapshot runs command on snapshot', async ({ runTraceCli }) => { + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Navigate']); 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]); + const { stdout, exitCode } = await runTraceCli(['snapshot', match![1]]); 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]); +test('trace snapshot --name before', async ({ runTraceCli }) => { + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Click']); 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]); + const { stdout, exitCode } = await runTraceCli(['snapshot', '--name', 'before', match![1]]); expect(exitCode).toBe(0); - expect(stdout).toContain('Snapshot saved to'); + expect(stdout).toBeTruthy(); }); -test('trace screenshot saves image file', async ({ traceFile, runTraceCli }, testInfo) => { - const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Navigate', traceFile]); +test('trace screenshot saves image file', async ({ runTraceCli }, testInfo) => { + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Navigate']); 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]); + const { stdout, exitCode } = await runTraceCli(['screenshot', match![1], '-o', outPath]); // Screenshot may or may not be available depending on timing if (exitCode === 0) { expect(stdout).toContain('Screenshot saved to'); @@ -175,8 +170,8 @@ test('trace screenshot saves image file', async ({ traceFile, runTraceCli }, tes } }); -test('trace attachments lists attachments', async ({ traceFile, runTraceCli }) => { - const { stdout, exitCode } = await runTraceCli(['attachments', traceFile]); +test('trace attachments lists attachments', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['attachments']); expect(exitCode).toBe(0); // Our test trace has no attachments, just verify it doesn't crash expect(stdout).toBeTruthy();