diff --git a/src/client/channelOwner.ts b/src/client/channelOwner.ts index eafef5cd69..e2e5a72e93 100644 --- a/src/client/channelOwner.ts +++ b/src/client/channelOwner.ts @@ -102,7 +102,7 @@ export abstract class ChannelOwner ${apiName} started`); - csiCallback = ancestorWithCSI._csi?.onApiCall(apiName); + csiCallback = ancestorWithCSI._csi?.onApiCall(stackTrace); const result = await func(channel as any, stackTrace); csiCallback?.(); logApiCall(logger, `<= ${apiName} succeeded`); diff --git a/src/client/connection.ts b/src/client/connection.ts index 68bfe0140b..500581d041 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -57,7 +57,7 @@ export class Connection extends EventEmitter { private _waitingForObject = new Map(); onmessage = (message: object): void => {}; private _lastId = 0; - private _callbacks = new Map void, reject: (a: Error) => void, metadata: channels.Metadata }>(); + private _callbacks = new Map void, reject: (a: Error) => void, stackTrace: ParsedStackTrace }>(); private _rootObject: Root; private _disconnectedErrorMessage: string | undefined; private _onClose?: () => void; @@ -72,17 +72,18 @@ export class Connection extends EventEmitter { return await this._rootObject.initialize(); } - pendingProtocolCalls(): channels.Metadata[] { - return Array.from(this._callbacks.values()).map(callback => callback.metadata); + pendingProtocolCalls(): ParsedStackTrace[] { + return Array.from(this._callbacks.values()).map(callback => callback.stackTrace); } getObjectWithKnownName(guid: string): any { return this._objects.get(guid)!; } - async sendMessageToServer(object: ChannelOwner, method: string, params: any, stackTrace: ParsedStackTrace | null): Promise { + async sendMessageToServer(object: ChannelOwner, method: string, params: any, maybeStackTrace: ParsedStackTrace | null): Promise { const guid = object._guid; - const { frames, apiName }: ParsedStackTrace = stackTrace || { frameTexts: [], frames: [], apiName: '' }; + const stackTrace = maybeStackTrace || { frameTexts: [], frames: [], apiName: '' }; + const { frames, apiName } = stackTrace; const id = ++this._lastId; const converted = { id, guid, method, params }; @@ -93,7 +94,7 @@ export class Connection extends EventEmitter { if (this._disconnectedErrorMessage) throw new Error(this._disconnectedErrorMessage); - return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, metadata })); + return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, stackTrace })); } _debugScopeState(): any { diff --git a/src/client/types.ts b/src/client/types.ts index ee50ac3f22..d5fa77e090 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -16,6 +16,9 @@ */ import * as channels from '../protocol/channels'; +import type { Size } from '../common/types'; +import type { ParsedStackTrace } from '../utils/stackTrace'; +export { Size, Point, Rect, Quad, URLMatch, TimeoutOptions } from '../common/types'; type LoggerSeverity = 'verbose' | 'info' | 'warning' | 'error'; export interface Logger { @@ -24,11 +27,9 @@ export interface Logger { } export interface ClientSideInstrumentation { - onApiCall(name: string): (error?: Error) => void; + onApiCall(stackTrace: ParsedStackTrace): (error?: Error) => void; } -import { Size } from '../common/types'; -export { Size, Point, Rect, Quad, URLMatch, TimeoutOptions } from '../common/types'; export type StrictOptions = { strict?: boolean }; export type Headers = { [key: string]: string }; export type Env = { [key: string]: string | number | boolean | undefined }; diff --git a/src/server/supplements/recorder/recorderApp.ts b/src/server/supplements/recorder/recorderApp.ts index de62782804..a290bc51c9 100644 --- a/src/server/supplements/recorder/recorderApp.ts +++ b/src/server/supplements/recorder/recorderApp.ts @@ -86,7 +86,7 @@ export class RecorderApp extends EventEmitter { } static async open(inspectedContext: BrowserContext): Promise { - const recorderPlaywright = (require('../../playwright').createPlaywright as typeof import('../../playwright').createPlaywright)('javascript', true) as import('../../playwright').Playwright; + const recorderPlaywright = (require('../../playwright').createPlaywright as typeof import('../../playwright').createPlaywright)('javascript', true); const args = [ '--app=data:text/html,', '--window-size=600,600', diff --git a/src/test/dispatcher.ts b/src/test/dispatcher.ts index 0165b0cd1c..1bf557fe1e 100644 --- a/src/test/dispatcher.ts +++ b/src/test/dispatcher.ts @@ -309,6 +309,7 @@ export class Dispatcher { startTime: new Date(params.wallTime), duration: 0, steps: [], + data: params.data, }; steps.set(params.stepId, step); (parentStep || result).steps.push(step); diff --git a/src/test/expect.ts b/src/test/expect.ts index b5175ed678..ced8a5096d 100644 --- a/src/test/expect.ts +++ b/src/test/expect.ts @@ -41,6 +41,8 @@ import type { Expect, TestError } from './types'; import matchers from 'expect/build/matchers'; import { currentTestInfo } from './globals'; import { serializeError } from './util'; +import StackUtils from 'stack-utils'; +import path from 'path'; export const expect: Expect = expectLibrary as any; expectLibrary.setState({ expand: false }); @@ -73,15 +75,17 @@ function wrap(matcherName: string, matcher: any) { if (!testInfo) return matcher.call(this, ...args); - const infix = this.isNot ? '.not' : ''; - const completeStep = testInfo._addStep('expect', `expect${infix}.${matcherName}`); - const stack = new Error().stack; + const INTERNAL_STACK_LENGTH = 3; + const stackLines = new Error().stack!.split('\n').slice(INTERNAL_STACK_LENGTH + 1); + const completeStep = testInfo._addStep('expect', `expect${this.isNot ? '.not' : ''}.${matcherName}`, prepareExpectStepData(stackLines)); const reportStepEnd = (result: any) => { const success = result.pass !== this.isNot; let error: TestError | undefined; - if (!success) - error = { message: result.message(), stack }; + if (!success) { + const message = result.message(); + error = { message, stack: message + '\n' + stackLines.join('\n') }; + } completeStep?.(error); return result; }; @@ -102,6 +106,22 @@ function wrap(matcherName: string, matcher: any) { }; } +const stackUtils = new StackUtils(); + +function prepareExpectStepData(lines: string[]) { + const frames = lines.map(line => { + const parsed = stackUtils.parseLine(line); + if (!parsed) + return; + return { + file: parsed.file ? path.resolve(process.cwd(), parsed.file) : undefined, + line: parsed.line, + column: parsed.column + }; + }).filter(frame => !!frame); + return { stack: frames }; +} + const wrappedMatchers: any = {}; for (const matcherName in matchers) wrappedMatchers[matcherName] = wrap(matcherName, matchers[matcherName]); diff --git a/src/test/index.ts b/src/test/index.ts index 6c10099758..a73a062fb2 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -20,6 +20,7 @@ import type { LaunchOptions, BrowserContextOptions, Page, BrowserContext, Browse import type { TestType, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions } from '../../types/test'; import { rootTestType } from './testType'; import { createGuid, removeFolders } from '../utils/utils'; +import { TestInfoImpl } from './types'; export { expect } from './expect'; export const _baseTest: TestType<{}, {}> = rootTestType.test; @@ -197,8 +198,10 @@ export const test = _baseTest.extend({ else await context.tracing.stop(); (context as any)._csi = { - onApiCall: (name: string) => { - return (testInfo as any)._addStep('pw:api', name); + onApiCall: (stackTrace: ParsedStackTrace) => { + if ((testInfo as TestInfoImpl)._currentSteps().some(step => step.category === 'pw:api' || step.category === 'expect')) + return () => {}; + return (testInfo as TestInfoImpl)._addStep('pw:api', stackTrace.apiName, { stack: stackTrace.frames }); }, }; }; @@ -223,7 +226,6 @@ export const test = _baseTest.extend({ }; // 1. Setup instrumentation and process existing contexts. - const oldOnDidCreateContext = (_browserType as any)._onDidCreateContext; (_browserType as any)._onDidCreateContext = onDidCreateContext; (_browserType as any)._onWillCloseContext = onWillCloseContext; (_browserType as any)._defaultContextOptions = _combinedContextOptions; @@ -257,7 +259,7 @@ export const test = _baseTest.extend({ // 4. Cleanup instrumentation. const leftoverContexts = Array.from((_browserType as any)._contexts) as BrowserContext[]; - (_browserType as any)._onDidCreateContext = oldOnDidCreateContext; + (_browserType as any)._onDidCreateContext = undefined; (_browserType as any)._onWillCloseContext = undefined; (_browserType as any)._defaultContextOptions = undefined; leftoverContexts.forEach(context => (context as any)._csi = undefined); @@ -346,11 +348,11 @@ export const test = _baseTest.extend({ }); export default test; -function formatPendingCalls(calls: ProtocolCall[]) { +function formatPendingCalls(calls: ParsedStackTrace[]) { if (!calls.length) return ''; return 'Pending operations:\n' + calls.map(call => { - const frame = call.stack && call.stack[0] ? formatStackFrame(call.stack[0]) : ''; + const frame = call.frames && call.frames[0] ? formatStackFrame(call.frames[0]) : ''; return ` - ${call.apiName} at ${frame}\n`; }).join('') + '\n'; } @@ -367,7 +369,8 @@ type StackFrame = { function?: string, }; -type ProtocolCall = { - stack?: StackFrame[], - apiName?: string, +type ParsedStackTrace = { + frames: StackFrame[]; + frameTexts: string[]; + apiName: string; }; diff --git a/src/test/ipc.ts b/src/test/ipc.ts index ccdb7e7ca9..619e5e3a51 100644 --- a/src/test/ipc.ts +++ b/src/test/ipc.ts @@ -52,6 +52,7 @@ export type StepBeginPayload = { title: string; category: string; wallTime: number; // milliseconds since unix epoch + data: { [key: string]: any }; }; export type StepEndPayload = { diff --git a/src/test/reporters/base.ts b/src/test/reporters/base.ts index 13c0aa954c..c28906cb93 100644 --- a/src/test/reporters/base.ts +++ b/src/test/reporters/base.ts @@ -218,7 +218,7 @@ function formatTestHeader(config: FullConfig, test: TestCase, indent: string, in return colors.red(pad(header, '=')); } -function formatError(error: TestError, file?: string) { +export function formatError(error: TestError, file?: string) { const stack = error.stack; const tokens = []; if (stack) { diff --git a/src/test/reporters/html.ts b/src/test/reporters/html.ts index 928dd28052..06ad68a93d 100644 --- a/src/test/reporters/html.ts +++ b/src/test/reporters/html.ts @@ -18,11 +18,12 @@ import fs from 'fs'; import path from 'path'; import { FullConfig, Location, Suite, TestCase, TestError, TestResult, TestStatus, TestStep } from '../../../types/testReporter'; import { calculateSha1 } from '../../utils/utils'; -import { formatResultFailure } from './base'; +import { formatError, formatResultFailure } from './base'; import { serializePatterns, toPosixPath } from './json'; export type JsonStats = { expected: number, unexpected: number, flaky: number, skipped: number }; export type JsonLocation = Location; +export type JsonStackFrame = { file: string, line: number, column: number, sha1?: string }; export type JsonConfig = Omit & { projects: { @@ -100,18 +101,23 @@ export type JsonTestStep = { startTime: string; duration: number; error?: TestError; + failureSnippet?: string; steps: JsonTestStep[]; + preview?: string; + stack?: JsonStackFrame[]; }; class HtmlReporter { private _reportFolder: string; private _resourcesFolder: string; + private _sourceProcessor: SourceProcessor; private config!: FullConfig; private suite!: Suite; constructor() { this._reportFolder = path.resolve(process.cwd(), process.env[`PLAYWRIGHT_HTML_REPORT`] || 'playwright-report'); this._resourcesFolder = path.join(this._reportFolder, 'resources'); + this._sourceProcessor = new SourceProcessor(this._resourcesFolder); fs.mkdirSync(this._resourcesFolder, { recursive: true }); const appFolder = path.join(__dirname, '..', '..', 'web', 'htmlReport'); for (const file of fs.readdirSync(appFolder)) @@ -147,7 +153,7 @@ class HtmlReporter { }) }, stats, - suites: await Promise.all(this.suite.suites.map(s => this._serializeSuite(s))) + suites: this.suite.suites.map(s => this._serializeSuite(s)) }; fs.writeFileSync(path.join(this._reportFolder, 'report.json'), JSON.stringify(output)); } @@ -162,16 +168,16 @@ class HtmlReporter { }; } - private async _serializeSuite(suite: Suite): Promise { + private _serializeSuite(suite: Suite): JsonSuite { return { title: suite.title, location: this._relativeLocation(suite.location), - suites: await Promise.all(suite.suites.map(s => this._serializeSuite(s))), - tests: await Promise.all(suite.tests.map(t => this._serializeTest(t))), + suites: suite.suites.map(s => this._serializeSuite(s)), + tests: suite.tests.map(t => this._serializeTest(t)), }; } - private async _serializeTest(test: TestCase): Promise { + private _serializeTest(test: TestCase): JsonTestCase { const testId = calculateSha1(test.titlePath().join('|')); return { testId, @@ -183,11 +189,11 @@ class HtmlReporter { retries: test.retries, ok: test.ok(), outcome: test.outcome(), - results: await Promise.all(test.results.map(r => this._serializeResult(testId, test, r))), + results: test.results.map(r => this._serializeResult(testId, test, r)), }; } - private async _serializeResult(testId: string, test: TestCase, result: TestResult): Promise { + private _serializeResult(testId: string, test: TestCase, result: TestResult): JsonTestResult { return { retry: result.retry, workerIndex: result.workerIndex, @@ -196,14 +202,29 @@ class HtmlReporter { status: result.status, error: result.error, failureSnippet: formatResultFailure(test, result, '').join('') || undefined, - attachments: await this._createAttachments(testId, result), + attachments: this._createAttachments(testId, result), stdout: result.stdout, stderr: result.stderr, - steps: serializeSteps(result.steps) + steps: this._serializeSteps(test, result.steps) }; } - private async _createAttachments(testId: string, result: TestResult): Promise { + private _serializeSteps(test: TestCase, steps: TestStep[]): JsonTestStep[] { + return steps.map(step => { + return { + title: step.title, + category: step.category, + startTime: step.startTime.toISOString(), + duration: step.duration, + error: step.error, + steps: this._serializeSteps(test, step.steps), + failureSnippet: step.error ? formatError(step.error, test.location.file) : undefined, + ...this._sourceProcessor.processStackTrace(step.data.stack), + }; + }); + } + + private _createAttachments(testId: string, result: TestResult): JsonAttachment[] { const attachments: JsonAttachment[] = []; for (const attachment of result.attachments) { if (attachment.path) { @@ -251,19 +272,6 @@ class HtmlReporter { } } -function serializeSteps(steps: TestStep[]): JsonTestStep[] { - return steps.map(step => { - return { - title: step.title, - category: step.category, - startTime: step.startTime.toISOString(), - duration: step.duration, - error: step.error, - steps: serializeSteps(step.steps), - }; - }); -} - function isTextAttachment(contentType: string) { if (contentType.startsWith('text/')) return true; @@ -272,4 +280,188 @@ function isTextAttachment(contentType: string) { return false; } +type SourceFile = { text: string, lineStart: number[] }; +class SourceProcessor { + private sourceCache = new Map(); + private sha1Cache = new Map(); + private resourcesFolder: string; + + constructor(resourcesFolder: string) { + this.resourcesFolder = resourcesFolder; + } + + processStackTrace(stack: { file?: string, line?: number, column?: number }[] | undefined) { + stack = stack || []; + const frames: JsonStackFrame[] = []; + let preview: string | undefined; + for (const frame of stack) { + if (!frame.file || !frame.line || !frame.column) + continue; + const sha1 = this.copySourceFile(frame.file); + const jsonFrame = { file: frame.file, line: frame.line, column: frame.column, sha1 }; + frames.push(jsonFrame); + if (frame === stack[0]) + preview = this.readPreview(jsonFrame); + } + return { stack: frames, preview }; + } + + private copySourceFile(file: string): string | undefined { + let sha1: string | undefined; + if (this.sha1Cache.has(file)) { + sha1 = this.sha1Cache.get(file); + } else { + if (fs.existsSync(file)) { + sha1 = calculateSha1(file) + path.extname(file); + fs.copyFileSync(file, path.join(this.resourcesFolder, sha1)); + } + this.sha1Cache.set(file, sha1); + } + return sha1; + } + + private readSourceFile(file: string): SourceFile | undefined { + let source: { text: string, lineStart: number[] } | undefined; + if (this.sourceCache.has(file)) { + source = this.sourceCache.get(file); + } else { + try { + const text = fs.readFileSync(file, 'utf8'); + const lines = text.split('\n'); + const lineStart = [0]; + for (const line of lines) + lineStart.push(lineStart[lineStart.length - 1] + line.length + 1); + source = { text, lineStart }; + } catch (e) { + } + this.sourceCache.set(file, source); + } + return source; + } + + private readPreview(frame: JsonStackFrame): string | undefined { + const source = this.readSourceFile(frame.file); + if (source === undefined) + return; + + if (frame.line - 1 >= source.lineStart.length) + return; + + const text = source.text; + const pos = source.lineStart[frame.line - 1] + frame.column - 1; + return new SourceParser(text).readPreview(pos); + } +} + +const kMaxPreviewLength = 100; +class SourceParser { + private text: string; + private pos!: number; + + constructor(text: string) { + this.text = text; + } + + readPreview(pos: number) { + let prefix = ''; + + this.pos = pos - 1; + while (true) { + if (this.pos < pos - kMaxPreviewLength) + return; + + this.skipWhiteSpace(-1); + if (this.text[this.pos] !== '.') + break; + + prefix = '.' + prefix; + this.pos--; + this.skipWhiteSpace(-1); + + while (this.text[this.pos] === ')' || this.text[this.pos] === ']') { + const expr = this.readBalancedExpr(-1, this.text[this.pos] === ')' ? '(' : '[', this.text[this.pos]); + if (expr === undefined) + return; + prefix = expr + prefix; + this.skipWhiteSpace(-1); + } + + const id = this.readId(-1); + if (id !== undefined) + prefix = id + prefix; + } + + if (prefix.length > kMaxPreviewLength) + return; + + this.pos = pos; + const suffix = this.readBalancedExpr(+1, ')', '('); + if (suffix === undefined) + return; + return prefix + suffix; + } + + private skipWhiteSpace(dir: number) { + while (this.pos >= 0 && this.pos < this.text.length && /[\s\r\n]/.test(this.text[this.pos])) + this.pos += dir; + } + + private readId(dir: number): string | undefined { + const start = this.pos; + while (this.pos >= 0 && this.pos < this.text.length && /[\p{L}0-9_]/u.test(this.text[this.pos])) + this.pos += dir; + if (this.pos === start) + return; + return dir === 1 ? this.text.substring(start, this.pos) : this.text.substring(this.pos + 1, start + 1); + } + + private readBalancedExpr(dir: number, stopChar: string, stopPair: string): string | undefined { + let result = ''; + let quote = ''; + let lastWhiteSpace = false; + let balance = 0; + const start = this.pos; + while (this.pos >= 0 && this.pos < this.text.length) { + if (this.pos < start - kMaxPreviewLength || this.pos > start + kMaxPreviewLength) + return; + let whiteSpace = false; + if (quote) { + whiteSpace = false; + if (dir === 1 && this.text[this.pos] === '\\') { + result = result + this.text[this.pos] + this.text[this.pos + 1]; + this.pos += 2; + continue; + } + if (dir === -1 && this.text[this.pos - 1] === '\\') { + result = this.text[this.pos - 1] + this.text[this.pos] + result; + this.pos -= 2; + continue; + } + if (this.text[this.pos] === quote) + quote = ''; + } else { + if (this.text[this.pos] === '\'' || this.text[this.pos] === '"' || this.text[this.pos] === '`') { + quote = this.text[this.pos]; + } else if (this.text[this.pos] === stopPair) { + balance++; + } else if (this.text[this.pos] === stopChar) { + balance--; + if (!balance) { + this.pos += dir; + result = dir === 1 ? result + stopChar : stopChar + result; + break; + } + } + whiteSpace = /[\s\r\n]/.test(this.text[this.pos]); + } + const char = whiteSpace ? ' ' : this.text[this.pos]; + if (!lastWhiteSpace || !whiteSpace) + result = dir === 1 ? result + char : char + result; + lastWhiteSpace = whiteSpace; + this.pos += dir; + } + return result; + } +} + export default HtmlReporter; diff --git a/src/test/types.ts b/src/test/types.ts index aade551240..de77cc2328 100644 --- a/src/test/types.ts +++ b/src/test/types.ts @@ -29,5 +29,6 @@ export type CompleteStepCallback = (error?: Error | TestError) => void; export interface TestInfoImpl extends TestInfo { _testFinished: Promise; - _addStep: (category: string, title: string) => CompleteStepCallback; + _addStep: (category: string, title: string, data?: { [key: string]: any }) => CompleteStepCallback; + _currentSteps(): { category: string }[]; } diff --git a/src/test/workerRunner.ts b/src/test/workerRunner.ts index 75c258c176..0be035f817 100644 --- a/src/test/workerRunner.ts +++ b/src/test/workerRunner.ts @@ -219,6 +219,7 @@ export class WorkerRunner extends EventEmitter { let testFinishedCallback = () => {}; let lastStepId = 0; + const stepStack = new Set<{ category: string }>(); const testInfo: TestInfoImpl = { workerIndex: this._params.workerIndex, project: this._project.config, @@ -267,17 +268,19 @@ export class WorkerRunner extends EventEmitter { deadlineRunner.updateDeadline(deadline()); }, _testFinished: new Promise(f => testFinishedCallback = f), - _addStep: (category: string, title: string) => { + _addStep: (category: string, title: string, data: { [key: string]: any } = {}) => { const stepId = `${category}@${title}@${++lastStepId}`; - const payload: StepBeginPayload = { + const step: StepBeginPayload = { testId, stepId, category, title, - wallTime: Date.now() + wallTime: Date.now(), + data, }; + stepStack.add(step); if (reportEvents) - this.emit('stepBegin', payload); + this.emit('stepBegin', step); let callbackHandled = false; return (error?: Error | TestError) => { if (callbackHandled) @@ -285,6 +288,7 @@ export class WorkerRunner extends EventEmitter { callbackHandled = true; if (error instanceof Error) error = serializeError(error); + stepStack.delete(step); const payload: StepEndPayload = { testId, stepId, @@ -295,6 +299,7 @@ export class WorkerRunner extends EventEmitter { this.emit('stepEnd', payload); }; }, + _currentSteps: () => [...stepStack], }; // Inherit test.setTimeout() from parent suites. diff --git a/src/web/components/splitView.css b/src/web/components/splitView.css index 0f2ccef8c2..5114112e0e 100644 --- a/src/web/components/splitView.css +++ b/src/web/components/splitView.css @@ -46,11 +46,11 @@ flex: none; } -.split-view.vertical > .split-view-sidebar { +.split-view.vertical:not(.sidebar-first) > .split-view-sidebar { border-top: 1px solid #ddd; } -.split-view.horizontal > .split-view-sidebar { +.split-view.horizontal:not(.sidebar-first) > .split-view-sidebar { border-left: 1px solid #ddd; } diff --git a/src/web/htmlReport/htmlReport.css b/src/web/htmlReport/htmlReport.css index 197d9efefe..bbd73b5d6c 100644 --- a/src/web/htmlReport/htmlReport.css +++ b/src/web/htmlReport/htmlReport.css @@ -14,25 +14,6 @@ limitations under the License. */ -.sidebar { - line-height: 24px; - color: #fff6; - background-color: #2c2c2c; - font-size: 14px; - flex: 0 0 80px; -} - -.sidebar > div { - padding: 12px; - cursor: pointer; - display: flex; - justify-content: center; -} - -.sidebar > div.selected { - color: white; -} - .suite-tree { line-height: 18px; flex: auto; @@ -76,10 +57,11 @@ white-space: pre; font-family: monospace; background: #000; - color: white; + color: white; padding: 5px; overflow: auto; margin: 20px 0; + flex: none; } .status-icon { @@ -104,8 +86,8 @@ } .test-result { - padding: 10px; flex: auto; + display: flex; } .test-overview-title { @@ -114,14 +96,6 @@ flex: none; } -.test-overview-property { - display: flex; - flex-direction: row; - align-items: center; - max-width: 450px; - line-height: 24px; -} - .awesome { font-size: 24px; display: flex; @@ -162,3 +136,16 @@ border: 1px solid #ccc; margin-left: 24px; } + +.steps-tree .tree-item-title:not(.selected):hover { + background-color: #e8e8e8; +} + +.steps-tree .tree-item-title.selected { + background-color: #0060c0; + color: white; +} + +.steps-tree .tree-item-title.selected * { + color: white !important; +} diff --git a/src/web/htmlReport/htmlReport.tsx b/src/web/htmlReport/htmlReport.tsx index 7154b251a5..576bb255b2 100644 --- a/src/web/htmlReport/htmlReport.tsx +++ b/src/web/htmlReport/htmlReport.tsx @@ -22,6 +22,7 @@ import { TabbedPane } from '../traceViewer/ui/tabbedPane'; import ansi2html from 'ansi-to-html'; import type { JsonAttachment, JsonLocation, JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from '../../test/reporters/html'; import { msToString } from '../uiUtils'; +import { Source, SourceProps } from '../components/source'; type Filter = 'Failing' | 'All'; @@ -32,7 +33,7 @@ export const Report: React.FC = () => { React.useEffect(() => { (async () => { const result = await fetch('report.json'); - const json = await result.json(); + const json = (await result.json()) as JsonReport; setReport(json); })(); }, []); @@ -50,10 +51,17 @@ export const Report: React.FC = () => { }, [report]); return
- - +
+
{ + (['Failing', 'All'] as Filter[]).map(item => { + const selected = item === filter; + return
{ + setFilter(item); + }}>{item}
; + }) + }
{filter === 'All' && report?.suites.map((s, i) => )} {filter === 'Failing' && !!unexpectedTestCount && report?.suites.map((s, i) => { const hasUnexpectedOutcomes = !!unexpectedTests.get(s)?.length; @@ -65,22 +73,6 @@ export const Report: React.FC = () => {
; }; -const FilterView: React.FC<{ - filter: Filter, - setFilter: (filter: Filter) => void -}> = ({ filter, setFilter }) => { - return
- { - (['Failing', 'All'] as Filter[]).map(item => { - const selected = item === filter; - return
{ - setFilter(item); - }}>{item}
; - }) - } -
; -}; - const ProjectTreeItem: React.FC<{ suite?: JsonSuite; selectedTest?: JsonTestCase, @@ -157,6 +149,7 @@ const TestCaseView: React.FC<{ }> = ({ test }) => { const [selectedTab, setSelectedTab] = React.useState('0'); return
+ { !test &&
} { test && ({ id: String(index), @@ -169,6 +162,28 @@ const TestCaseView: React.FC<{ const TestOverview: React.FC<{ test: JsonTestCase, result: JsonTestResult, +}> = ({ test, result }) => { + const [selectedStep, setSelectedStep] = React.useState(); + return
+ + {!selectedStep && } + {!!selectedStep && } +
+ {renderLocation(test.location, true)} › {test?.title} ({msToString(result.duration)})
} + depth={0} + key='test' + onClick={() => setSelectedStep(undefined)}> + + {result.steps.map((step, i) => )} +
+ +
; +}; + +const TestResultDetails: React.FC<{ + test: JsonTestCase, + result: JsonTestResult, }> = ({ test, result }) => { const { screenshots, video, attachmentsMap } = React.useMemo(() => { const attachmentsMap = new Map(); @@ -178,11 +193,9 @@ const TestOverview: React.FC<{ attachmentsMap.set(a.name, a); return { attachmentsMap, screenshots, video }; }, [ result ]); - return
-
{test?.title}
-
{renderLocation(test.location, true)}
{msToString(result.duration)}
- {result.failureSnippet &&
} - {result.steps.map((step, i) => )} + return
+ {result.failureSnippet &&
Test error
} + {result.failureSnippet &&
} {attachmentsMap.has('expected') && attachmentsMap.has('actual') && } {!!screenshots.length &&
Screenshots
} {screenshots.map(a =>
)} @@ -194,22 +207,50 @@ const TestOverview: React.FC<{
)} {!!result.attachments &&
Attachments
} {result.attachments.map(a => )} -
+
; +}; + +const TestStepDetails: React.FC<{ + test: JsonTestCase, + result: JsonTestResult, + step: JsonTestStep, +}> = ({ test, result, step }) => { + const [source, setSource] = React.useState({ text: '', language: 'javascript' }); + React.useEffect(() => { + (async () => { + const frame = step.stack?.[0]; + if (!frame || !frame.sha1) + return; + try { + const response = await fetch('resources/' + frame.sha1); + const text = await response.text(); + setSource({ text, language: 'javascript', highlight: [{ line: frame.line, type: 'paused' }], revealLine: frame.line }); + } catch (e) { + setSource({ text: '', language: 'javascript' }); + } + })(); + }, [step]); + return
+ {step.failureSnippet &&
Step error
} + {step.failureSnippet &&
} +
; }; const StepTreeItem: React.FC<{ step: JsonTestStep; depth: number, -}> = ({ step, depth }) => { + selectedStep?: JsonTestStep, + setSelectedStep: (step: JsonTestStep | undefined) => void; +}> = ({ step, depth, selectedStep, setSelectedStep }) => { return {testStepStatusIcon(step)} - {step.title} + {step.preview || step.title}
{msToString(step.duration)}
} loadChildren={step.steps.length ? () => { - return step.steps.map((s, i) => ); - } : undefined} depth={depth}>; + return step.steps.map((s, i) => ); + } : undefined} depth={depth} selected={step === selectedStep} onClick={() => setSelectedStep(step)}>; }; export const ImageDiff: React.FunctionComponent<{ @@ -341,3 +382,7 @@ const ansiColors = { 14: '#5FF', 15: '#FFF' }; + +function escapeHTML(text: string): string { + return text.replace(/[&"<>]/g, c => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c]!)); +} diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index e2b7acda24..00160a1070 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.ts @@ -45,6 +45,7 @@ class Reporter { startTime: undefined, duration: undefined, parent: undefined, + data: undefined, steps: step.steps.length ? step.steps.map(s => this.distillStep(s)) : undefined, }; } @@ -228,9 +229,7 @@ test('should report expect steps', async ({ runInlineTest }) => { `%% end {\"title\":\"browserContext.newPage\",\"category\":\"pw:api\"}`, `%% end {\"title\":\"Before Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"browserContext.newPage\",\"category\":\"pw:api\"}]}`, `%% begin {\"title\":\"expect.not.toHaveTitle\",\"category\":\"expect\"}`, - `%% begin {\"title\":\"page.title\",\"category\":\"pw:api\"}`, - `%% end {\"title\":\"page.title\",\"category\":\"pw:api\"}`, - `%% end {\"title\":\"expect.not.toHaveTitle\",\"category\":\"expect\",\"steps\":[{\"title\":\"page.title\",\"category\":\"pw:api\"}]}`, + `%% end {\"title\":\"expect.not.toHaveTitle\",\"category\":\"expect\"}`, `%% begin {\"title\":\"After Hooks\",\"category\":\"hook\"}`, `%% begin {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`, `%% end {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`, @@ -408,6 +407,7 @@ test('should report api step hierarchy', async ({ runInlineTest }) => { startTime: undefined, duration: undefined, parent: undefined, + data: undefined, steps: step.steps.length ? step.steps.map(s => this.distillStep(s)) : undefined, }; } @@ -506,6 +506,51 @@ test('should report api step hierarchy', async ({ runInlineTest }) => { ]); }); +test('should report expect and pw:api stacks', async ({ runInlineTest }, testInfo) => { + const expectReporterJS = ` + class Reporter { + stepDetails(step) { + if (!step.data.stack || !step.data.stack[0]) + return step.title + ' '; + const frame = step.data.stack[0] + return step.title + ' ' + frame.file + ':' + frame.line + ':' + frame.column; + } + onStepBegin(test, result, step) { + console.log('%%%% begin', this.stepDetails(step)); + } + onStepEnd(test, result, step) { + console.log('%%%% end', this.stepDetails(step)); + } + } + module.exports = Reporter; + `; + + const result = await runInlineTest({ + 'reporter.ts': expectReporterJS, + 'playwright.config.ts': ` + module.exports = { + reporter: './reporter', + }; + `, + 'a.test.ts': ` + const { test } = pwt; + test('pass', async ({ page }) => { + await page.setContent('hello'); + expect(1).toBe(1); + await expect(page).toHaveTitle('hello'); + }); + ` + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain(`%% begin page.setContent ${testInfo.outputPath('a.test.ts:7:20')}`); + expect(result.output).toContain(`%% end page.setContent ${testInfo.outputPath('a.test.ts:7:20')}`); + expect(result.output).toContain(`%% begin expect.toBe ${testInfo.outputPath('a.test.ts:8:19')}`); + expect(result.output).toContain(`%% end expect.toBe ${testInfo.outputPath('a.test.ts:8:19')}`); + expect(result.output).toContain(`%% begin expect.toHaveTitle ${testInfo.outputPath('a.test.ts:9:28')}`); + expect(result.output).toContain(`%% end expect.toHaveTitle ${testInfo.outputPath('a.test.ts:9:28')}`); +}); + function stripEscapedAscii(str: string) { return str.replace(/\\u00[a-z0-9][a-z0-9]\[[^m]+m/g, ''); } diff --git a/types/testReporter.d.ts b/types/testReporter.d.ts index b0ecd0b5f2..b6269d793a 100644 --- a/types/testReporter.d.ts +++ b/types/testReporter.d.ts @@ -258,6 +258,7 @@ export interface TestStep { * List of steps inside this step. */ steps: TestStep[]; + data: { [key: string]: any }; } /** diff --git a/utils/generate_types/overrides-testReporter.d.ts b/utils/generate_types/overrides-testReporter.d.ts index 41f114aa98..94bc2824d1 100644 --- a/utils/generate_types/overrides-testReporter.d.ts +++ b/utils/generate_types/overrides-testReporter.d.ts @@ -67,6 +67,7 @@ export interface TestStep { duration: number; error?: TestError; steps: TestStep[]; + data: { [key: string]: any }; } /**