diff --git a/package-lock.json b/package-lock.json index c3036072f4..cc8b491c4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1945,8 +1945,8 @@ }, "node_modules/ansi-to-html": { "version": "0.7.2", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz", + "integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==", "dependencies": { "entities": "^2.2.0" }, @@ -2753,7 +2753,6 @@ }, "node_modules/entities": { "version": "2.2.0", - "dev": true, "license": "BSD-2-Clause", "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -5932,7 +5931,10 @@ } }, "packages/html-reporter": { - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "ansi-to-html": "^0.7.2" + } }, "packages/playwright": { "version": "1.32.0-next", @@ -6167,7 +6169,10 @@ "version": "0.0.0" }, "packages/trace-viewer": { - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "ansi-to-html": "^0.7.2" + } } }, "dependencies": { @@ -7421,7 +7426,8 @@ }, "ansi-to-html": { "version": "0.7.2", - "dev": true, + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz", + "integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==", "requires": { "entities": "^2.2.0" } @@ -7963,8 +7969,7 @@ } }, "entities": { - "version": "2.2.0", - "dev": true + "version": "2.2.0" }, "env-paths": { "version": "2.2.1", @@ -8640,7 +8645,10 @@ "integrity": "sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==" }, "html-reporter": { - "version": "file:packages/html-reporter" + "version": "file:packages/html-reporter", + "requires": { + "ansi-to-html": "*" + } }, "http-cache-semantics": { "version": "4.1.1", @@ -9647,7 +9655,10 @@ } }, "trace-viewer": { - "version": "file:packages/trace-viewer" + "version": "file:packages/trace-viewer", + "requires": { + "ansi-to-html": "^0.7.2" + } }, "tree-kill": { "version": "1.2.2", diff --git a/packages/html-reporter/package.json b/packages/html-reporter/package.json index b11334c5b1..62d5d95bfd 100644 --- a/packages/html-reporter/package.json +++ b/packages/html-reporter/package.json @@ -6,5 +6,8 @@ "dev": "vite", "build": "vite build && tsc", "preview": "vite preview" + }, + "dependencies": { + "ansi-to-html": "^0.7.2" } } diff --git a/packages/playwright-core/src/utils/traceUtils.ts b/packages/playwright-core/src/utils/traceUtils.ts index d6d800328e..43fcbe6bb8 100644 --- a/packages/playwright-core/src/utils/traceUtils.ts +++ b/packages/playwright-core/src/utils/traceUtils.ts @@ -16,10 +16,13 @@ import fs from 'fs'; import type EventEmitter from 'events'; -import type { ClientSideCallMetadata } from '@protocol/channels'; +import type { ClientSideCallMetadata, StackFrame } from '@protocol/channels'; import type { SerializedClientSideCallMetadata, SerializedStack, SerializedStackFrame } from '@trace/traceUtils'; import { yazl, yauzl } from '../zipBundle'; import { ManualPromise } from './manualPromise'; +import type { ActionTraceEvent } from '@trace/trace'; +import { calculateSha1 } from './crypto'; +import { monotonicTime } from './time'; export function serializeClientSideCallMetadata(metadatas: ClientSideCallMetadata[]): SerializedClientSideCallMetadata { const fileNames = new Map(); @@ -92,3 +95,65 @@ export async function mergeTraceFiles(fileName: string, temporaryTraceFiles: str }); await mergePromise; } + +export async function saveTraceFile(fileName: string, traceEvents: ActionTraceEvent[], saveSources: boolean) { + const lines: string[] = traceEvents.map(e => JSON.stringify(e)); + const zipFile = new yazl.ZipFile(); + zipFile.addBuffer(Buffer.from(lines.join('\n')), 'trace.trace'); + + if (saveSources) { + const sourceFiles = new Set(); + for (const event of traceEvents) { + for (const frame of event.stack || []) + sourceFiles.add(frame.file); + } + for (const sourceFile of sourceFiles) { + await fs.promises.readFile(sourceFile, 'utf8').then(source => { + zipFile.addBuffer(Buffer.from(source), 'resources/src@' + calculateSha1(sourceFile) + '.txt'); + }).catch(() => {}); + } + } + await new Promise(f => { + zipFile.end(undefined, () => { + zipFile.outputStream.pipe(fs.createWriteStream(fileName)).on('close', f); + }); + }); +} + +export function createTraceEventForExpect(apiName: string, expected: any, stack: StackFrame[], wallTime: number): ActionTraceEvent { + return { + type: 'action', + callId: 'expect@' + wallTime, + wallTime, + startTime: monotonicTime(), + endTime: 0, + class: 'Test', + method: 'step', + apiName, + params: { expected: generatePreview(expected) }, + snapshots: [], + log: [], + stack, + }; +} + +function generatePreview(value: any, visited = new Set()): string { + if (visited.has(value)) + return ''; + visited.add(value); + if (typeof value === 'string') + return value; + if (typeof value === 'number') + return value.toString(); + if (typeof value === 'boolean') + return value.toString(); + if (value === null) + return 'null'; + if (value === undefined) + return 'undefined'; + if (Array.isArray(value)) + return '[' + value.map(v => generatePreview(v, visited)).join(', ') + ']'; + if (typeof value === 'object') + return 'Object'; + return String(value); +} diff --git a/packages/playwright-test/src/index.ts b/packages/playwright-test/src/index.ts index ec11ce9896..7ca77a5e0c 100644 --- a/packages/playwright-test/src/index.ts +++ b/packages/playwright-test/src/index.ts @@ -18,7 +18,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { APIRequestContext, BrowserContext, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core'; import * as playwrightLibrary from 'playwright-core'; -import { createGuid, debugMode, removeFolders, addInternalStackPrefix, mergeTraceFiles } from 'playwright-core/lib/utils'; +import { createGuid, debugMode, removeFolders, addInternalStackPrefix, mergeTraceFiles, saveTraceFile } from 'playwright-core/lib/utils'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, TraceMode, VideoMode } from '../types/test'; import type { TestInfoImpl } from './worker/testInfo'; import { rootTestType } from './common/testType'; @@ -426,7 +426,18 @@ const playwrightFixtures: Fixtures = ({ await stopTracing(tracing); }))); - // 6. Either remove or attach temporary traces and screenshots for contexts closed + + // 6. Save test trace. + if (preserveTrace) { + const events = (testInfo as any)._traceEvents; + if (events.length) { + const tracePath = path.join(_artifactsDir(), createGuid() + '.zip'); + temporaryTraceFiles.push(tracePath); + await saveTraceFile(tracePath, events, traceOptions.sources); + } + } + + // 7. Either remove or attach temporary traces and screenshots for contexts closed // before the test has finished. if (preserveTrace && temporaryTraceFiles.length) { const tracePath = testInfo.outputPath(`trace.zip`); diff --git a/packages/playwright-test/src/matchers/expect.ts b/packages/playwright-test/src/matchers/expect.ts index af94aa5312..1feb916404 100644 --- a/packages/playwright-test/src/matchers/expect.ts +++ b/packages/playwright-test/src/matchers/expect.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { captureRawStack, pollAgainstTimeout } from 'playwright-core/lib/utils'; +import { captureRawStack, createTraceEventForExpect, monotonicTime, pollAgainstTimeout } from 'playwright-core/lib/utils'; import type { ExpectZone } from 'playwright-core/lib/utils'; import { toBeChecked, @@ -214,6 +214,11 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { }); testInfo.currentStep = step; + const generateTraceEvent = matcherName !== 'poll' && matcherName !== 'toPass'; + const traceEvent = generateTraceEvent ? createTraceEventForExpect(defaultTitle, args[0], stackFrames, wallTime) : undefined; + if (traceEvent) + testInfo._traceEvents.push(traceEvent); + const reportStepError = (jestError: Error) => { const message = jestError.message; if (customMessage) { @@ -238,22 +243,32 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } const serializerError = serializeError(jestError); - step.complete({ error: serializerError }); + if (traceEvent) { + traceEvent.error = { name: jestError.name, message: jestError.message, stack: jestError.stack }; + traceEvent.endTime = monotonicTime(); + step.complete({ error: serializerError }); + } if (this._info.isSoft) testInfo._failWithError(serializerError, false /* isHardError */); else throw jestError; }; + const finalizer = () => { + if (traceEvent) + traceEvent.endTime = monotonicTime(); + step.complete({}); + }; + try { const expectZone: ExpectZone = { title: defaultTitle, wallTime }; const result = zones.run('expectZone', expectZone, () => { return matcher.call(target, ...args); }); if (result instanceof Promise) - return result.then(() => step.complete({})).catch(reportStepError); + return result.then(() => finalizer()).catch(reportStepError); else - step.complete({}); + finalizer(); } catch (e) { reportStepError(e); } diff --git a/packages/playwright-test/src/worker/testInfo.ts b/packages/playwright-test/src/worker/testInfo.ts index 41fccb3612..78ee51193f 100644 --- a/packages/playwright-test/src/worker/testInfo.ts +++ b/packages/playwright-test/src/worker/testInfo.ts @@ -23,6 +23,7 @@ import type { TestCase } from '../common/test'; import { TimeoutManager } from './timeoutManager'; import type { Annotation, FullConfigInternal, FullProjectInternal, Location } from '../common/types'; import { getContainedPath, normalizeAndSaveAttachment, sanitizeForFilePath, serializeError, trimLongString } from '../util'; +import type * as trace from '@trace/trace'; export type TestInfoErrorState = { status: TestStatus, @@ -49,6 +50,7 @@ export class TestInfoImpl implements TestInfo { readonly _startTime: number; readonly _startWallTime: number; private _hasHardError: boolean = false; + readonly _traceEvents: trace.TraceEvent[] = []; readonly _onTestFailureImmediateCallbacks = new Map<() => Promise, string>(); // fn -> title _didTimeout = false; _lastStepId = 0; diff --git a/packages/trace-viewer/package.json b/packages/trace-viewer/package.json index 069d430495..ba6c550df3 100644 --- a/packages/trace-viewer/package.json +++ b/packages/trace-viewer/package.json @@ -7,5 +7,8 @@ "build": "vite build && tsc", "build-sw": "vite --config vite.sw.config.ts build && tsc", "preview": "vite preview" + }, + "dependencies": { + "ansi-to-html": "^0.7.2" } } diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index 8593d48779..92ac679fe4 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import ansi2html from 'ansi-to-html'; import type { SerializedValue } from '@protocol/channels'; import type { ActionTraceEvent } from '@trace/trace'; import { msToString } from '@web/uiUtils'; @@ -38,10 +39,8 @@ export const CallTab: React.FunctionComponent<{ const wallTime = action.wallTime ? new Date(action.wallTime).toLocaleString() : null; const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'Timed Out'; return
-