diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b38d8151..8338b939 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,6 @@ name: ci +permissions: + contents: read on: push diff --git a/README.md b/README.md index 8b63abc5..6514f96d 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,12 @@ should be copied to this directory after the pack is completed, and then screenshots from this directory will be displayed in the HTML report. If `null`, screenshots will not be displayed in the HTML report. +`regroupSteps: (steps: readonly LogEvent[]) => readonly LogEvent[]`: a function that regroups the tree of test steps +in the HTML report. +This way, you can leave only the important test steps (actions, checks) at the top level, +while hiding minor technical steps at deeper levels of the tree +(they will be visible in the report if you explicitly expand them). + `reportFileName: string | null`: the name of the file under which, after running the tests, the HTML report will be saved in the `autotests/reports` directory, for example, `report.html`. Also this name is used as the title of the report page. diff --git a/autotests/configurator/index.ts b/autotests/configurator/index.ts index d9b6bfa7..23fe1b8d 100644 --- a/autotests/configurator/index.ts +++ b/autotests/configurator/index.ts @@ -7,6 +7,7 @@ export {mapLogPayloadInConsole} from './mapLogPayloadInConsole'; export {mapLogPayloadInLogFile} from './mapLogPayloadInLogFile'; export {mapLogPayloadInReport} from './mapLogPayloadInReport'; export {matchScreenshot} from './matchScreenshot'; +export {regroupSteps} from './regroupSteps'; export {skipTests} from './skipTests'; export type { DoAfterPack, diff --git a/src/utils/report/client/groupLogEvents.ts b/autotests/configurator/regroupSteps.ts similarity index 61% rename from src/utils/report/client/groupLogEvents.ts rename to autotests/configurator/regroupSteps.ts index 86454dc3..dc3a2890 100644 --- a/src/utils/report/client/groupLogEvents.ts +++ b/autotests/configurator/regroupSteps.ts @@ -1,13 +1,14 @@ -import {LogEventStatus, LogEventType} from '../../../constants/internal'; +import {LogEventStatus, LogEventType} from 'e2ed/constants'; +import {setReadonlyProperty} from 'e2ed/utils'; -import type {LogEvent, LogEventWithChildren} from '../../../types/internal'; +import type {LogEvent, Mutable} from 'e2ed/types'; /** - * Group log events to log events with children (for groupping of `TestRun` steps). + * Regroup log events (for groupping of `TestRun` steps). * This base client function should not use scope variables (except other base functions). * @internal */ -export const groupLogEvents = (logEvents: readonly LogEvent[]): readonly LogEventWithChildren[] => { +export const regroupSteps = (logEvents: readonly LogEvent[]): readonly LogEvent[] => { const topLevelTypes: readonly LogEventType[] = [ LogEventType.Action, LogEventType.Assert, @@ -20,16 +21,17 @@ export const groupLogEvents = (logEvents: readonly LogEvent[]): readonly LogEven topLevelTypes.includes(logEvent.type) || logEvent.payload?.logEventStatus === LogEventStatus.Failed; - const result: LogEventWithChildren[] = []; + const result: LogEvent[] = []; for (const logEvent of logEvents) { const last = result.at(-1); - const newEvent: LogEventWithChildren = {children: [], ...logEvent}; + const newEvent: LogEvent = {...logEvent}; if (isTopLevelEvent(logEvent)) { if (last && !isTopLevelEvent(last)) { - const firstTopLevel: LogEventWithChildren = { + const firstTopLevel: LogEvent = { children: [...result], + endTime: undefined, message: 'Initialization', payload: undefined, time: last.time, @@ -43,7 +45,11 @@ export const groupLogEvents = (logEvents: readonly LogEvent[]): readonly LogEven result.push(newEvent); } else if (last && isTopLevelEvent(last)) { - (last.children as LogEventWithChildren[]).push(newEvent); + if (last.children === undefined) { + setReadonlyProperty(last, 'children', [newEvent]); + } else { + (last.children as Mutable).push(newEvent); + } } else { result.push(newEvent); } diff --git a/autotests/packs/allTests.ts b/autotests/packs/allTests.ts index 2350c0e3..c82826f9 100644 --- a/autotests/packs/allTests.ts +++ b/autotests/packs/allTests.ts @@ -18,6 +18,7 @@ import { mapLogPayloadInLogFile, mapLogPayloadInReport, matchScreenshot, + regroupSteps, skipTests, } from '../configurator'; @@ -74,6 +75,7 @@ export const pack: Pack = { pathToScreenshotsDirectoryForReport: './screenshots', port1: 1337, port2: 1338, + regroupSteps, reportFileName: 'report.html', resourceUsageReadingInternal: 5_000, selectorTimeout: 10_000, diff --git a/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts b/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts index ddaef23b..f898dc11 100644 --- a/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts +++ b/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts @@ -26,6 +26,11 @@ export class E2edReportExample extends Page { */ readonly header: Selector = locator('header'); + /** + * Logo of `e2ed` in page header. + */ + readonly logo: Selector = locator('logo'); + /** * Navigation bar with test retries. */ @@ -83,7 +88,7 @@ export class E2edReportExample extends Page { } async clickLogo(): Promise { - await click(this.header, {position: {x: 30, y: 30}}); + await click(this.logo); } getRoute(): E2edReportExampleRoute { diff --git a/autotests/pageObjects/pages/Main.ts b/autotests/pageObjects/pages/Main.ts index baec21e1..33aa9273 100644 --- a/autotests/pageObjects/pages/Main.ts +++ b/autotests/pageObjects/pages/Main.ts @@ -67,6 +67,9 @@ export class Main extends Page { await waitForAllRequestsComplete( ({url}) => { if ( + url.startsWith('https://assets.msn.com/') || + url.startsWith('https://www.bing.com/ipv6test/') || + url.startsWith('https://www2.bing.com/ipv6test/') || url.startsWith('https://browser.events.data.msn.com/') || url.startsWith('https://img-s-msn-com.akamaized.net/') || url.startsWith('https://rewards.bing.com/widget/') || diff --git a/autotests/tests/e2edReportExample/toMatchScreenshot.ts b/autotests/tests/e2edReportExample/toMatchScreenshot.ts index aed9a6b6..c12aacf3 100644 --- a/autotests/tests/e2edReportExample/toMatchScreenshot.ts +++ b/autotests/tests/e2edReportExample/toMatchScreenshot.ts @@ -6,7 +6,7 @@ import {navigateToPage} from 'e2ed/actions'; test('correctly check screenshots via toMatchScreenshot', {meta: {testId: '20'}}, async () => { const reportPage = await navigateToPage(E2edReportExample); - await expect(reportPage.header.find('a'), 'toMatchScreenshot check screenshot').toMatchScreenshot( + await expect(reportPage.logo, 'toMatchScreenshot check screenshot').toMatchScreenshot( 'pwoZRA8i7O', {mask: []}, ); diff --git a/autotests/tests/expect.ts b/autotests/tests/expect.ts index 74f2babe..be374862 100644 --- a/autotests/tests/expect.ts +++ b/autotests/tests/expect.ts @@ -6,7 +6,7 @@ import {getFullPackConfig} from 'autotests/utils'; import {expect} from 'e2ed'; import {assertFunctionThrows, getTimeoutPromise} from 'e2ed/utils'; -test('expect function works correctly', {meta: {testId: '16'}}, async () => { +test('expect(...) function works correctly', {meta: {testId: '16'}}, async () => { const {assertionTimeout} = getFullPackConfig(); await assertFunctionThrows(async () => { diff --git a/autotests/tests/main/exists.ts b/autotests/tests/main/exists.ts index dedcc880..00a327c5 100644 --- a/autotests/tests/main/exists.ts +++ b/autotests/tests/main/exists.ts @@ -19,12 +19,12 @@ import {assertFunctionThrows, getDocumentUrl} from 'e2ed/utils'; import type {Url} from 'e2ed/types'; -const testScrollValue = 200; -const language = 'en'; -const searchQuery = 'foo'; - // eslint-disable-next-line max-statements test('exists', {meta: {testId: '1'}, testIdleTimeout: 10_000, testTimeout: 15_000}, async () => { + const language = 'en'; + const searchQuery = 'foo'; + const testScrollValue = 200; + await scroll(0, testScrollValue); assertFunctionThrows(() => { @@ -40,8 +40,6 @@ test('exists', {meta: {testId: '1'}, testIdleTimeout: 10_000, testTimeout: 15_00 'dynamic custom pack properties is correct', ).gt(0); - await step('Some step'); - const urlObjectPromise = waitForStartOfPageLoad(); const mainPage = await navigateToPage(Main, {language}); @@ -60,7 +58,7 @@ test('exists', {meta: {testId: '1'}, testIdleTimeout: 10_000, testTimeout: 15_00 await expect(mainPage.searchQuery, 'search query on page is empty').eql(''); - await step('Another step', () => { + await step('Some step', () => { getPageCookies(); }); diff --git a/autotests/tests/step.ts b/autotests/tests/step.ts new file mode 100644 index 00000000..b717b333 --- /dev/null +++ b/autotests/tests/step.ts @@ -0,0 +1,57 @@ +import {test} from 'autotests'; +import {step} from 'e2ed'; +import {waitForTimeout} from 'e2ed/actions'; +import {LogEventType} from 'e2ed/constants'; +import {assertFunctionThrows, log} from 'e2ed/utils'; + +test('step(...) function works correctly', {meta: {testId: '23'}}, async () => { + const timeout = 30; + const timeoutAddition = 10; + + await step('First step', (): undefined => {}); + + await step('Step level 1', async () => { + await step('Step level 2'); + await step('Skipped step level 2', () => {}, {skipLogs: true}); + + log('Some log on level 2', {payload: 18}); + + await step('Step level 2 with children', async () => { + await step('Step level 3 with action type', () => {}, { + payload: {initialPayload: 10}, + timeout: 10, + type: LogEventType.Action, + }); + + await step('Step level 3', async () => { + await step('Step level 4'); + + await assertFunctionThrows(async () => { + await step( + 'Failed step with timeout', + async () => { + await waitForTimeout(timeout + timeoutAddition); + + await step('Also failed step', () => { + throw new Error('This step should be torn out of the tree'); + }); + }, + {runPlaywrightStep: true, timeout}, + ); + }, 'step body throws an error on timeout end'); + + await step( + 'Step level 4 with running playwright step', + () => ({finalPayload: 40, initialPayload: 30}), + {payload: {initialPayload: 20}, runPlaywrightStep: true}, + ); + + log('Some log on level 4'); + }); + + log('Some log on level 3', {level: 3}); + }); + + log('Also some log on level 2', {level: 2}); + }); +}); diff --git a/autotests/tests/switchingPagesForRequests.ts b/autotests/tests/switchingPagesForRequests.ts index 19a15f4c..9610bad5 100644 --- a/autotests/tests/switchingPagesForRequests.ts +++ b/autotests/tests/switchingPagesForRequests.ts @@ -52,9 +52,12 @@ test( await waitForTimeout(maxNumberOfRequests * 333); - const npmPageTab = await waitForNewTab(async () => { - await reportPage.clickLogo(); - }); + const npmPageTab = await waitForNewTab( + async () => { + await reportPage.clickLogo(); + }, + {timeout: 10_000}, + ); await switchToTab(npmPageTab); diff --git a/autotests/tests/switchingPagesForResponses.ts b/autotests/tests/switchingPagesForResponses.ts index b39d9923..ec037381 100644 --- a/autotests/tests/switchingPagesForResponses.ts +++ b/autotests/tests/switchingPagesForResponses.ts @@ -52,9 +52,12 @@ test( await waitForTimeout(maxNumberOfRequests * 333); - const npmPageTab = await waitForNewTab(async () => { - await reportPage.clickLogo(); - }); + const npmPageTab = await waitForNewTab( + async () => { + await reportPage.clickLogo(); + }, + {timeout: 10_000}, + ); await switchToTab(npmPageTab); diff --git a/autotests/tests/waitForAllRequestsComplete.ts b/autotests/tests/waitForAllRequestsComplete.ts index 3d66843c..9ffa0657 100644 --- a/autotests/tests/waitForAllRequestsComplete.ts +++ b/autotests/tests/waitForAllRequestsComplete.ts @@ -25,7 +25,7 @@ test( await assertFunctionThrows(async () => { await waitForAllRequestsComplete(() => true, {timeout: 100}); - }, 'Catch error from waitForAllRequestsComplete for {timeout: 100}'); + }, 'Caught an error from waitForAllRequestsComplete for {timeout: 100}'); await waitForAllRequestsComplete(() => true, {timeout: 1000}); @@ -37,7 +37,7 @@ test( await assertFunctionThrows( () => promise, - 'Catch error from waitForAllRequestsComplete for {timeout: 1000}', + 'Caught an error from waitForAllRequestsComplete for {timeout: 1000}', ); waitedInMs = Date.now() - startRequestInMs; diff --git a/src/context/stepsStack.ts b/src/context/stepsStack.ts new file mode 100644 index 00000000..25e9c43f --- /dev/null +++ b/src/context/stepsStack.ts @@ -0,0 +1,27 @@ +import {useContext} from '../useContext'; + +import type {LogEvent} from '../types/internal'; + +/** + * Raw get and set (maybe `undefined`) of test steps stack. + * @internal + */ +const [getRawStepsStack, setRawStepsStack] = useContext(); + +/** + * Get always defined test steps stack. + * @internal + */ +export const getStepsStack = (): readonly LogEvent[] => { + const maybeStepsStack = getRawStepsStack(); + + if (maybeStepsStack !== undefined) { + return maybeStepsStack; + } + + const stepsStack: LogEvent[] = []; + + setRawStepsStack(stepsStack); + + return stepsStack; +}; diff --git a/src/step.ts b/src/step.ts index 9a455e97..b65cd1a4 100644 --- a/src/step.ts +++ b/src/step.ts @@ -1,30 +1,146 @@ -import {LogEventType} from './constants/internal'; +import {LogEventStatus, LogEventType} from './constants/internal'; +import {getStepsStack} from './context/stepsStack'; +import {getFullPackConfig} from './utils/config'; +import {E2edError} from './utils/error'; import {setCustomInspectOnFunction} from './utils/fn'; -import {log} from './utils/log'; +import {generalLog} from './utils/generalLog'; +import {getDurationWithUnits} from './utils/getDurationWithUnits'; +import {logAndGetLogEvent} from './utils/log'; +import {setReadonlyProperty} from './utils/object'; +import {addTimeoutToPromise} from './utils/promise'; -import type {MaybePromise} from './types/internal'; +import type { + LogEvent, + LogPayload, + MaybePromise, + Mutable, + UtcTimeInMs, + Void, +} from './types/internal'; import {test as playwrightTest} from '@playwright/test'; -type Options = Readonly<{skipLogs?: boolean; timeout?: number}>; - -const noop = (): void => {}; +type Options = Readonly<{ + payload?: LogPayload; + runPlaywrightStep?: boolean; + skipLogs?: boolean; + timeout?: number; + type?: LogEventType; +}>; /** - * Declares a test step (calls Playwright's `test.step` function inside). + * Declares a test step (could calls Playwright's `test.step` function inside). */ -export const step = ( +// eslint-disable-next-line complexity, max-statements +export const step = async ( name: string, - body: () => MaybePromise = noop, + body?: () => MaybePromise, options?: Options, ): Promise => { + if (body !== undefined) { + setCustomInspectOnFunction(body); + } + + let logEvent: LogEvent | undefined; + const stepsStack = getStepsStack(); + const timeout: number = options?.timeout ?? getFullPackConfig().testIdleTimeout; + if (options?.skipLogs !== true) { - if (body !== noop) { - setCustomInspectOnFunction(body); - } + logEvent = logAndGetLogEvent( + name, + options?.payload, + options?.type ?? LogEventType.InternalCore, + ); + } - log(name, {body: body === noop ? undefined : body, options}, LogEventType.InternalCore); + if (logEvent !== undefined) { + (stepsStack as Mutable).push(logEvent); } - return playwrightTest.step(name, body, options); + const errorProperties = {fromStep: name, stepBody: body, stepOptions: options}; + let payload = undefined as LogPayload | Void; + let stepError: unknown; + + try { + const timeoutError = new E2edError( + `Body of step "${name}" rejected after ${getDurationWithUnits(timeout)} timeout`, + errorProperties, + ); + + const runBody = async (): Promise => { + payload = await body?.(); + }; + + let bodyError: unknown; + let hasError = false; + + const runBodyWithTimeout = (): Promise => + addTimeoutToPromise(runBody(), timeout, timeoutError).catch((error: unknown) => { + bodyError = error; + hasError = true; + }); + + if (options?.runPlaywrightStep === true) { + await playwrightTest.step(name, () => runBodyWithTimeout()); + } else { + await runBodyWithTimeout(); + } + + if (hasError) { + throw bodyError; + } + } catch (error) { + stepError = error; + + if (error !== null && (typeof error === 'object' || typeof error === 'function')) { + if (!('fromStep' in error)) { + Object.assign( + error, + errorProperties, + 'message' in error ? {originalMessage: error.message} : undefined, + ); + } + } else { + stepError = new E2edError('Caught an error in step', {cause: error, ...errorProperties}); + } + + if (logEvent !== undefined) { + if (logEvent.payload !== undefined) { + setReadonlyProperty(logEvent.payload, 'error', stepError); + setReadonlyProperty(logEvent.payload, 'logEventStatus', LogEventStatus.Failed); + } else { + setReadonlyProperty(logEvent, 'payload', { + error: stepError, + logEventStatus: LogEventStatus.Failed, + }); + } + } + + throw stepError; + } finally { + if (logEvent !== undefined) { + const endTime = Date.now() as UtcTimeInMs; + + setReadonlyProperty(logEvent, 'endTime', endTime); + + if (payload !== undefined) { + setReadonlyProperty(logEvent, 'payload', {...logEvent.payload, ...payload}); + } + + if (stepsStack.at(-1) === logEvent) { + (stepsStack as Mutable).pop(); + } else { + // eslint-disable-next-line no-unsafe-finally + throw new E2edError('Running step is not equal to last step in test steps stack', { + lastStep: stepsStack.at(-1), + runningStep: logEvent, + stepBody: body, + stepError, + stepOptions: options, + }); + } + + generalLog(`Step "${name}" completed`, {body, step: logEvent, stepError}); + } + } }; diff --git a/src/test.ts b/src/test.ts index e3e0776e..2d49b1c3 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,4 +1,5 @@ import {getFullPackConfig} from './utils/config'; +import {generalLog} from './utils/generalLog'; import {getGlobalErrorHandler} from './utils/getGlobalErrorHandler'; import {getRunTest} from './utils/test'; import {isUiMode} from './utils/uiMode'; @@ -22,9 +23,17 @@ export const test: TestFunction = (name, options, testFn) => { if (isUiMode) { const {getTestNamePrefixInUiMode} = getFullPackConfig(); - const prefix = getTestNamePrefixInUiMode(options); - - playwrightTestName = `${prefix} ${name}`; + try { + const prefix = getTestNamePrefixInUiMode(options); + + playwrightTestName = `${prefix} ${name}`; + } catch (error) { + generalLog('Caught an error on run "getTestNamePrefixInUiMode" function', { + error, + name, + options, + }); + } } if (options.enableCsp !== undefined) { diff --git a/src/types/config/ownE2edConfig.ts b/src/types/config/ownE2edConfig.ts index 61567d8b..7afb2db2 100644 --- a/src/types/config/ownE2edConfig.ts +++ b/src/types/config/ownE2edConfig.ts @@ -2,6 +2,7 @@ import type {PlaywrightTestConfig} from '@playwright/test'; import type {TestRunStatus} from '../../constants/internal'; +import type {LogEvent} from '../events'; import type {FullMocksConfig} from '../fullMocks'; import type {LogTag, MapBackendResponseToLog, MapLogPayload, MapLogPayloadInReport} from '../log'; import type {MatchScreenshotConfig} from '../matchScreenshot'; @@ -198,6 +199,15 @@ export type OwnE2edConfig< */ pathToScreenshotsDirectoryForReport: string | null; + /** + * Regroup tree of test steps in the HTML report. + * A function that regroups the tree of test steps in the report. + * This way, you can leave only the important test steps (actions, checks) at the top level, + * while hiding minor technical steps at deeper levels of the tree + * (they will be visible in the report if you explicitly expand them). + */ + regroupSteps: (this: void, steps: readonly LogEvent[]) => readonly LogEvent[]; + /** * The name of the file under which, after running the tests, * the HTML report will be saved in the `autotests/reports` directory, for example, `report.html`. diff --git a/src/types/events.ts b/src/types/events.ts index 5601dfaa..c922ea17 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -13,17 +13,14 @@ import type {TestMetaPlaceholder} from './userland'; * Log event (on log call). */ export type LogEvent = Readonly<{ + children: readonly LogEvent[] | undefined; + endTime: UtcTimeInMs | undefined; message: string; payload: LogPayload | undefined; time: UtcTimeInMs; type: LogEventType; }>; -/** - * Log event with children (for groupping of `TestRun` steps). - */ -export type LogEventWithChildren = LogEvent & Readonly<{children: readonly LogEventWithChildren[]}>; - /** * EndTestRun event (on closing test). * @internal diff --git a/src/types/index.ts b/src/types/index.ts index 0965e5ef..d9cdaf3b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,7 +16,7 @@ export type {ConsoleMessage, ConsoleMessageType} from './console'; export type {UtcTimeInMs} from './date'; export type {DeepMutable, DeepPartial, DeepReadonly, DeepRequired} from './deep'; export type {E2edPrintedFields, JsError} from './errors'; -export type {LogEvent, LogEventWithChildren, Onlog, TestRunEvent} from './events'; +export type {LogEvent, Onlog, TestRunEvent} from './events'; export type {Fn, MergeFunctions} from './fn'; export type { FullMocksConfig, diff --git a/src/types/internal.ts b/src/types/internal.ts index 6f3eba03..a896da50 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -31,7 +31,7 @@ export type {E2edEnvironment} from './environment'; export type {E2edPrintedFields, JsError} from './errors'; /** @internal */ export type {GlobalErrorType, MaybeWithIsTestRunBroken} from './errors'; -export type {LogEvent, LogEventWithChildren, Onlog, TestRunEvent} from './events'; +export type {LogEvent, Onlog, TestRunEvent} from './events'; /** @internal */ export type {EndTestRunEvent, FullEventsData} from './events'; export type {Fn, MergeFunctions} from './fn'; diff --git a/src/types/log.ts b/src/types/log.ts index b40ab88e..95358e79 100644 --- a/src/types/log.ts +++ b/src/types/log.ts @@ -5,13 +5,13 @@ import type {ResponseWithRequest} from './http'; /** * Type for `log` function in test context. */ -export type Log = (( +export type Log = (( this: void, message: string, payload?: LogPayload, logEventType?: LogEventType, -) => void) & - ((message: string, logEventType: LogEventType) => void); +) => Return) & + ((message: string, logEventType: LogEventType) => Return); /** * Context of log event. @@ -28,6 +28,7 @@ export type LogParams = Payload & Readonly<{cause?: unknown}>; */ export type LogPayload = Readonly<{ backendResponses?: readonly Payload[]; + error?: unknown; filePath?: unknown; logEventStatus?: LogEventStatus; logTag?: LogTag; diff --git a/src/utils/events/getRegroupedSteps.ts b/src/utils/events/getRegroupedSteps.ts new file mode 100644 index 00000000..7021eedb --- /dev/null +++ b/src/utils/events/getRegroupedSteps.ts @@ -0,0 +1,29 @@ +import {cloneWithoutLogEvents} from '../clone'; +import {getFullPackConfig} from '../config'; +import {generalLog} from '../generalLog'; + +import type {LogEvent, TestRunEvent} from '../../types/internal'; + +/** + * Get regrouped tree of test steps in the HTML report. + * @internal + */ +export const getRegroupedSteps = (testRunEvent: TestRunEvent): readonly LogEvent[] => { + const {regroupSteps} = getFullPackConfig(); + const {logEvents} = testRunEvent; + + let regroupedSteps: readonly LogEvent[] = [...logEvents]; + + try { + regroupedSteps = regroupSteps(regroupedSteps); + } catch (error) { + regroupedSteps = [...logEvents]; + + generalLog('Caught an error on run "regroupSteps" function', { + error, + testRunEvent: cloneWithoutLogEvents(testRunEvent), + }); + } + + return regroupedSteps; +}; diff --git a/src/utils/events/registerEndTestRunEvent.ts b/src/utils/events/registerEndTestRunEvent.ts index cc9507fb..77a74f0c 100644 --- a/src/utils/events/registerEndTestRunEvent.ts +++ b/src/utils/events/registerEndTestRunEvent.ts @@ -11,6 +11,7 @@ import {setReadonlyProperty} from '../object'; import {getUserlandHooks} from '../userland'; import {calculateTestRunStatus} from './calculateTestRunStatus'; +import {getRegroupedSteps} from './getRegroupedSteps'; import {getTestRunEvent} from './getTestRunEvent'; import {writeFullMocksIfNeeded} from './writeFullMocksIfNeeded'; @@ -22,12 +23,10 @@ import type {EndTestRunEvent, FullTestRun, RunHash, TestRun} from '../../types/i */ export const registerEndTestRunEvent = async (endTestRunEvent: EndTestRunEvent): Promise => { const {runId} = endTestRunEvent; - const testRunEvent = getTestRunEvent(runId); const { filePath, - logEvents, name, options, outputDirectoryName, @@ -59,7 +58,7 @@ export const registerEndTestRunEvent = async (endTestRunEvent: EndTestRunEvent): const testRun: TestRun = { endTimeInMs, filePath, - logEvents, + logEvents: getRegroupedSteps(testRunEvent), name, options, outputDirectoryName, diff --git a/src/utils/events/registerLogEvent.ts b/src/utils/events/registerLogEvent.ts index 174a927e..f41bf3e3 100644 --- a/src/utils/events/registerLogEvent.ts +++ b/src/utils/events/registerLogEvent.ts @@ -1,6 +1,10 @@ +import {getStepsStack} from '../../context/stepsStack'; + +import {setReadonlyProperty} from '../object'; + import {getTestRunEvent} from './getTestRunEvent'; -import type {LogEvent, RunId} from '../../types/internal'; +import type {LogEvent, Mutable, RunId} from '../../types/internal'; type LogEventWithMaybeSkippedPayload = Omit & Readonly<{payload: LogEvent['payload'] | 'skipLog'}>; @@ -9,12 +13,31 @@ type LogEventWithMaybeSkippedPayload = Omit & * Registers log event (for report). * @internal */ -export const registerLogEvent = (runId: RunId, logEvent: LogEventWithMaybeSkippedPayload): void => { +export const registerLogEvent = ( + runId: RunId, + logEventWithMaybeSkippedPayload: LogEventWithMaybeSkippedPayload, +): LogEvent | undefined => { + let logEvent: LogEvent | undefined; const runTestEvent = getTestRunEvent(runId); - if (logEvent.payload !== 'skipLog') { - (runTestEvent.logEvents as LogEvent[]).push(logEvent as LogEvent); + if (logEventWithMaybeSkippedPayload.payload !== 'skipLog') { + logEvent = logEventWithMaybeSkippedPayload as LogEvent; + + const stepsStack = getStepsStack(); + const runningStep = stepsStack.at(-1); + + if (runningStep !== undefined) { + if (runningStep.children !== undefined) { + (runningStep.children as Mutable).push(logEvent); + } else { + setReadonlyProperty(runningStep, 'children', [logEvent]); + } + } else { + (runTestEvent.logEvents as Mutable).push(logEvent); + } } runTestEvent.onlog(); + + return logEvent; }; diff --git a/src/utils/flatLogEvents.ts b/src/utils/flatLogEvents.ts new file mode 100644 index 00000000..a6f2e5bf --- /dev/null +++ b/src/utils/flatLogEvents.ts @@ -0,0 +1,19 @@ +import type {LogEvent} from '../types/internal'; + +/** + * Flats array of log events with children to flat array. + * @internal + */ +export const flatLogEvents = (logEvents: readonly LogEvent[]): readonly LogEvent[] => { + const result: LogEvent[] = []; + + for (const logEvent of logEvents) { + result.push(logEvent); + + if (logEvent.children !== undefined && logEvent.children.length > 0) { + result.push(...flatLogEvents(logEvent.children)); + } + } + + return result; +}; diff --git a/src/utils/log/addBackendResponseToLogEvent.ts b/src/utils/log/addBackendResponseToLogEvent.ts new file mode 100644 index 00000000..c60e72b8 --- /dev/null +++ b/src/utils/log/addBackendResponseToLogEvent.ts @@ -0,0 +1,61 @@ +import {LogEventType} from '../../constants/internal'; + +import {getFullPackConfig} from '../config'; +import {setReadonlyProperty} from '../object'; + +import {logWithPreparedOptions} from './logWithPreparedOptions'; + +import type {LogEvent, Payload} from '../../types/internal'; + +const messageOfSingleResponse = 'Got a backend response to log'; + +/** + * Adds single backend response to existing log event. + * @internal + */ +export const addBackendResponseToLogEvent = (payload: Payload, logEvent: LogEvent): void => { + logWithPreparedOptions(messageOfSingleResponse, { + payload, + type: LogEventType.InternalUtil, + }); + + const {mapLogPayloadInReport} = getFullPackConfig(); + + const payloadInReport = mapLogPayloadInReport( + messageOfSingleResponse, + {backendResponses: [payload]}, + LogEventType.InternalUtil, + ); + + if (payloadInReport === 'skipLog' || payloadInReport === undefined) { + return; + } + + if (logEvent.payload === undefined) { + setReadonlyProperty(logEvent, 'payload', payloadInReport); + + return; + } + + const backendResponsesFromPayload = payloadInReport.backendResponses; + + if (!(backendResponsesFromPayload instanceof Array)) { + return; + } + + const {backendResponses} = logEvent.payload; + + if (backendResponses === undefined) { + setReadonlyProperty(logEvent.payload, 'backendResponses', backendResponsesFromPayload); + + return; + } + + const responseFromPayload = backendResponsesFromPayload[0]; + + if (responseFromPayload === undefined) { + return; + } + + (backendResponses as Payload[]).push(responseFromPayload); +}; diff --git a/src/utils/log/index.ts b/src/utils/log/index.ts index 5691152c..0bbcb8d0 100644 --- a/src/utils/log/index.ts +++ b/src/utils/log/index.ts @@ -1,3 +1,5 @@ export {log} from './log'; /** @internal */ +export {logAndGetLogEvent} from './logAndGetLogEvent'; +/** @internal */ export {mapBackendResponseForLogs} from './mapBackendResponseForLogs'; diff --git a/src/utils/log/log.ts b/src/utils/log/log.ts index 14ae261a..6756a69f 100644 --- a/src/utils/log/log.ts +++ b/src/utils/log/log.ts @@ -1,35 +1,11 @@ -import {LogEventType} from '../../constants/internal'; -import {getRunId} from '../../context/runId'; +import {logAndGetLogEvent} from './logAndGetLogEvent'; -import {getFullPackConfig} from '../config'; -// eslint-disable-next-line import/no-internal-modules -import {registerLogEvent} from '../events/registerLogEvent'; - -import {logWithPreparedOptions} from './logWithPreparedOptions'; - -import type {Log, LogPayload, UtcTimeInMs} from '../../types/internal'; +import type {LogEventType} from '../../constants/internal'; +import type {Log, LogPayload} from '../../types/internal'; /** - * Logs every actions and API requests in e2ed tests. + * Logs message with payload. */ export const log: Log = (message, maybePayload?: unknown, maybeLogEventType?: unknown) => { - const time = Date.now() as UtcTimeInMs; - const runId = getRunId(); - const payload = typeof maybePayload === 'object' ? (maybePayload as LogPayload) : undefined; - const type = - typeof maybePayload === 'number' - ? (maybePayload as LogEventType) - : ((maybeLogEventType as LogEventType) ?? LogEventType.Unspecified); - - const {addLogsWithTags, mapLogPayloadInReport} = getFullPackConfig(); - - if (payload && 'logTag' in payload && !addLogsWithTags.includes(payload.logTag)) { - return; - } - - const payloadInReport = mapLogPayloadInReport(message, payload, type); - - registerLogEvent(runId, {message, payload: payloadInReport, time, type}); - - logWithPreparedOptions(message, {payload, runId, type, utcTimeInMs: time}); + logAndGetLogEvent(message, maybePayload as LogPayload, maybeLogEventType as LogEventType); }; diff --git a/src/utils/log/logAndGetLogEvent.ts b/src/utils/log/logAndGetLogEvent.ts new file mode 100644 index 00000000..993c6295 --- /dev/null +++ b/src/utils/log/logAndGetLogEvent.ts @@ -0,0 +1,49 @@ +import {LogEventType} from '../../constants/internal'; +import {getRunId} from '../../context/runId'; + +import {getFullPackConfig} from '../config'; +// eslint-disable-next-line import/no-internal-modules +import {registerLogEvent} from '../events/registerLogEvent'; + +import {logWithPreparedOptions} from './logWithPreparedOptions'; + +import type {Log, LogEvent, LogPayload, UtcTimeInMs} from '../../types/internal'; + +/** + * Logs message with payload and get log event. + * @internal + */ +export const logAndGetLogEvent: Log = ( + message, + maybePayload?: unknown, + maybeLogEventType?: unknown, +) => { + const time = Date.now() as UtcTimeInMs; + const runId = getRunId(); + const payload = typeof maybePayload === 'object' ? (maybePayload as LogPayload) : undefined; + const type = + typeof maybePayload === 'number' + ? (maybePayload as LogEventType) + : ((maybeLogEventType as LogEventType) ?? LogEventType.Unspecified); + + const {addLogsWithTags, mapLogPayloadInReport} = getFullPackConfig(); + + if (payload && 'logTag' in payload && !addLogsWithTags.includes(payload.logTag)) { + return; + } + + const payloadInReport = mapLogPayloadInReport(message, payload, type); + + const maybeLogEvent = registerLogEvent(runId, { + children: undefined, + endTime: undefined, + message, + payload: payloadInReport, + time, + type, + }); + + logWithPreparedOptions(message, {payload, runId, type, utcTimeInMs: time}); + + return maybeLogEvent; +}; diff --git a/src/utils/log/logBackendResponse.ts b/src/utils/log/logBackendResponse.ts index 8450a7b3..85bba998 100644 --- a/src/utils/log/logBackendResponse.ts +++ b/src/utils/log/logBackendResponse.ts @@ -1,72 +1,39 @@ import {LogEventType} from '../../constants/internal'; import {getRunId} from '../../context/runId'; +import {getStepsStack} from '../../context/stepsStack'; -import {getFullPackConfig} from '../config'; import {getTestRunEvent} from '../events'; +import {addBackendResponseToLogEvent} from './addBackendResponseToLogEvent'; import {log} from './log'; -import {logWithPreparedOptions} from './logWithPreparedOptions'; -import type {Mutable, Payload} from '../../types/internal'; - -const messageOfSingleResponse = 'Got a backend response to log'; +import type {LogEvent, Payload} from '../../types/internal'; /** * Logs backend response to last log event. * @internal */ export const logBackendResponse = (payload: Payload): void => { - const runId = getRunId(); - const {logEvents} = getTestRunEvent(runId); - - const lastLogEvent = logEvents[logEvents.length - 1]; - - if (lastLogEvent !== undefined) { - logWithPreparedOptions(messageOfSingleResponse, { - payload, - type: LogEventType.InternalUtil, - }); - - const {mapLogPayloadInReport} = getFullPackConfig(); + const stepsStack = getStepsStack(); + const runningStep = stepsStack.at(-1); - const payloadInReport = mapLogPayloadInReport( - messageOfSingleResponse, - {backendResponses: [payload]}, - LogEventType.InternalUtil, - ); + let lastLogEvent: LogEvent | undefined; - if (payloadInReport === 'skipLog' || payloadInReport === undefined) { - return; + if (runningStep !== undefined) { + if (runningStep.children !== undefined && runningStep.children.length > 0) { + lastLogEvent = runningStep.children.at(-1); + } else { + lastLogEvent = runningStep; } + } else { + const runId = getRunId(); + const {logEvents} = getTestRunEvent(runId); - if (lastLogEvent.payload === undefined) { - (lastLogEvent as Mutable).payload = payloadInReport; - - return; - } - - const backendResponsesFromPayload = payloadInReport.backendResponses; - - if (!(backendResponsesFromPayload instanceof Array)) { - return; - } - - const {backendResponses} = lastLogEvent.payload; - - if (backendResponses === undefined) { - (lastLogEvent.payload as Mutable).backendResponses = - backendResponsesFromPayload; - - return; - } - - const responseFromPayload = backendResponsesFromPayload[0]; - - if (responseFromPayload === undefined) { - return; - } + lastLogEvent = logEvents.at(-1); + } - (backendResponses as Payload[]).push(responseFromPayload); + if (lastLogEvent !== undefined) { + addBackendResponseToLogEvent(payload, lastLogEvent); return; } diff --git a/src/utils/report/client/index.ts b/src/utils/report/client/index.ts index 7a66f02e..32f7b763 100644 --- a/src/utils/report/client/index.ts +++ b/src/utils/report/client/index.ts @@ -17,8 +17,6 @@ export {clickOnTestRun} from './clickOnTestRun'; /** @internal */ export {createJsxRuntime} from './createJsxRuntime'; /** @internal */ -export {groupLogEvents} from './groupLogEvents'; -/** @internal */ export {initialScript} from './initialScript'; /** @internal */ export {onDomContentLoad} from './onDomContentLoad'; diff --git a/src/utils/report/client/render/Step.tsx b/src/utils/report/client/render/Step.tsx index 3fc2a39c..23fe9f54 100644 --- a/src/utils/report/client/render/Step.tsx +++ b/src/utils/report/client/render/Step.tsx @@ -4,11 +4,7 @@ import {Duration as clientDuration} from './Duration'; import {StepContent as clientStepContent} from './StepContent'; import {Steps as clientSteps} from './Steps'; -import type { - LogEventWithChildren, - ReportClientState, - UtcTimeInMs, -} from '../../../../types/internal'; +import type {LogEvent, ReportClientState, UtcTimeInMs} from '../../../../types/internal'; const Duration = clientDuration; const StepContent = clientStepContent; @@ -19,7 +15,7 @@ declare const reportClientState: ReportClientState; type Props = Readonly<{ isEnd?: boolean; - logEvent: LogEventWithChildren; + logEvent: LogEvent; nextLogEventTime: UtcTimeInMs; open?: boolean; }>; @@ -30,10 +26,11 @@ type Props = Readonly<{ * @internal */ export const Step: JSX.Component = ({isEnd = false, logEvent, nextLogEventTime, open}) => { - const {children, message, payload, time, type} = logEvent; + const baseRadix = 16; + const {children, endTime = nextLogEventTime, message, payload, time, type} = logEvent; const date = new Date(time).toISOString(); const isPayloadEmpty = !payload || Object.keys(payload).length === 0; - const popoverId = Math.random().toString(16).slice(2); + const popoverId = Math.random().toString(baseRadix).slice(2); const status = payload?.logEventStatus ?? LogEventStatus.Passed; let pathToScreenshotOfPage: string | undefined; @@ -54,11 +51,11 @@ export const Step: JSX.Component = ({isEnd = false, logEvent, nextLogEven if (!isEnd) { content = - isPayloadEmpty && children.length === 0 ? ( + isPayloadEmpty && (children === undefined || children.length === 0) ? (
{message} - +
) : ( @@ -66,7 +63,7 @@ export const Step: JSX.Component = ({isEnd = false, logEvent, nextLogEven {message} - + = ({isEnd = false, logEvent, nextLogEven payload={payload} type={type} /> - + ); } diff --git a/src/utils/report/client/render/Steps.tsx b/src/utils/report/client/render/Steps.tsx index cfe30b27..9421946e 100644 --- a/src/utils/report/client/render/Steps.tsx +++ b/src/utils/report/client/render/Steps.tsx @@ -5,7 +5,7 @@ import {assertValueIsDefined as clientAssertValueIsDefined} from '../assertValue import {List as clientList} from './List'; import {Step as clientStep} from './Step'; -import type {LogEventWithChildren, SafeHtml, UtcTimeInMs} from '../../../../types/internal'; +import type {LogEvent, SafeHtml, UtcTimeInMs} from '../../../../types/internal'; const assertValueIsDefined: typeof clientAssertValueIsDefined = clientAssertValueIsDefined; const List = clientList; @@ -16,7 +16,7 @@ declare const jsx: JSX.Runtime; type Props = Readonly<{ endTimeInMs: UtcTimeInMs; isRoot?: boolean; - logEvents: readonly LogEventWithChildren[]; + logEvents: readonly LogEvent[] | undefined; }>; /** @@ -25,14 +25,14 @@ type Props = Readonly<{ * @internal */ export const Steps: JSX.Component = ({endTimeInMs, isRoot = false, logEvents}) => { - if (logEvents.length === 0) { + if (logEvents === undefined || logEvents.length === 0) { return <>; } const stepHtmls: SafeHtml[] = []; for (let index = 0; index < logEvents.length; index += 1) { - const logEvent = logEvents[index]; + const logEvent: LogEvent | undefined = logEvents[index]; assertValueIsDefined(logEvent); @@ -44,8 +44,9 @@ export const Steps: JSX.Component = ({endTimeInMs, isRoot = false, logEve } if (isRoot) { - const endLogEvent: LogEventWithChildren = { + const endLogEvent: LogEvent = { children: [], + endTime: undefined, message: '', payload: undefined, time: endTimeInMs, diff --git a/src/utils/report/client/render/TestRunDetails.tsx b/src/utils/report/client/render/TestRunDetails.tsx index d6422220..925063de 100644 --- a/src/utils/report/client/render/TestRunDetails.tsx +++ b/src/utils/report/client/render/TestRunDetails.tsx @@ -1,5 +1,4 @@ import {assertValueIsDefined as clientAssertValueIsDefined} from '../assertValueIsDefined'; -import {groupLogEvents as clientGroupLogEvents} from '../groupLogEvents'; import {Steps as clientSteps} from './Steps'; import {TestRunDescription as clientTestRunDescription} from './TestRunDescription'; @@ -11,7 +10,6 @@ declare const jsx: JSX.Runtime; declare const reportClientState: ReportClientState; const assertValueIsDefined: typeof clientAssertValueIsDefined = clientAssertValueIsDefined; -const groupLogEvents = clientGroupLogEvents; const Steps = clientSteps; const TestRunDescription = clientTestRunDescription; const TestRunError = clientTestRunError; @@ -32,7 +30,6 @@ export const TestRunDetails: JSX.Component = ({fullTestRun}) => { assertValueIsDefined(firstStatusString); const capitalizedStatus = `${firstStatusString.toUpperCase()}${status.slice(1)}`; - const logEventsWithChildren = groupLogEvents(logEvents); return (
@@ -46,7 +43,7 @@ export const TestRunDetails: JSX.Component = ({fullTestRun}) => {

Execution

- +
); }; diff --git a/src/utils/report/getImgCspHosts.ts b/src/utils/report/getImgCspHosts.ts index e0a9381e..1201efb9 100644 --- a/src/utils/report/getImgCspHosts.ts +++ b/src/utils/report/getImgCspHosts.ts @@ -2,6 +2,8 @@ import {URL} from 'node:url'; import {LogEventType} from '../../constants/internal'; +import {flatLogEvents} from '../flatLogEvents'; + import type {ReportData} from '../../types/internal'; /** @@ -24,7 +26,7 @@ export const getImgCspHosts = (reportData: ReportData): string => { for (const {fullTestRuns} of retries) { for (const {logEvents} of fullTestRuns) { - for (const {payload, type} of logEvents) { + for (const {payload, type} of flatLogEvents(logEvents)) { // eslint-disable-next-line max-depth if (type !== LogEventType.InternalAssert || payload === undefined) { continue; diff --git a/styles/report.css b/styles/report.css index bc66611a..ccd1fcb9 100644 --- a/styles/report.css +++ b/styles/report.css @@ -1436,11 +1436,8 @@ button:focus-visible, padding-bottom: 25px; width: 120px; max-height: 100vh; - overflow-y: scroll; - } - .retry-links, - .test-details { - margin-bottom: var(--header-height); + overflow-y: auto; + scrollbar-gutter: stable; } .retry-links { max-width: 100%;