diff --git a/CHANGELOG.md b/CHANGELOG.md index cecbc875..c9a39b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,9 @@ - [fix: add `expect` method `toBeInViewport` instead of separate asserts](https://github.com/joomcode/e2ed/commit/0c50fe8f6b7c3adc20c21c66a334a15dd00c054f) ([uid11](https://github.com/uid11)) - [Merge pull request #125 from joomcode/feat/support-steps-in-actions](https://github.com/joomcode/e2ed/commit/72b7b394289085dc955f200621aa19f9e83f1e9e) ([uid11](https://github.com/uid11)) - feat: support steps in actions (for groupping steps) + feat: support steps in actions (for grouping steps) -- [PRO-16178 feat: support steps in actions (for groupping steps)](https://github.com/joomcode/e2ed/commit/ba860e4ed6f2e778374d8c0ddd4d758836a98859) ([uid11](https://github.com/uid11)) +- [PRO-16178 feat: support steps in actions (for grouping steps)](https://github.com/joomcode/e2ed/commit/ba860e4ed6f2e778374d8c0ddd4d758836a98859) ([uid11](https://github.com/uid11)) - [Merge pull request #124 from joomcode/feat/add-logs-with-children](https://github.com/joomcode/e2ed/commit/e8e72d924150feefcc382dfdcf7c86ca51127657) ([uid11](https://github.com/uid11)) feat: add functionality of `regroupSteps` in HTML reports diff --git a/README.md b/README.md index 6514f96d..9fa04f42 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,7 @@ If the mapping returns `undefined`, the log entry is not skipped, but is printed For example, if it is equal to three, the test will be run no more than three times. `navigationTimeout: number`: default timeout for navigation to url -(`navigateToPage`, `navigateToUrl` actions) in milliseconds. +(`navigateToUrl`, `setHeadersAndNavigateToUrl` actions) in milliseconds. `overriddenConfigFields: PlaywrightTestConfig | null`: if not `null`, then this value will override fields of internal `Playwright` config. diff --git a/autotests/configurator/regroupSteps.ts b/autotests/configurator/regroupSteps.ts index dc3a2890..42f22c2b 100644 --- a/autotests/configurator/regroupSteps.ts +++ b/autotests/configurator/regroupSteps.ts @@ -4,7 +4,7 @@ import {setReadonlyProperty} from 'e2ed/utils'; import type {LogEvent, Mutable} from 'e2ed/types'; /** - * Regroup log events (for groupping of `TestRun` steps). + * Regroup log events (for grouping of `TestRun` steps). * This base client function should not use scope variables (except other base functions). * @internal */ diff --git a/autotests/packs/allTests.ts b/autotests/packs/allTests.ts index c82826f9..225b2675 100644 --- a/autotests/packs/allTests.ts +++ b/autotests/packs/allTests.ts @@ -33,7 +33,7 @@ const browserFlags = [ const filterTestsIntoPack: FilterTestsIntoPack = ({options}) => options.meta.testId !== '13'; const userAgent = - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'; + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'; const msInMinute = 60_000; const packTimeoutInMinutes = 5; diff --git a/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts b/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts index f898dc11..8b47fd87 100644 --- a/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts +++ b/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts @@ -21,6 +21,11 @@ type CustomPageParams = * The e2ed report example page. */ export class E2edReportExample extends Page { + /** + * Page navigation timeout. + */ + static override readonly navigationTimeout = 5_000; + /** * Page header. */ @@ -47,11 +52,6 @@ export class E2edReportExample extends Page { readonly navigationRetriesButtonSelected: Selector = this.navigationRetriesButton.filterByLocatorParameter('selected', 'true'); - /** - * Page navigation timeout. - */ - override readonly navigationTimeout = 5_000; - /** * Cookies that we set (additionally) on a page before navigating to it. */ diff --git a/autotests/pageObjects/pages/Main.ts b/autotests/pageObjects/pages/Main.ts index 33aa9273..91880d35 100644 --- a/autotests/pageObjects/pages/Main.ts +++ b/autotests/pageObjects/pages/Main.ts @@ -15,6 +15,11 @@ type CustomPageParams = Partial | undefined; * The Main (index) page. */ export class Main extends Page { + /** + * Page navigation timeout. + */ + static override readonly navigationTimeout = 12_000; + /** * Body selector. */ diff --git a/autotests/tests/switchingPagesForRequests.skip.ts b/autotests/tests/switchingPagesForRequests.ts similarity index 86% rename from autotests/tests/switchingPagesForRequests.skip.ts rename to autotests/tests/switchingPagesForRequests.ts index 9610bad5..9d1534b9 100644 --- a/autotests/tests/switchingPagesForRequests.skip.ts +++ b/autotests/tests/switchingPagesForRequests.ts @@ -12,6 +12,7 @@ import { waitForRequestToRoute, waitForTimeout, } from 'e2ed/actions'; +import {LogEventType} from 'e2ed/constants'; import {log} from 'e2ed/utils'; const maxNumberOfRequests = 15; @@ -29,7 +30,7 @@ test( if (numberOfSentRequests < maxNumberOfRequests) { numberOfSentRequests += 1; - log(`Sent request number ${numberOfSentRequests}`); + log(`Sent request number ${numberOfSentRequests}`, LogEventType.Assert); void getUsers({retries: 1}); } @@ -39,7 +40,11 @@ test( predicate: (routeParams, request) => { numberOfCaughtRequests += 1; - log(`Caught request number ${numberOfCaughtRequests}`, {request, routeParams}); + log( + `Caught request number ${numberOfCaughtRequests}`, + {request, routeParams}, + LogEventType.Assert, + ); return false; }, diff --git a/autotests/tests/switchingPagesForResponses.skip.ts b/autotests/tests/switchingPagesForResponses.ts similarity index 87% rename from autotests/tests/switchingPagesForResponses.skip.ts rename to autotests/tests/switchingPagesForResponses.ts index ec037381..4eeedd5e 100644 --- a/autotests/tests/switchingPagesForResponses.skip.ts +++ b/autotests/tests/switchingPagesForResponses.ts @@ -12,6 +12,7 @@ import { waitForResponseToRoute, waitForTimeout, } from 'e2ed/actions'; +import {LogEventType} from 'e2ed/constants'; import {log} from 'e2ed/utils'; const maxNumberOfRequests = 15; @@ -29,7 +30,7 @@ test( if (numberOfSentRequests < maxNumberOfRequests) { numberOfSentRequests += 1; - log(`Sent request number ${numberOfSentRequests}`); + log(`Sent request number ${numberOfSentRequests}`, LogEventType.Assert); void getUsers({retries: 1}); } @@ -39,7 +40,11 @@ test( predicate: (routeParams, response) => { numberOfCaughtResponses += 1; - log(`Caught response number ${numberOfCaughtResponses}`, {response, routeParams}); + log( + `Caught response number ${numberOfCaughtResponses}`, + {response, routeParams}, + LogEventType.Assert, + ); return false; }, diff --git a/src/Page.ts b/src/Page.ts index 681c036a..45f57be6 100644 --- a/src/Page.ts +++ b/src/Page.ts @@ -24,6 +24,11 @@ import type { * Abstract page with base methods. */ export abstract class Page { + /** + * Default timeout for navigation to url (`navigateToPage`, `navigateToUrl` actions) in milliseconds. + */ + static readonly navigationTimeout: number = 8_000; + /** * Type of page parameters. */ @@ -40,12 +45,6 @@ export abstract class Page { */ readonly maxIntervalBetweenRequestsInMs: number; - /** - * Default timeout for navigation to url (`navigateToPage`, `navigateToUrl` actions) in milliseconds. - * The default value is taken from the corresponding field of the pack config. - */ - readonly navigationTimeout: number; - /** * Immutable page parameters. */ @@ -67,12 +66,10 @@ export abstract class Page { this.pageParams = pageParams as PageParams; const { - navigationTimeout, waitForAllRequestsComplete: {maxIntervalBetweenRequestsInMs}, } = getFullPackConfig(); this.maxIntervalBetweenRequestsInMs = maxIntervalBetweenRequestsInMs; - this.navigationTimeout = navigationTimeout; } /** @@ -132,7 +129,7 @@ export abstract class Page { ): Promise { const navigationReturn = await navigateToUrl(url, { skipLogs: true, - timeout: this.navigationTimeout, + timeout: (this.constructor as typeof Page).navigationTimeout, ...options, }); const {statusCode} = navigationReturn; diff --git a/src/README.md b/src/README.md index 8a7cb959..096f1e60 100644 --- a/src/README.md +++ b/src/README.md @@ -23,33 +23,35 @@ Modules in the dependency graph should only import the modules above them: 16. `utils/error` 17. `utils/asserts` 18. `utils/object` -19. `utils/uiMode` -20. `utils/runLabel` -21. `utils/clone` -22. `utils/notIncludedInPackTests` -23. `utils/userland` -24. `utils/fn` -25. `utils/environment` -26. `utils/packCompiler` -27. `config` -28. `utils/config` -29. `utils/generalLog` -30. `utils/testFilePaths` -31. `utils/exit` -32. `utils/promise` -33. `utils/resourceUsage` -34. `utils/fs` -35. `utils/getGlobalErrorHandler` -36. `utils/tests` -37. `utils/end` -38. `utils/pack` -39. `useContext` -40. `context` -41. `utils/apiStatistics` -42. `utils/selectors` -43. `selectors` -44. `utils/log` -45. `utils/waitForEvents` -46. `utils/expect` -47. `expect` -48. ... +19. `utils/step` +20. `utils/uiMode` +21. `utils/runLabel` +22. `utils/clone` +23. `utils/notIncludedInPackTests` +24. `utils/userland` +25. `utils/fn` +26. `utils/environment` +27. `utils/packCompiler` +28. `config` +29. `utils/config` +30. `utils/generalLog` +31. `utils/testFilePaths` +32. `utils/exit` +33. `utils/promise` +34. `utils/resourceUsage` +35. `utils/fs` +36. `utils/getGlobalErrorHandler` +37. `utils/tests` +38. `utils/end` +39. `utils/pack` +40. `useContext` +41. `context` +42. `utils/apiStatistics` +43. `utils/selectors` +44. `selectors` +45. `utils/log` +46. `step` +47. `utils/waitForEvents` +48. `utils/expect` +49. `expect` +50. ... diff --git a/src/actions/navigateToUrl.ts b/src/actions/navigateToUrl.ts index 3c0621a5..402c7c34 100644 --- a/src/actions/navigateToUrl.ts +++ b/src/actions/navigateToUrl.ts @@ -1,4 +1,4 @@ -import {LogEventType} from '../constants/internal'; +import {ADDITIONAL_STEP_TIMEOUT, LogEventType} from '../constants/internal'; import {step} from '../step'; import {getPlaywrightPage} from '../useContext'; @@ -11,7 +11,7 @@ export const navigateToUrl = async ( url: Url, options: NavigateToUrlOptions = {}, ): Promise => { - const {skipLogs = false} = options; + const {skipLogs = false, timeout} = options; let statusCode: StatusCode | undefined; await step( @@ -27,7 +27,12 @@ export const navigateToUrl = async ( return {statusCode}; }, - {payload: options, skipLogs, type: LogEventType.InternalAction}, + { + payload: options, + skipLogs, + ...(timeout !== undefined ? {timeout: timeout + ADDITIONAL_STEP_TIMEOUT} : undefined), + type: LogEventType.InternalAction, + }, ); return {statusCode}; diff --git a/src/actions/pages/assertPage.ts b/src/actions/pages/assertPage.ts index 4bfb586f..4dd2fcaf 100644 --- a/src/actions/pages/assertPage.ts +++ b/src/actions/pages/assertPage.ts @@ -1,4 +1,4 @@ -import {LogEventStatus, LogEventType} from '../../constants/internal'; +import {ADDITIONAL_STEP_TIMEOUT, LogEventStatus, LogEventType} from '../../constants/internal'; import {step} from '../../step'; import {assertValueIsDefined} from '../../utils/asserts'; import {getDocumentUrl} from '../../utils/document'; @@ -38,7 +38,11 @@ export const assertPage = async ( return {documentUrl, isMatch, logEventStatus, routeParams}; }, - {payload: {pageParams}, type: LogEventType.InternalAction}, + { + payload: {pageParams}, + timeout: PageClass.navigationTimeout + ADDITIONAL_STEP_TIMEOUT, + type: LogEventType.InternalAction, + }, ); assertValueIsDefined(page, 'page is defined', {name: PageClass.name, pageParams}); diff --git a/src/actions/pages/navigateToPage.ts b/src/actions/pages/navigateToPage.ts index 6eca948a..90fc69e3 100644 --- a/src/actions/pages/navigateToPage.ts +++ b/src/actions/pages/navigateToPage.ts @@ -1,4 +1,4 @@ -import {LogEventType} from '../../constants/internal'; +import {ADDITIONAL_STEP_TIMEOUT, LogEventType} from '../../constants/internal'; import {step} from '../../step'; import {addPageToApiStatistics} from '../../utils/apiStatistics'; import {assertValueIsDefined} from '../../utils/asserts'; @@ -56,7 +56,11 @@ export const navigateToPage = async ( return {documentUrl, isMatch, pageInstanceCreatedInMs, routeParams, url}; }, - {payload: {pageParams}, type: LogEventType.InternalAction}, + { + payload: {pageParams}, + timeout: PageClass.navigationTimeout + ADDITIONAL_STEP_TIMEOUT, + type: LogEventType.InternalAction, + }, ); assertValueIsDefined(page, 'page is defined', {name: PageClass.name, pageParams}); diff --git a/src/actions/setHeadersAndNavigateToUrl.ts b/src/actions/setHeadersAndNavigateToUrl.ts index 9ba866c7..7fc3a7e0 100644 --- a/src/actions/setHeadersAndNavigateToUrl.ts +++ b/src/actions/setHeadersAndNavigateToUrl.ts @@ -1,6 +1,6 @@ import {AsyncLocalStorage} from 'node:async_hooks'; -import {LogEventType} from '../constants/internal'; +import {ADDITIONAL_STEP_TIMEOUT, LogEventType} from '../constants/internal'; import {step} from '../step'; import {getPlaywrightPage} from '../useContext'; import {assertValueIsDefined} from '../utils/asserts'; @@ -26,6 +26,7 @@ export const setHeadersAndNavigateToUrl = async ( navigateToUrlOptions?: NavigateToUrlOptions, ): Promise => { let navigationReturn: NavigationReturn | undefined; + const timeout = navigateToUrlOptions?.timeout ?? getFullPackConfig().navigationTimeout; await step( `Navigate to ${url} and map headers`, @@ -42,8 +43,6 @@ export const setHeadersAndNavigateToUrl = async ( return route.fallback(); } - const timeout = navigateToUrlOptions?.timeout ?? getFullPackConfig().navigationTimeout; - const response = await route.fetch({timeout}); const headers = response.headers(); @@ -76,7 +75,11 @@ export const setHeadersAndNavigateToUrl = async ( return {requestHeaders, responseHeaders}; }, - {skipLogs, type: LogEventType.InternalAction}, + { + skipLogs, + timeout: timeout + ADDITIONAL_STEP_TIMEOUT, + type: LogEventType.InternalAction, + }, ); assertValueIsDefined(navigationReturn, 'navigationReturn is defined', { diff --git a/src/actions/waitFor/waitForAllRequestsComplete.ts b/src/actions/waitFor/waitForAllRequestsComplete.ts index b5be661b..4d9b63e7 100644 --- a/src/actions/waitFor/waitForAllRequestsComplete.ts +++ b/src/actions/waitFor/waitForAllRequestsComplete.ts @@ -134,7 +134,7 @@ export const waitForAllRequestsComplete = async ( allRequestsCompletePredicateWithPromise.setResolveTimeout(); } - await promiseWithTimeout; + await Promise.race([promiseWithTimeout, testRunPromise]); }, { payload: {maxIntervalBetweenRequests, predicate}, diff --git a/src/actions/waitFor/waitForNewTab.ts b/src/actions/waitFor/waitForNewTab.ts index bd78f68a..b5a8ceab 100644 --- a/src/actions/waitFor/waitForNewTab.ts +++ b/src/actions/waitFor/waitForNewTab.ts @@ -1,4 +1,5 @@ import {ADDITIONAL_STEP_TIMEOUT, LogEventType} from '../../constants/internal'; +import {getTestRunPromise} from '../../context/testRunPromise'; import {step} from '../../step'; import {getPlaywrightPage} from '../../useContext'; import {assertValueIsDefined} from '../../utils/asserts'; @@ -6,6 +7,8 @@ import {getFullPackConfig} from '../../utils/config'; import {setCustomInspectOnFunction} from '../../utils/fn'; import {getDurationWithUnits} from '../../utils/getDurationWithUnits'; +import type {Page} from '@playwright/test'; + import type {InternalTab, Tab, Trigger} from '../../types/internal'; type Options = Readonly<{ @@ -44,11 +47,13 @@ export const waitForNewTab = (async ( await trigger?.(); - const page = await pagePromise; + const testRunPromise = getTestRunPromise(); + + const page = await Promise.race([pagePromise, testRunPromise]); - newTab = {page}; + newTab = {page: page ?? ({} as unknown as Page)}; - const url = page.url(); + const url = page?.url(); return {url}; }, diff --git a/src/actions/waitFor/waitForStartOfPageLoad.ts b/src/actions/waitFor/waitForStartOfPageLoad.ts index ba942e03..51b1cb35 100644 --- a/src/actions/waitFor/waitForStartOfPageLoad.ts +++ b/src/actions/waitFor/waitForStartOfPageLoad.ts @@ -1,4 +1,5 @@ import {ADDITIONAL_STEP_TIMEOUT, LogEventType} from '../../constants/internal'; +import {getTestRunPromise} from '../../context/testRunPromise'; import {step} from '../../step'; import {getPlaywrightPage} from '../../useContext'; import {assertValueIsDefined} from '../../utils/asserts'; @@ -24,7 +25,7 @@ export const waitForStartOfPageLoad = async (options?: Options): Promise => let wasCalled = false; - await page.waitForURL( + const promise = page.waitForURL( (url) => { if (wasCalled === false) { wasCalled = true; @@ -39,9 +40,11 @@ export const waitForStartOfPageLoad = async (options?: Options): Promise => {timeout, waitUntil: 'commit'}, ); - assertValueIsDefined(urlObject, 'urlObject is defined', {timeout}); + const testRunPromise = getTestRunPromise(); - return {url: urlObject.href}; + await Promise.race([promise, testRunPromise]); + + return {url: urlObject?.href}; }, { payload: {options}, diff --git a/src/constants/index.ts b/src/constants/index.ts index 1ca71274..c1e78f12 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -9,6 +9,6 @@ export { NOT_FOUND_STATUS_CODE, OK_STATUS_CODE, } from './http'; -export {LogEventStatus, LogEventType} from './log'; +export {BACKEND_RESPONSES_LOG_MESSAGE, LogEventStatus, LogEventType} from './log'; export {FAILED_TEST_RUN_STATUSES, TestRunStatus} from './testRun'; export {ANY_URL_REGEXP, SLASHES_AT_THE_END_REGEXP, SLASHES_AT_THE_START_REGEXP} from './url'; diff --git a/src/constants/internal.ts b/src/constants/internal.ts index 545a528d..97aae588 100644 --- a/src/constants/internal.ts +++ b/src/constants/internal.ts @@ -37,7 +37,7 @@ export { MAX_LINES_COUNT_IN_PRINTED_VALUE, MAX_STRING_LENGTH_IN_PRINTED_VALUE, } from './inspect'; -export {LogEventStatus, LogEventType} from './log'; +export {BACKEND_RESPONSES_LOG_MESSAGE, LogEventStatus, LogEventType} from './log'; /** @internal */ export {ADDITIONAL_STEP_TIMEOUT, MESSAGE_BACKGROUND_COLOR_BY_STATUS} from './log'; /** @internal */ diff --git a/src/constants/log.ts b/src/constants/log.ts index 1d4492f9..e8955e78 100644 --- a/src/constants/log.ts +++ b/src/constants/log.ts @@ -7,6 +7,11 @@ import {TestRunStatus} from './testRun'; */ export const ADDITIONAL_STEP_TIMEOUT = 1_000; +/** + * Message of log for backend responses. + */ +export const BACKEND_RESPONSES_LOG_MESSAGE = 'Got a backend responses to log'; + /** * Status of `LogEvent`. */ diff --git a/src/context/stepsStack.ts b/src/context/stepsStack.ts deleted file mode 100644 index 25e9c43f..00000000 --- a/src/context/stepsStack.ts +++ /dev/null @@ -1,27 +0,0 @@ -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/context/stepsStackStorage.ts b/src/context/stepsStackStorage.ts new file mode 100644 index 00000000..4df93dc2 --- /dev/null +++ b/src/context/stepsStackStorage.ts @@ -0,0 +1,30 @@ +import {AsyncLocalStorage} from 'node:async_hooks'; + +import {useContext} from '../useContext'; + +import type {LogEvent} from '../types/internal'; + +/** + * Raw get and set (maybe `undefined`) of test steps stack storage. + * @internal + */ +const [getRawStepsStackStorage, setRawStepsStackStorage] = + useContext>(); + +/** + * Get always defined test steps stack storage. + * @internal + */ +export const getStepsStackStorage = (): AsyncLocalStorage => { + const maybeStepsStackStorage = getRawStepsStackStorage(); + + if (maybeStepsStackStorage !== undefined) { + return maybeStepsStackStorage; + } + + const stepsStackStorage = new AsyncLocalStorage(); + + setRawStepsStackStorage(stepsStackStorage); + + return stepsStackStorage; +}; diff --git a/src/step.ts b/src/step.ts index ad2ece9f..5130d61b 100644 --- a/src/step.ts +++ b/src/step.ts @@ -1,8 +1,7 @@ -/* eslint-disable max-lines */ - -import {LogEventStatus, LogEventType} from './constants/internal'; -import {getStepsStack} from './context/stepsStack'; +import {LogEventType} from './constants/internal'; +import {getStepsStackStorage} from './context/stepsStackStorage'; import {getTestIdleTimeout} from './context/testIdleTimeout'; +import {getTestRunPromise} from './context/testRunPromise'; import {E2edError} from './utils/error'; import {setCustomInspectOnFunction} from './utils/fn'; import {generalLog} from './utils/generalLog'; @@ -10,41 +9,34 @@ import {getDurationWithUnits} from './utils/getDurationWithUnits'; import {logAndGetLogEvent} from './utils/log'; import {setReadonlyProperty} from './utils/object'; import {addTimeoutToPromise} from './utils/promise'; +import {processStepError} from './utils/step'; import type { LogEvent, LogPayload, - MaybePromise, - Mutable, + StepBody, + StepErrorProperties, + StepOptions, UtcTimeInMs, Void, } from './types/internal'; import {test as playwrightTest} from '@playwright/test'; -type Options = Readonly<{ - payload?: LogPayload; - runPlaywrightStep?: boolean; - skipLogs?: boolean; - timeout?: number; - type?: LogEventType; -}>; - /** * Declares a test step (could calls Playwright's `test.step` function inside). */ -// eslint-disable-next-line complexity, max-statements, max-lines-per-function +// eslint-disable-next-line complexity, max-statements export const step = async ( name: string, - body?: () => MaybePromise, - options?: Options, + body?: StepBody, + options: StepOptions = {}, ): Promise => { if (body !== undefined) { setCustomInspectOnFunction(body); } let logEvent: LogEvent | undefined; - const stepsStack = getStepsStack(); const timeout: number = options?.timeout ?? getTestIdleTimeout(); if (options?.skipLogs !== true) { @@ -55,22 +47,35 @@ export const step = async ( ); } - if (logEvent !== undefined) { - (stepsStack as Mutable).push(logEvent); - } - - const errorProperties = {stepBody: body, stepName: name, stepOptions: options}; + const errorProperties: StepErrorProperties = { + stepBody: body, + stepName: name, + stepOptions: options, + }; + let isTestRunCompleted = false; let payload = undefined as LogPayload | Void; let stepError: unknown; try { + const testRunPromise = getTestRunPromise(); + + void testRunPromise.then(() => { + isTestRunCompleted = true; + }); + const timeoutError = new E2edError( `Body of step "${name}" rejected after ${getDurationWithUnits(timeout)} timeout`, errorProperties, ); const runBody = async (): Promise => { - payload = await body?.(); + if (logEvent !== undefined && typeof body === 'function') { + const stepsStackStorage = getStepsStackStorage(); + + payload = await stepsStackStorage.run(logEvent, body); + } else { + payload = await body?.(); + } }; let bodyError: unknown; @@ -92,40 +97,10 @@ export const step = async ( throw bodyError; } } catch (error) { - stepError = error; - - if ( - !(stepError instanceof E2edError) && - Object.getOwnPropertySymbols(stepError ?? {}).length > 0 - ) { - stepError = new E2edError('Caught an error in step', { - cause: String(stepError), - ...errorProperties, - }); - } + stepError = processStepError({error, errorProperties, logEvent}); - if (stepError !== null && (typeof stepError === 'object' || typeof stepError === 'function')) { - if (!('stepName' in stepError)) { - Object.assign( - stepError, - errorProperties, - 'message' in stepError ? {originalMessage: stepError.message} : undefined, - ); - } - } else { - stepError = new E2edError('Caught an error in step', {cause: stepError, ...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, - }); - } + if (isTestRunCompleted) { + await new Promise(() => {}); } throw stepError; @@ -139,20 +114,6 @@ export const step = async ( setReadonlyProperty(logEvent, 'payload', {...logEvent.payload, ...payload}); } - const logEventIndex = stepsStack.findIndex((event) => event === logEvent); - - if (logEventIndex === -1) { - // eslint-disable-next-line no-unsafe-finally - throw new E2edError('Cannot find running step in test steps stack', { - runningStep: logEvent, - stepBody: body, - stepError, - stepOptions: options, - }); - } - - (stepsStack as Mutable).splice(logEventIndex, 1); - generalLog(`Step "${name}" completed`, { body, step: {...logEvent, children: logEvent.children?.map(({message}) => message)}, diff --git a/src/types/config/ownE2edConfig.ts b/src/types/config/ownE2edConfig.ts index 7afb2db2..3221e93d 100644 --- a/src/types/config/ownE2edConfig.ts +++ b/src/types/config/ownE2edConfig.ts @@ -172,7 +172,7 @@ export type OwnE2edConfig< maxRetriesCountInDocker: number; /** - * Default timeout for navigation to url (`navigateToPage`, `navigateToUrl` actions) in milliseconds. + * Default timeout for navigation to url (`navigateToUrl`, `setHeadersAndNavigateToUrl` actions) in milliseconds. */ navigationTimeout: number; diff --git a/src/types/index.ts b/src/types/index.ts index 384ed0de..cb40bca4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -84,6 +84,7 @@ export type { export type {CreateSelector, CreateSelectorFunctionOptions, Selector} from './selectors'; export type {StackFrame} from './stackTrace'; export type {PackageInfo, StartInfo} from './startInfo'; +export type {StepBody, StepOptions} from './step'; export type {StringForLogs} from './string'; export type {Tab} from './tab'; export type {MergeTuples, TupleRest} from './tuples'; diff --git a/src/types/internal.ts b/src/types/internal.ts index 2d4ddce7..aef8191c 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -140,6 +140,9 @@ export type {SelectorPropertyRetryData} from './selectors'; export type {IsTestSkippedResult} from './skipTest'; export type {StackFrame} from './stackTrace'; export type {PackageInfo, StartInfo} from './startInfo'; +export type {StepBody, StepOptions} from './step'; +/** @internal */ +export type {StepErrorProperties} from './step'; export type {StringForLogs} from './string'; export type {Tab} from './tab'; /** @internal */ diff --git a/src/types/pages.ts b/src/types/pages.ts index 4cb6d1ab..98df91c5 100644 --- a/src/types/pages.ts +++ b/src/types/pages.ts @@ -12,7 +12,11 @@ export type PageClassTypeArgs = OneOrTwoArgs = Class, Page>; +export type PageClassType = Class< + PageClassTypeArgs, + Page, + Readonly<{navigationTimeout: number}> +>; /** * Base page class type for any page. diff --git a/src/types/step.ts b/src/types/step.ts new file mode 100644 index 00000000..e5ee53f5 --- /dev/null +++ b/src/types/step.ts @@ -0,0 +1,31 @@ +import type {LogEventType} from '../constants/internal'; + +import type {LogPayload} from './log'; +import type {MaybePromise} from './promise'; +import type {Void} from './undefined'; + +/** + * Body function of step. + */ +export type StepBody = () => MaybePromise; + +/** + * Error properties of step. + * @internal + */ +export type StepErrorProperties = Readonly<{ + stepBody: StepBody | undefined; + stepName: string; + stepOptions: StepOptions; +}>; + +/** + * Options of `step` function. + */ +export type StepOptions = Readonly<{ + payload?: LogPayload; + runPlaywrightStep?: boolean; + skipLogs?: boolean; + timeout?: number; + type?: LogEventType; +}>; diff --git a/src/utils/events/registerLogEvent.ts b/src/utils/events/registerLogEvent.ts index f41bf3e3..26a22577 100644 --- a/src/utils/events/registerLogEvent.ts +++ b/src/utils/events/registerLogEvent.ts @@ -1,6 +1,5 @@ -import {getStepsStack} from '../../context/stepsStack'; - import {setReadonlyProperty} from '../object'; +import {getTopStep} from '../step'; import {getTestRunEvent} from './getTestRunEvent'; @@ -23,14 +22,13 @@ export const registerLogEvent = ( if (logEventWithMaybeSkippedPayload.payload !== 'skipLog') { logEvent = logEventWithMaybeSkippedPayload as LogEvent; - const stepsStack = getStepsStack(); - const runningStep = stepsStack.at(-1); + const topStep = getTopStep(); - if (runningStep !== undefined) { - if (runningStep.children !== undefined) { - (runningStep.children as Mutable).push(logEvent); + if (topStep !== undefined) { + if (topStep.children !== undefined) { + (topStep.children as Mutable).push(logEvent); } else { - setReadonlyProperty(runningStep, 'children', [logEvent]); + setReadonlyProperty(topStep, 'children', [logEvent]); } } else { (runTestEvent.logEvents as Mutable).push(logEvent); diff --git a/src/utils/expect/createExpectMethod.ts b/src/utils/expect/createExpectMethod.ts index cd446a13..b9d7a732 100644 --- a/src/utils/expect/createExpectMethod.ts +++ b/src/utils/expect/createExpectMethod.ts @@ -1,25 +1,23 @@ -import {LogEventStatus, LogEventType, RESOLVED_PROMISE, RETRY_KEY} from '../../constants/internal'; +import {LogEventStatus, LogEventType, RETRY_KEY} from '../../constants/internal'; +import {assertValueIsDefined} from '../asserts'; import {getFullPackConfig} from '../config'; import {E2edError} from '../error'; import {getDurationWithUnits} from '../getDurationWithUnits'; -import {log} from '../log'; +import {logAndGetLogEvent} from '../log'; import {setReadonlyProperty} from '../object'; import {addTimeoutToPromise} from '../promise'; import {Selector} from '../selectors'; import {isThenable} from '../typeGuards'; import {removeStyleFromString, valueToString, wrapStringForLogs} from '../valueToString'; -import {additionalMatchers} from './additionalMatchers'; -import {applyAdditionalMatcher} from './applyAdditionalMatcher'; +import {getAssertionPromise} from './getAssertionPromise'; -import type {Fn, SelectorPropertyRetryData} from '../../types/internal'; +import type {SelectorPropertyRetryData, UtcTimeInMs} from '../../types/internal'; -import type {Expect} from './Expect'; +import type {additionalMatchers} from './additionalMatchers'; import type {AssertionFunction, ExpectMethod} from './types'; -import {expect as playwrightExpect} from '@playwright/test'; - const additionalAssertionTimeoutInMs = 1_000; /** @@ -40,67 +38,14 @@ export const createExpectMethod = ( const selectorPropertyRetryData = ( this.actualValue as {[RETRY_KEY]?: SelectorPropertyRetryData} )?.[RETRY_KEY]; - const timeoutWithUnits = getDurationWithUnits(timeout); - const timeoutError = new E2edError( - `"${key}" assertion promise rejected after ${timeoutWithUnits} timeout`, - ); - const runAssertion = (value: unknown): Promise => { - const additionalMatcher = additionalMatchers[key as keyof typeof additionalMatchers]; - const ctx: Expect = {actualValue: value, description: this.description}; - - if (additionalMatcher !== undefined) { - return addTimeoutToPromise( - applyAdditionalMatcher( - additionalMatcher as Fn>, - ctx, - args, - selectorPropertyRetryData, - ), - timeout, - timeoutError, - ).catch((assertError: Error) => { - setReadonlyProperty(ctx, 'error', assertError); - - return ctx; - }); - } - - const assertion = playwrightExpect(value, ctx.description) as unknown as Record< - string, - Fn> - >; - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return addTimeoutToPromise(assertion[key]!(...args), timeout, timeoutError).then( - () => ctx, - (assertError: Error) => { - setReadonlyProperty(ctx, 'error', assertError); - - return ctx; - }, - ); - }; - - const assertionPromise: Promise = RESOLVED_PROMISE.then(() => { - if (isThenable(this.actualValue)) { - return addTimeoutToPromise( - this.actualValue as Promise, - timeout, - timeoutError, - ).then(runAssertion); - } - - return runAssertion(this.actualValue); - }); - - return assertionPromise.then(({actualValue, additionalLogFields, error}) => { - const logMessage = `Assert: ${this.description}`; - const logPayload = { + const printedValue = isThenable(this.actualValue) ? '' : this.actualValue; + const logEvent = logAndGetLogEvent( + `Assert: ${this.description}`, + { + actualValue: printedValue, + assertion: wrapStringForLogs(`value ${valueToString(printedValue)} ${message}`), assertionArguments: args, - ...additionalLogFields, - error: error?.message === undefined ? undefined : removeStyleFromString(error.message), - logEventStatus: error ? LogEventStatus.Failed : LogEventStatus.Passed, selector: selectorPropertyRetryData?.selector.description ?? (this.actualValue instanceof Selector ? this.actualValue.description : undefined), @@ -110,22 +55,50 @@ export const createExpectMethod = ( selectorPropertyArgs: selectorPropertyRetryData.args, } : undefined), - }; + }, + LogEventType.InternalAssert, + ); + + assertValueIsDefined(logEvent, 'logEvent is defined', {args, message, ...this}); + + const {payload} = logEvent; + + assertValueIsDefined(payload, 'payload is defined', {args, message, ...this}); + + const timeoutError = new E2edError( + `"${key}" assertion promise rejected after ${getDurationWithUnits(timeout)} timeout`, + ); + + const assertionPromise = getAssertionPromise({ + args, + context: this, + key: key as keyof typeof additionalMatchers, + selectorPropertyRetryData, + timeout, + timeoutError, + }); + + return assertionPromise.then(({actualValue, additionalLogFields, error}) => { + Object.assign(payload, { + ...additionalLogFields, + error: error?.message === undefined ? undefined : removeStyleFromString(error.message), + logEventStatus: error ? LogEventStatus.Failed : LogEventStatus.Passed, + }); return addTimeoutToPromise(Promise.resolve(actualValue), timeout, timeoutError) .then( - (value) => - log( - logMessage, - { - actualValue: value, - assertion: wrapStringForLogs(`value ${valueToString(value)} ${message}`), - ...logPayload, - }, - LogEventType.InternalAssert, - ), + (value) => { + Object.assign(payload, { + actualValue: value, + assertion: wrapStringForLogs(`value ${valueToString(value)} ${message}`), + }); + + setReadonlyProperty(logEvent, 'endTime', Date.now() as UtcTimeInMs); + }, (actualValueResolveError: Error) => { - log(logMessage, {actualValueResolveError, ...logPayload}, LogEventType.InternalAssert); + Object.assign(payload, {actualValueResolveError}); + + setReadonlyProperty(logEvent, 'endTime', Date.now() as UtcTimeInMs); }, ) .then(() => { diff --git a/src/utils/expect/getAssertionPromise.ts b/src/utils/expect/getAssertionPromise.ts new file mode 100644 index 00000000..ede24439 --- /dev/null +++ b/src/utils/expect/getAssertionPromise.ts @@ -0,0 +1,90 @@ +import {RESOLVED_PROMISE} from '../../constants/internal'; + +import {setReadonlyProperty} from '../object'; +import {addTimeoutToPromise} from '../promise'; +import {isThenable} from '../typeGuards'; + +import {additionalMatchers} from './additionalMatchers'; +import {applyAdditionalMatcher} from './applyAdditionalMatcher'; + +import type {Fn, SelectorPropertyRetryData} from '../../types/internal'; + +import type {E2edError} from '../error'; + +import type {Expect} from './Expect'; +import type {ExpectMethod} from './types'; + +import {expect as playwrightExpect} from '@playwright/test'; + +type Options = Readonly<{ + args: Parameters; + context: Expect; + key: keyof typeof additionalMatchers; + selectorPropertyRetryData: SelectorPropertyRetryData | undefined; + timeout: number; + timeoutError: E2edError; +}>; + +/** + * Get internal assertion promise by assertion options. + * @internal + */ +export const getAssertionPromise = ({ + args, + context, + key, + selectorPropertyRetryData, + timeout, + timeoutError, +}: Options): Promise => { + const runAssertion = (value: unknown): Promise => { + const additionalMatcher = additionalMatchers[key]; + const ctx: Expect = {actualValue: value, description: context.description}; + + if (additionalMatcher !== undefined) { + return addTimeoutToPromise( + applyAdditionalMatcher( + additionalMatcher as Fn>, + ctx, + args, + selectorPropertyRetryData, + ), + timeout, + timeoutError, + ).catch((assertError: Error) => { + setReadonlyProperty(ctx, 'error', assertError); + + return ctx; + }); + } + + const assertion = playwrightExpect(value, ctx.description) as unknown as Record< + string, + Fn> + >; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return addTimeoutToPromise(assertion[key]!(...args), timeout, timeoutError).then( + () => ctx, + (assertError: Error) => { + setReadonlyProperty(ctx, 'error', assertError); + + return ctx; + }, + ); + }; + + const assertionPromise: Promise = RESOLVED_PROMISE.then(() => { + if (isThenable(context.actualValue)) { + return addTimeoutToPromise( + context.actualValue as Promise, + timeout, + timeoutError, + ).then(runAssertion); + } + + return runAssertion(context.actualValue); + }); + + return assertionPromise; +}; diff --git a/src/utils/log/addBackendResponseToLogEvent.ts b/src/utils/log/addBackendResponseToLogEvent.ts index c60e72b8..236fcb59 100644 --- a/src/utils/log/addBackendResponseToLogEvent.ts +++ b/src/utils/log/addBackendResponseToLogEvent.ts @@ -3,9 +3,10 @@ import {LogEventType} from '../../constants/internal'; import {getFullPackConfig} from '../config'; import {setReadonlyProperty} from '../object'; +import {getBackendResponsesLogEvent} from './getBackendResponsesLogEvent'; import {logWithPreparedOptions} from './logWithPreparedOptions'; -import type {LogEvent, Payload} from '../../types/internal'; +import type {LogEvent, Mutable, Payload} from '../../types/internal'; const messageOfSingleResponse = 'Got a backend response to log'; @@ -31,8 +32,10 @@ export const addBackendResponseToLogEvent = (payload: Payload, logEvent: LogEven return; } - if (logEvent.payload === undefined) { - setReadonlyProperty(logEvent, 'payload', payloadInReport); + const backendResponsesLogEvent = getBackendResponsesLogEvent(logEvent); + + if (backendResponsesLogEvent.payload === undefined) { + setReadonlyProperty(backendResponsesLogEvent, 'payload', payloadInReport); return; } @@ -43,10 +46,14 @@ export const addBackendResponseToLogEvent = (payload: Payload, logEvent: LogEven return; } - const {backendResponses} = logEvent.payload; + const {backendResponses} = backendResponsesLogEvent.payload; if (backendResponses === undefined) { - setReadonlyProperty(logEvent.payload, 'backendResponses', backendResponsesFromPayload); + setReadonlyProperty( + backendResponsesLogEvent.payload, + 'backendResponses', + backendResponsesFromPayload, + ); return; } @@ -57,5 +64,5 @@ export const addBackendResponseToLogEvent = (payload: Payload, logEvent: LogEven return; } - (backendResponses as Payload[]).push(responseFromPayload); + (backendResponses as Mutable).push(responseFromPayload); }; diff --git a/src/utils/log/getBackendResponsesLogEvent.ts b/src/utils/log/getBackendResponsesLogEvent.ts new file mode 100644 index 00000000..b573f967 --- /dev/null +++ b/src/utils/log/getBackendResponsesLogEvent.ts @@ -0,0 +1,39 @@ +import {BACKEND_RESPONSES_LOG_MESSAGE, LogEventType} from '../../constants/internal'; + +import {setReadonlyProperty} from '../object'; + +import type {LogEvent, Mutable, UtcTimeInMs} from '../../types/internal'; + +/** + * Get log event for backend responses. + * @internal + */ +export const getBackendResponsesLogEvent = (logEvent: LogEvent): LogEvent => { + if (logEvent.message === BACKEND_RESPONSES_LOG_MESSAGE) { + return logEvent; + } + + if ( + logEvent.children !== undefined && + logEvent.children.at(-1)?.message === BACKEND_RESPONSES_LOG_MESSAGE + ) { + return logEvent.children.at(-1) as LogEvent; + } + + const backendResponsesLogEvent: LogEvent = { + children: undefined, + endTime: undefined, + message: BACKEND_RESPONSES_LOG_MESSAGE, + payload: undefined, + time: Date.now() as UtcTimeInMs, + type: LogEventType.InternalUtil, + }; + + if (logEvent.children !== undefined) { + (logEvent.children as Mutable).push(backendResponsesLogEvent); + } else { + setReadonlyProperty(logEvent, 'children', [backendResponsesLogEvent]); + } + + return backendResponsesLogEvent; +}; diff --git a/src/utils/log/logBackendResponse.ts b/src/utils/log/logBackendResponse.ts index 85bba998..581e190a 100644 --- a/src/utils/log/logBackendResponse.ts +++ b/src/utils/log/logBackendResponse.ts @@ -1,8 +1,8 @@ -import {LogEventType} from '../../constants/internal'; +import {BACKEND_RESPONSES_LOG_MESSAGE, LogEventType} from '../../constants/internal'; import {getRunId} from '../../context/runId'; -import {getStepsStack} from '../../context/stepsStack'; import {getTestRunEvent} from '../events'; +import {getTopStep} from '../step'; import {addBackendResponseToLogEvent} from './addBackendResponseToLogEvent'; import {log} from './log'; @@ -14,16 +14,15 @@ import type {LogEvent, Payload} from '../../types/internal'; * @internal */ export const logBackendResponse = (payload: Payload): void => { - const stepsStack = getStepsStack(); - const runningStep = stepsStack.at(-1); + const topStep = getTopStep(); let lastLogEvent: LogEvent | undefined; - if (runningStep !== undefined) { - if (runningStep.children !== undefined && runningStep.children.length > 0) { - lastLogEvent = runningStep.children.at(-1); + if (topStep !== undefined) { + if (topStep.children !== undefined && topStep.children.length > 0) { + lastLogEvent = topStep.children.at(-1); } else { - lastLogEvent = runningStep; + lastLogEvent = topStep; } } else { const runId = getRunId(); @@ -38,5 +37,5 @@ export const logBackendResponse = (payload: Payload): void => { return; } - log('Got a backend responses to log', {backendResponses: [payload]}, LogEventType.InternalUtil); + log(BACKEND_RESPONSES_LOG_MESSAGE, {backendResponses: [payload]}, LogEventType.InternalUtil); }; diff --git a/src/utils/step/getTopStep.ts b/src/utils/step/getTopStep.ts new file mode 100644 index 00000000..7760a7bc --- /dev/null +++ b/src/utils/step/getTopStep.ts @@ -0,0 +1,13 @@ +import {getStepsStackStorage} from '../../context/stepsStackStorage'; + +import type {LogEvent} from '../../types/internal'; + +/** + * Get current top step (log event), if any. + * @internal + */ +export const getTopStep = (): LogEvent | undefined => { + const stepsStackStorage = getStepsStackStorage(); + + return stepsStackStorage.getStore(); +}; diff --git a/src/utils/step/index.ts b/src/utils/step/index.ts new file mode 100644 index 00000000..aaa2a362 --- /dev/null +++ b/src/utils/step/index.ts @@ -0,0 +1,4 @@ +/** @internal */ +export {getTopStep} from './getTopStep'; +/** @internal */ +export {processStepError} from './processStepError'; diff --git a/src/utils/step/processStepError.ts b/src/utils/step/processStepError.ts new file mode 100644 index 00000000..b4fd45ee --- /dev/null +++ b/src/utils/step/processStepError.ts @@ -0,0 +1,57 @@ +import {LogEventStatus} from '../../constants/internal'; + +import {E2edError} from '../error'; +import {setReadonlyProperty} from '../object'; + +import type {LogEvent, StepErrorProperties} from '../../types/internal'; + +type Options = Readonly<{ + error: unknown; + errorProperties: StepErrorProperties; + logEvent: LogEvent | undefined; +}>; + +/** + * Processes `step` error. + * @internal + */ +// eslint-disable-next-line complexity +export const processStepError = ({error, errorProperties, logEvent}: Options): unknown => { + let stepError: unknown = error; + + if ( + !(stepError instanceof E2edError) && + Object.getOwnPropertySymbols(stepError ?? {}).length > 0 + ) { + stepError = new E2edError('Caught an error in step', { + cause: String(stepError), + ...errorProperties, + }); + } + + if (stepError !== null && (typeof stepError === 'object' || typeof stepError === 'function')) { + if (!('stepName' in stepError)) { + Object.assign( + stepError, + errorProperties, + 'message' in stepError ? {originalMessage: stepError.message} : undefined, + ); + } + } else { + stepError = new E2edError('Caught an error in step', {cause: stepError, ...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, + }); + } + } + + return stepError; +};