From e2b092c1a03d4ac303f73396a24aa38f15c29968 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 3 Sep 2021 13:08:17 -0700 Subject: [PATCH] feat(html reporter): show log for pw:api and expect steps (#8692) --- src/client/channelOwner.ts | 17 ++++--- src/client/connection.ts | 13 +++-- src/client/types.ts | 5 +- src/dispatchers/dispatcher.ts | 8 +-- src/dispatchers/networkDispatchers.ts | 11 +++-- src/dispatchers/playwrightDispatcher.ts | 10 ++-- src/protocol/channels.ts | 1 + src/protocol/protocol.yml | 1 + src/protocol/validator.ts | 1 + src/test/dispatcher.ts | 3 +- src/test/expect.ts | 8 +-- src/test/fixtures.ts | 6 +-- src/test/index.ts | 10 ++-- src/test/ipc.ts | 2 +- src/test/reporters/html.ts | 2 + src/test/testType.ts | 6 +-- src/test/types.ts | 10 ++-- src/test/workerRunner.ts | 65 +++++++++++++------------ src/web/htmlReport/htmlReport.css | 6 +++ src/web/htmlReport/htmlReport.tsx | 2 +- tests/config/browserTest.ts | 5 +- tests/playwright-test/reporter.spec.ts | 30 ++++++------ 22 files changed, 129 insertions(+), 93 deletions(-) diff --git a/src/client/channelOwner.ts b/src/client/channelOwner.ts index e2e5a72e93..eaaee561f2 100644 --- a/src/client/channelOwner.ts +++ b/src/client/channelOwner.ts @@ -21,7 +21,7 @@ import { debugLogger } from '../utils/debugLogger'; import { captureStackTrace, ParsedStackTrace } from '../utils/stackTrace'; import { isUnderTest } from '../utils/utils'; import type { Connection } from './connection'; -import type { ClientSideInstrumentation, Logger } from './types'; +import type { ClientSideInstrumentation, LogContainer, Logger } from './types'; export abstract class ChannelOwner extends EventEmitter { protected _connection: Connection; @@ -72,15 +72,15 @@ export abstract class ChannelOwner { if (prop === 'debugScopeState') - return (params: any) => this._connection.sendMessageToServer(this, prop, params, stackTrace); + return (params: any) => this._connection.sendMessageToServer(this, prop, params, stackTrace, logContainer); if (typeof prop === 'string') { const validator = scheme[paramsName(this._type, prop)]; if (validator) - return (params: any) => this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace); + return (params: any) => this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace, logContainer); } return obj[prop]; }, @@ -93,25 +93,26 @@ export abstract class ChannelOwner = this; while (!ancestorWithCSI._csi && ancestorWithCSI._parent) ancestorWithCSI = ancestorWithCSI._parent; - let csiCallback: ((e?: Error) => void) | undefined; + let csiCallback: ((log: string[], e?: Error) => void) | undefined; + const logContainer = { log: [] }; try { logApiCall(logger, `=> ${apiName} started`); csiCallback = ancestorWithCSI._csi?.onApiCall(stackTrace); + const channel = this._createChannel({}, stackTrace, csiCallback ? logContainer : undefined); const result = await func(channel as any, stackTrace); - csiCallback?.(); + csiCallback?.(logContainer.log); logApiCall(logger, `<= ${apiName} succeeded`); return result; } catch (e) { const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n\n' + e.stack : ''; e.message = apiName + ': ' + e.message; e.stack = e.message + '\n' + frameTexts.join('\n') + innerError; - csiCallback?.(e); + csiCallback?.(logContainer.log, e); logApiCall(logger, `<= ${apiName} failed`); throw e; } diff --git a/src/client/connection.ts b/src/client/connection.ts index 500581d041..f1411f3205 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -39,6 +39,7 @@ import { ParsedStackTrace } from '../utils/stackTrace'; import { Artifact } from './artifact'; import { EventEmitter } from 'events'; import { JsonPipe } from './jsonPipe'; +import type { LogContainer } from './types'; class Root extends ChannelOwner { constructor(connection: Connection) { @@ -57,7 +58,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, stackTrace: ParsedStackTrace }>(); + private _callbacks = new Map void, reject: (a: Error) => void, stackTrace: ParsedStackTrace, logContainer: LogContainer | undefined }>(); private _rootObject: Root; private _disconnectedErrorMessage: string | undefined; private _onClose?: () => void; @@ -80,7 +81,7 @@ export class Connection extends EventEmitter { return this._objects.get(guid)!; } - async sendMessageToServer(object: ChannelOwner, method: string, params: any, maybeStackTrace: ParsedStackTrace | null): Promise { + async sendMessageToServer(object: ChannelOwner, method: string, params: any, maybeStackTrace: ParsedStackTrace | null, logContainer?: LogContainer): Promise { const guid = object._guid; const stackTrace = maybeStackTrace || { frameTexts: [], frames: [], apiName: '' }; const { frames, apiName } = stackTrace; @@ -89,12 +90,12 @@ export class Connection extends EventEmitter { const converted = { id, guid, method, params }; // Do not include metadata in debug logs to avoid noise. debugLogger.log('channel:command', converted); - const metadata: channels.Metadata = { stack: frames, apiName }; + const metadata: channels.Metadata = { stack: frames, apiName, collectLogs: logContainer ? true : undefined }; this.onmessage({ ...converted, metadata }); if (this._disconnectedErrorMessage) throw new Error(this._disconnectedErrorMessage); - return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, stackTrace })); + return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, stackTrace, logContainer })); } _debugScopeState(): any { @@ -102,13 +103,15 @@ export class Connection extends EventEmitter { } dispatch(message: object) { - const { id, guid, method, params, result, error } = message as any; + const { id, guid, method, params, result, error, log } = message as any; if (id) { debugLogger.log('channel:response', message); const callback = this._callbacks.get(id); if (!callback) throw new Error(`Cannot find command to respond: ${id}`); this._callbacks.delete(id); + if (log && callback.logContainer) + callback.logContainer.log.push(...log); if (error) callback.reject(parseError(error)); else diff --git a/src/client/types.ts b/src/client/types.ts index 0bf730888f..a4ade36d9a 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -27,8 +27,11 @@ export interface Logger { } export interface ClientSideInstrumentation { - onApiCall(stackTrace: ParsedStackTrace): (error?: Error) => void; + onApiCall(stackTrace: ParsedStackTrace): ((log: string[], error?: Error) => void) | undefined; } +export type LogContainer = { + log: string[]; +}; export type StrictOptions = { strict?: boolean }; export type Headers = { [key: string]: string }; diff --git a/src/dispatchers/dispatcher.ts b/src/dispatchers/dispatcher.ts index 7c8c5f9992..5552e2ebe8 100644 --- a/src/dispatchers/dispatcher.ts +++ b/src/dispatchers/dispatcher.ts @@ -222,7 +222,8 @@ export class DispatcherConnection { const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined; const callMetadata: CallMetadata = { id: `call@${id}`, - ...validMetadata, + stack: validMetadata.stack, + apiName: validMetadata.apiName, objectId: sdkObject?.guid, pageId: sdkObject?.attribution?.page?.guid, frameId: sdkObject?.attribution?.frame?.guid, @@ -277,10 +278,11 @@ export class DispatcherConnection { await sdkObject?.instrumentation.onAfterCall(sdkObject, callMetadata); } + const log = validMetadata.collectLogs ? callMetadata.log : undefined; if (callMetadata.error) - this.onmessage({ id, error: error }); + this.onmessage({ id, error: error, log }); else - this.onmessage({ id, result: callMetadata.result }); + this.onmessage({ id, result: callMetadata.result, log }); } private _replaceDispatchersWithGuids(payload: any): any { diff --git a/src/dispatchers/networkDispatchers.ts b/src/dispatchers/networkDispatchers.ts index 4c1b081bca..db3fa616d3 100644 --- a/src/dispatchers/networkDispatchers.ts +++ b/src/dispatchers/networkDispatchers.ts @@ -18,6 +18,7 @@ import { Request, Response, Route, WebSocket } from '../server/network'; import * as channels from '../protocol/channels'; import { Dispatcher, DispatcherScope, lookupNullableDispatcher, existingDispatcher } from './dispatcher'; import { FrameDispatcher } from './frameDispatcher'; +import { CallMetadata } from '../server/instrumentation'; export class RequestDispatcher extends Dispatcher implements channels.RequestChannel { @@ -84,15 +85,15 @@ export class ResponseDispatcher extends Dispatcher { + async rawRequestHeaders(params?: channels.ResponseRawRequestHeadersParams): Promise { return { headers: await this._object.rawRequestHeaders() }; } - async rawResponseHeaders(params?: channels.ResponseRawResponseHeadersParams, metadata?: channels.Metadata): Promise { + async rawResponseHeaders(params?: channels.ResponseRawResponseHeadersParams): Promise { return { headers: await this._object.rawResponseHeaders() }; } - async sizes(params?: channels.ResponseSizesParams, metadata?: channels.Metadata): Promise { + async sizes(params?: channels.ResponseSizesParams): Promise { return { sizes: await this._object.sizes() }; } } @@ -111,11 +112,11 @@ export class RouteDispatcher extends Dispatcher { + async responseBody(params?: channels.RouteResponseBodyParams): Promise { return { binary: (await this._object.responseBody()).toString('base64') }; } - async continue(params: channels.RouteContinueParams, metadata?: channels.Metadata): Promise { + async continue(params: channels.RouteContinueParams, metadata: CallMetadata): Promise { const response = await this._object.continue({ url: params.url, method: params.method, diff --git a/src/dispatchers/playwrightDispatcher.ts b/src/dispatchers/playwrightDispatcher.ts index c075ba7457..e80b5869f3 100644 --- a/src/dispatchers/playwrightDispatcher.ts +++ b/src/dispatchers/playwrightDispatcher.ts @@ -52,23 +52,23 @@ export class PlaywrightDispatcher extends Dispatcher { + async socksConnected(params: channels.PlaywrightSocksConnectedParams): Promise { this._socksProxy?.socketConnected(params); } - async socksFailed(params: channels.PlaywrightSocksFailedParams, metadata?: channels.Metadata): Promise { + async socksFailed(params: channels.PlaywrightSocksFailedParams): Promise { this._socksProxy?.socketFailed(params); } - async socksData(params: channels.PlaywrightSocksDataParams, metadata?: channels.Metadata): Promise { + async socksData(params: channels.PlaywrightSocksDataParams): Promise { this._socksProxy?.sendSocketData(params); } - async socksError(params: channels.PlaywrightSocksErrorParams, metadata?: channels.Metadata): Promise { + async socksError(params: channels.PlaywrightSocksErrorParams): Promise { this._socksProxy?.sendSocketError(params); } - async socksEnd(params: channels.PlaywrightSocksEndParams, metadata?: channels.Metadata): Promise { + async socksEnd(params: channels.PlaywrightSocksEndParams): Promise { this._socksProxy?.sendSocketEnd(params); } } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 8f97c1a9cb..348a87b4da 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -33,6 +33,7 @@ export type StackFrame = { export type Metadata = { stack?: StackFrame[], apiName?: string, + collectLogs?: boolean, }; export type Point = { diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 7bd986bfcb..6f37f99b91 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -29,6 +29,7 @@ Metadata: type: array? items: StackFrame apiName: string? + collectLogs: boolean? Point: diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index df63e87a2c..f31055505d 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -42,6 +42,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.Metadata = tObject({ stack: tOptional(tArray(tType('StackFrame'))), apiName: tOptional(tString), + collectLogs: tOptional(tBoolean), }); scheme.Point = tObject({ x: tNumber, diff --git a/src/test/dispatcher.ts b/src/test/dispatcher.ts index 77f48acb70..718bd0ddcf 100644 --- a/src/test/dispatcher.ts +++ b/src/test/dispatcher.ts @@ -309,7 +309,7 @@ export class Dispatcher { startTime: new Date(params.wallTime), duration: 0, steps: [], - data: params.data, + data: {}, }; steps.set(params.stepId, step); (parentStep || result).steps.push(step); @@ -331,6 +331,7 @@ export class Dispatcher { step.duration = params.wallTime - step.startTime.getTime(); if (params.error) step.error = params.error; + step.data = params.data; stepStack.delete(step); steps.delete(params.stepId); this._reporter.onStepEnd?.(test, result, step); diff --git a/src/test/expect.ts b/src/test/expect.ts index ced8a5096d..8156d2efce 100644 --- a/src/test/expect.ts +++ b/src/test/expect.ts @@ -77,7 +77,7 @@ function wrap(matcherName: string, matcher: any) { 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 step = testInfo._addStep('expect', `expect${this.isNot ? '.not' : ''}.${matcherName}`, prepareExpectStepData(stackLines)); const reportStepEnd = (result: any) => { const success = result.pass !== this.isNot; @@ -86,12 +86,12 @@ function wrap(matcherName: string, matcher: any) { const message = result.message(); error = { message, stack: message + '\n' + stackLines.join('\n') }; } - completeStep?.(error); + step.complete(error); return result; }; const reportStepError = (error: Error) => { - completeStep?.(serializeError(error)); + step.complete(serializeError(error)); throw error; }; @@ -119,7 +119,7 @@ function prepareExpectStepData(lines: string[]) { column: parsed.column }; }).filter(frame => !!frame); - return { stack: frames }; + return { stack: frames, log: [] }; } const wrappedMatchers: any = {}; diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 4e89e30792..9f31527d62 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -16,7 +16,7 @@ import { formatLocation, wrapInPromise } from './util'; import * as crypto from 'crypto'; -import { FixturesWithLocation, Location, WorkerInfo, TestInfo, CompleteStepCallback } from './types'; +import { FixturesWithLocation, Location, WorkerInfo, TestInfo, TestStepInternal } from './types'; type FixtureScope = 'test' | 'worker'; type FixtureRegistration = { @@ -242,7 +242,7 @@ export class FixtureRunner { throw error; } - async resolveParametersAndRunHookOrTest(fn: Function, workerInfo: WorkerInfo, testInfo: TestInfo | undefined, paramsStepCallback?: CompleteStepCallback) { + async resolveParametersAndRunHookOrTest(fn: Function, workerInfo: WorkerInfo, testInfo: TestInfo | undefined, paramsStepCallback?: TestStepInternal) { // Install all automatic fixtures. for (const registration of this.pool!.registrations.values()) { const shouldSkip = !testInfo && registration.scope === 'test'; @@ -260,7 +260,7 @@ export class FixtureRunner { } // Report fixture hooks step as completed. - paramsStepCallback?.(); + paramsStepCallback?.complete(); return fn(params, testInfo || workerInfo); } diff --git a/src/test/index.ts b/src/test/index.ts index 8bedfae348..c6c67d5244 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -204,9 +204,13 @@ export const test = _baseTest.extend({ } (context as any)._csi = { 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 }); + const testInfoImpl = testInfo as TestInfoImpl; + const existingStep = testInfoImpl._currentSteps().find(step => step.category === 'pw:api' || step.category === 'expect'); + const newStep = existingStep ? undefined : testInfoImpl._addStep('pw:api', stackTrace.apiName, { stack: stackTrace.frames, log: [] }); + return (log: string[], error?: Error) => { + (existingStep || newStep)?.data.log?.push(...log); + newStep?.complete(error); + }; }, }; }; diff --git a/src/test/ipc.ts b/src/test/ipc.ts index 619e5e3a51..4b2e89508f 100644 --- a/src/test/ipc.ts +++ b/src/test/ipc.ts @@ -52,7 +52,6 @@ export type StepBeginPayload = { title: string; category: string; wallTime: number; // milliseconds since unix epoch - data: { [key: string]: any }; }; export type StepEndPayload = { @@ -60,6 +59,7 @@ export type StepEndPayload = { stepId: string; wallTime: number; // milliseconds since unix epoch error?: TestError; + data: { [key: string]: any }; }; export type TestEntry = { diff --git a/src/test/reporters/html.ts b/src/test/reporters/html.ts index 6040a9a2aa..76c3dcfcf9 100644 --- a/src/test/reporters/html.ts +++ b/src/test/reporters/html.ts @@ -105,6 +105,7 @@ export type JsonTestStep = { steps: JsonTestStep[]; preview?: string; stack?: JsonStackFrame[]; + log?: string[]; }; class HtmlReporter { @@ -221,6 +222,7 @@ class HtmlReporter { steps: this._serializeSteps(test, step.steps), failureSnippet: step.error ? formatError(step.error, test.location.file) : undefined, ...this._sourceProcessor.processStackTrace(step.data.stack), + log: step.data.log || undefined, }; }); } diff --git a/src/test/testType.ts b/src/test/testType.ts index 563b2ee324..201fd3a88b 100644 --- a/src/test/testType.ts +++ b/src/test/testType.ts @@ -186,12 +186,12 @@ export class TestTypeImpl { const testInfo = currentTestInfo(); if (!testInfo) throw errorWithLocation(location, `test.step() can only be called from a test`); - const complete = testInfo._addStep('test.step', title); + const step = testInfo._addStep('test.step', title); try { await body(); - complete(); + step.complete(); } catch (e) { - complete(serializeError(e)); + step.complete(serializeError(e)); throw e; } } diff --git a/src/test/types.ts b/src/test/types.ts index de77cc2328..751b34f975 100644 --- a/src/test/types.ts +++ b/src/test/types.ts @@ -25,10 +25,14 @@ export type FixturesWithLocation = { }; export type Annotations = { type: string, description?: string }[]; -export type CompleteStepCallback = (error?: Error | TestError) => void; +export interface TestStepInternal { + complete(error?: Error | TestError): void; + category: string; + data: { [key: string]: any }; +} export interface TestInfoImpl extends TestInfo { _testFinished: Promise; - _addStep: (category: string, title: string, data?: { [key: string]: any }) => CompleteStepCallback; - _currentSteps(): { category: string }[]; + _addStep: (category: string, title: string, data?: { [key: string]: any }) => TestStepInternal; + _currentSteps(): TestStepInternal[]; } diff --git a/src/test/workerRunner.ts b/src/test/workerRunner.ts index 32baa162d0..7fcf0a863c 100644 --- a/src/test/workerRunner.ts +++ b/src/test/workerRunner.ts @@ -25,7 +25,7 @@ import { TestBeginPayload, TestEndPayload, RunPayload, TestEntry, DonePayload, W import { setCurrentTestInfo } from './globals'; import { Loader } from './loader'; import { Modifier, Suite, TestCase } from './test'; -import { Annotations, CompleteStepCallback, TestError, TestInfo, TestInfoImpl, WorkerInfo } from './types'; +import { Annotations, TestError, TestInfo, TestInfoImpl, TestStepInternal, WorkerInfo } from './types'; import { ProjectImpl } from './project'; import { FixturePool, FixtureRunner } from './fixtures'; import { DeadlineRunner, raceAgainstDeadline } from '../utils/async'; @@ -219,7 +219,7 @@ export class WorkerRunner extends EventEmitter { let testFinishedCallback = () => {}; let lastStepId = 0; - const stepStack = new Set<{ category: string }>(); + const stepStack = new Set(); const testInfo: TestInfoImpl = { workerIndex: this._params.workerIndex, project: this._project.config, @@ -270,34 +270,39 @@ export class WorkerRunner extends EventEmitter { _testFinished: new Promise(f => testFinishedCallback = f), _addStep: (category: string, title: string, data: { [key: string]: any } = {}) => { const stepId = `${category}@${title}@${++lastStepId}`; - const step: StepBeginPayload = { + let callbackHandled = false; + const step: TestStepInternal = { + data, + category, + complete: (error?: Error | TestError) => { + if (callbackHandled) + return; + callbackHandled = true; + if (error instanceof Error) + error = serializeError(error); + stepStack.delete(step); + const payload: StepEndPayload = { + testId, + stepId, + wallTime: Date.now(), + error, + data, + }; + if (reportEvents) + this.emit('stepEnd', payload); + } + }; + stepStack.add(step); + const payload: StepBeginPayload = { testId, stepId, category, title, wallTime: Date.now(), - data, }; - stepStack.add(step); if (reportEvents) - this.emit('stepBegin', step); - let callbackHandled = false; - return (error?: Error | TestError) => { - if (callbackHandled) - return; - callbackHandled = true; - if (error instanceof Error) - error = serializeError(error); - stepStack.delete(step); - const payload: StepEndPayload = { - testId, - stepId, - wallTime: Date.now(), - error - }; - if (reportEvents) - this.emit('stepEnd', payload); - }; + this.emit('stepBegin', payload); + return step; }, _currentSteps: () => [...stepStack], }; @@ -422,18 +427,18 @@ export class WorkerRunner extends EventEmitter { } private async _runTestWithBeforeHooks(test: TestCase, testInfo: TestInfoImpl) { - const completeStep = testInfo._addStep('hook', 'Before Hooks'); + const step = testInfo._addStep('hook', 'Before Hooks'); if (test._type === 'test') await this._runBeforeHooks(test, testInfo); // Do not run the test when beforeEach hook fails. if (testInfo.status === 'failed' || testInfo.status === 'skipped') { - completeStep?.(testInfo.error); + step.complete(testInfo.error); return; } try { - await this._fixtureRunner.resolveParametersAndRunHookOrTest(test.fn, this._workerInfo, testInfo, completeStep); + await this._fixtureRunner.resolveParametersAndRunHookOrTest(test.fn, this._workerInfo, testInfo, step); } catch (error) { if (error instanceof SkipError) { if (testInfo.status === 'passed') @@ -448,15 +453,15 @@ export class WorkerRunner extends EventEmitter { testInfo.error = serializeError(error); } } finally { - completeStep?.(testInfo.error); + step.complete(testInfo.error); } } private async _runAfterHooks(test: TestCase, testInfo: TestInfoImpl) { - let completeStep: CompleteStepCallback | undefined; + let step: TestStepInternal | undefined; let teardownError: TestError | undefined; try { - completeStep = testInfo._addStep('hook', 'After Hooks'); + step = testInfo._addStep('hook', 'After Hooks'); if (test._type === 'test') await this._runHooks(test.parent!, 'afterEach', testInfo); } catch (error) { @@ -480,7 +485,7 @@ export class WorkerRunner extends EventEmitter { teardownError = testInfo.error; } } - completeStep?.(teardownError); + step?.complete(teardownError); } private async _runHooks(suite: Suite, type: 'beforeEach' | 'afterEach', testInfo: TestInfo) { diff --git a/src/web/htmlReport/htmlReport.css b/src/web/htmlReport/htmlReport.css index 833108d297..b1edcc1c8a 100644 --- a/src/web/htmlReport/htmlReport.css +++ b/src/web/htmlReport/htmlReport.css @@ -181,3 +181,9 @@ .test-details-column { overflow-y: auto; } + +.step-log { + line-height: 20px; + white-space: pre; + padding: 8px; +} diff --git a/src/web/htmlReport/htmlReport.tsx b/src/web/htmlReport/htmlReport.tsx index 7754727150..a70c60b56e 100644 --- a/src/web/htmlReport/htmlReport.tsx +++ b/src/web/htmlReport/htmlReport.tsx @@ -272,7 +272,7 @@ const TestStepDetails: React.FC<{ { id: 'log', title: 'Log', - render: () =>
+ render: () =>
{step?.log ? step.log.join('\n') : ''}
}, { id: 'errors', diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 27daf64f91..c0a7513da5 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -139,8 +139,9 @@ export const playwrightFixtures: Fixtures { - return (testInfo as any)._addStep('pw:api', name); + onApiCall: (stackTrace: any) => { + const step = (testInfo as any)._addStep('pw:api', stackTrace.apiName); + return (log, error) => step.complete(error); }, }; contexts.push(context); diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index 00160a1070..8dfc2f9646 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.ts @@ -506,20 +506,18 @@ test('should report api step hierarchy', async ({ runInlineTest }) => { ]); }); -test('should report expect and pw:api stacks', async ({ runInlineTest }, testInfo) => { +test('should report expect and pw:api stacks and logs', async ({ runInlineTest }, testInfo) => { const expectReporterJS = ` class Reporter { - stepDetails(step) { + stepStack(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)); + return step.title + ' stack: ' + frame.file + ':' + frame.line + ':' + frame.column; } onStepEnd(test, result, step) { - console.log('%%%% end', this.stepDetails(step)); + console.log('%%%% ' + this.stepStack(step)); + console.log('%%%% ' + step.title + ' log: ' + (step.data.log || []).join('')); } } module.exports = Reporter; @@ -535,20 +533,22 @@ test('should report expect and pw:api stacks', async ({ runInlineTest }, testInf 'a.test.ts': ` const { test } = pwt; test('pass', async ({ page }) => { - await page.setContent('hello'); + await page.setContent('hello
Click me
'); + await page.click('text=Click me'); expect(1).toBe(1); - await expect(page).toHaveTitle('hello'); + await expect(page.locator('div')).toHaveText('Click me'); }); ` }, { 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')}`); + expect(result.output).toContain(`%% page.setContent stack: ${testInfo.outputPath('a.test.ts:7:20')}`); + expect(result.output).toContain(`%% page.setContent log: setting frame content, waiting until "load"`); + expect(result.output).toContain(`%% page.click stack: ${testInfo.outputPath('a.test.ts:8:20')}`); + expect(result.output).toContain(`%% page.click log: waiting for selector "text=Click me"`); + expect(result.output).toContain(`%% expect.toBe stack: ${testInfo.outputPath('a.test.ts:9:19')}`); + expect(result.output).toContain(`%% expect.toHaveText stack: ${testInfo.outputPath('a.test.ts:10:43')}`); + expect(result.output).toContain(`%% expect.toHaveText log: retrieving textContent from "div"`); }); function stripEscapedAscii(str: string) {