diff --git a/README.md b/README.md index d633a78b..bd4d4dd7 100644 --- a/README.md +++ b/README.md @@ -325,16 +325,15 @@ If the mapping returns `undefined`, the log entry is not skipped, but is printed `your-project/autotests/bin/runDocker.sh` (until the test passes). 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. + `overriddenConfigFields: PlaywrightTestConfig | null`: if not `null`, then this value will override fields of internal Playwright config. `packTimeout: number`: timeout (in millisecond) for the entire pack of tests (tasks). If the test pack takes longer than this timeout, the pack will fail with the appropriate error. -`pageStabilizationInterval: number`: after navigating to the page, `e2ed` will wait until -the page is stable for the specified time in millisecond, and only after that it will consider the page loaded. -This parameter can be overridden on a specific page instance. - `pathToScreenshotsDirectoryForReport: string | null`: path to the directory where screenshots will be stored for displaying them in the HTML report. This path must be either relative (from the HTML report file) or absolute (i.e. with http/https protocol). diff --git a/autotests/packs/allTests.ts b/autotests/packs/allTests.ts index f06b6e4d..be8a3699 100644 --- a/autotests/packs/allTests.ts +++ b/autotests/packs/allTests.ts @@ -65,10 +65,9 @@ export const pack: Pack = { mapLogPayloadInLogFile, mapLogPayloadInReport, maxRetriesCountInDocker: 3, + navigationTimeout: 6_000, overriddenConfigFields: null, packTimeout: packTimeoutInMinutes * msInMinute, - pageRequestTimeout: 7_000, - pageStabilizationInterval: 500, pathToScreenshotsDirectoryForReport: './screenshots', port1: 1337, port2: 1338, diff --git a/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts b/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts index ef5d36f6..4772930a 100644 --- a/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts +++ b/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts @@ -37,6 +37,8 @@ export class E2edReportExample extends Page { readonly navigationRetriesButtonSelected: Selector = this.navigationRetriesButton.filterByLocatorParameter('selected', 'true'); + override readonly navigationTimeout = 5_000; + /** * Cookies that we set (additionally) on a page before navigating to it. */ @@ -47,8 +49,6 @@ export class E2edReportExample extends Page { */ readonly pageRequestHeaders: StringHeaders | undefined; - override readonly pageStabilizationInterval = 600; - /** * Test run button. */ diff --git a/package-lock.json b/package-lock.json index 7849681c..3112750b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ }, "devDependencies": { "@playwright/browser-chromium": "1.49.0", - "@types/node": "22.9.0", + "@types/node": "22.10.1", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "assert-modules-support-case-insensitive-fs": "1.0.1", @@ -33,8 +33,8 @@ "eslint-plugin-simple-import-sort": "12.1.1", "eslint-plugin-typescript-sort-keys": "3.3.0", "husky": "9.1.7", - "prettier": "3.3.3", - "typescript": "5.6.3" + "prettier": "3.4.1", + "typescript": "5.7.2" }, "engines": { "node": ">=16.11.1" @@ -228,13 +228,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "version": "22.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", + "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.19.8" + "undici-types": "~6.20.0" } }, "node_modules/@types/semver": { @@ -2780,10 +2780,11 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.1.tgz", + "integrity": "sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -3302,9 +3303,9 @@ } }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3331,9 +3332,9 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index e592e791..5ffe96f1 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@playwright/browser-chromium": "1.49.0", - "@types/node": "22.9.0", + "@types/node": "22.10.1", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "assert-modules-support-case-insensitive-fs": "1.0.1", @@ -44,8 +44,8 @@ "eslint-plugin-simple-import-sort": "12.1.1", "eslint-plugin-typescript-sort-keys": "3.3.0", "husky": "9.1.7", - "prettier": "3.3.3", - "typescript": "5.6.3" + "prettier": "3.4.1", + "typescript": "5.7.2" }, "peerDependencies": { "@types/node": ">=20", diff --git a/src/Page.ts b/src/Page.ts index 105282dd..a7d77e94 100644 --- a/src/Page.ts +++ b/src/Page.ts @@ -10,7 +10,7 @@ import {reloadDocument} from './utils/document'; import {getPlaywrightPage} from './useContext'; import type {PageRoute} from './PageRoute'; -import type {AsyncVoid, PageClassTypeArgs, Url} from './types/internal'; +import type {AsyncVoid, NavigateToUrlOptions, PageClassTypeArgs, Url} from './types/internal'; /** * Abstract page with base methods. @@ -33,17 +33,15 @@ export abstract class Page { readonly maxIntervalBetweenRequestsInMs: number; /** - * Immutable page parameters. + * 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 pageParams: PageParams; + readonly navigationTimeout: number; /** - * After navigating to the page, `e2ed` will wait until - * the page is stable for the specified time in millisecond, - * and only after that it will consider the page loaded. - * The default value is taken from the corresponding field of the pack config. + * Immutable page parameters. */ - readonly pageStabilizationInterval: number; + readonly pageParams: PageParams; constructor(...args: PageClassTypeArgs) { const [createPageToken, pageParams] = args; @@ -56,12 +54,12 @@ export abstract class Page { this.pageParams = pageParams as PageParams; const { - pageStabilizationInterval, + navigationTimeout, waitForAllRequestsComplete: {maxIntervalBetweenRequestsInMs}, } = getFullPackConfig(); this.maxIntervalBetweenRequestsInMs = maxIntervalBetweenRequestsInMs; - this.pageStabilizationInterval = pageStabilizationInterval; + this.navigationTimeout = navigationTimeout; } /** @@ -114,8 +112,8 @@ export abstract class Page { /** * Navigates to the page by url. */ - navigateToPage(url: Url): Promise { - return navigateToUrl(url, {skipLogs: true}); + navigateToPage(url: Url, options?: NavigateToUrlOptions): Promise { + return navigateToUrl(url, {skipLogs: true, timeout: this.navigationTimeout, ...options}); } /** diff --git a/src/README.md b/src/README.md index 6d804ca3..ffc19fb0 100644 --- a/src/README.md +++ b/src/README.md @@ -9,7 +9,7 @@ Modules in the dependency graph should only import the modules above them: 2. `constants` 3. `configurator` 4. `generators` -5. `utils/browser` +5. `utils/parse` 6. `utils/getDurationWithUnits` 7. `utils/setReadonlyProperty` 8. `utils/selectors` @@ -40,11 +40,12 @@ Modules in the dependency graph should only import the modules above them: 33. `Route` 34. `ApiRoute` 35. `PageRoute` -36. `testController` -37. `useContext` -38. `context` -39. `utils/log` -40. `utils/waitForEvents` -41. `utils/expect` -42. `expect` -43. ... +36. `WebSocketRoute` +37. `testController` +38. `useContext` +39. `context` +40. `utils/log` +41. `utils/waitForEvents` +42. `utils/expect` +43. `expect` +44. ... diff --git a/src/Route.ts b/src/Route.ts index 63c6c8ec..7f8735e3 100644 --- a/src/Route.ts +++ b/src/Route.ts @@ -1,6 +1,6 @@ import {SLASHES_AT_THE_END_REGEXP, SLASHES_AT_THE_START_REGEXP} from './constants/internal'; -import type {Method, Url, ZeroOrOneArg} from './types/internal'; +import type {Url, ZeroOrOneArg} from './types/internal'; /** * Abstract route with base methods. @@ -25,7 +25,7 @@ export abstract class Route { * Returns route params from the passed url. * @throws {Error} If the route does not match on the url. */ - static getParamsFromUrlOrThrow?(url: Url, method?: Method): unknown; + static getParamsFromUrlOrThrow?(url: Url): unknown; /** * Returns the url of the route. diff --git a/src/WebSocketRoute.ts b/src/WebSocketRoute.ts new file mode 100644 index 00000000..d9abba96 --- /dev/null +++ b/src/WebSocketRoute.ts @@ -0,0 +1,36 @@ +import {Route} from './Route'; + +/** + * Abstract route for WebSocket "requests". + */ +export abstract class WebSocketRoute< + Params = undefined, + SomeRequest = unknown, + SomeResponse = unknown, +> extends Route { + /** + * Request type of WebSocket route. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + declare readonly __REQUEST_KEY: SomeRequest; + + /** + * Response type of WebSocket route. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + declare readonly __RESPONSE_KEY: SomeResponse; + + /** + * Returns `true`, if the request body is in JSON format. + */ + getIsRequestBodyInJsonFormat(): boolean { + return true; + } + + /** + * Returns `true`, if the response body is in JSON format. + */ + getIsResponseBodyInJsonFormat(): boolean { + return true; + } +} diff --git a/src/actions/index.ts b/src/actions/index.ts index 7fa155cb..e7ca6783 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -17,7 +17,7 @@ export {getBrowserConsoleMessages} from './getBrowserConsoleMessages'; export {getBrowserJsErrors} from './getBrowserJsErrors'; export {getCookies} from './getCookies'; export {hover} from './hover'; -export {mockApiRoute, unmockApiRoute} from './mock'; +export {mockApiRoute, mockWebSocketRoute, unmockApiRoute, unmockWebSocketRoute} from './mock'; export {navigateToUrl} from './navigateToUrl'; export { assertPage, diff --git a/src/actions/mock/index.ts b/src/actions/mock/index.ts index c9531294..bc51d3a0 100644 --- a/src/actions/mock/index.ts +++ b/src/actions/mock/index.ts @@ -1,2 +1,4 @@ export {mockApiRoute} from './mockApiRoute'; +export {mockWebSocketRoute} from './mockWebSocketRoute'; export {unmockApiRoute} from './unmockApiRoute'; +export {unmockWebSocketRoute} from './unmockWebSocketRoute'; diff --git a/src/actions/mock/mockApiRoute.ts b/src/actions/mock/mockApiRoute.ts index f0f2e12c..a35aa804 100644 --- a/src/actions/mock/mockApiRoute.ts +++ b/src/actions/mock/mockApiRoute.ts @@ -19,7 +19,7 @@ import type { * Mock API for some API route. * Applicable only for routes with the `getParamsFromUrlOrThrow` method. * The mock is applied to a request that matches the route by url - * (by methods `getParamsFromUrlOrThrow` and `isMatchUrl`) and by HTTP method (by `getMethod`). + * (by methods `getParamsFromUrlOrThrow` and `isMatchUrl`). */ export const mockApiRoute = async < RouteParams, diff --git a/src/actions/mock/mockWebSocketRoute.ts b/src/actions/mock/mockWebSocketRoute.ts new file mode 100644 index 00000000..be341cf2 --- /dev/null +++ b/src/actions/mock/mockWebSocketRoute.ts @@ -0,0 +1,80 @@ +import {LogEventType} from '../../constants/internal'; +import {getFullMocksState} from '../../context/fullMocks'; +import {getWebSocketMockState} from '../../context/webSocketMockState'; +import {getPlaywrightPage} from '../../useContext'; +import {assertValueIsDefined} from '../../utils/asserts'; +import {setCustomInspectOnFunction} from '../../utils/fn'; +import {log} from '../../utils/log'; +import {getRequestsFilter, getSetResponse} from '../../utils/mockWebSocketRoute'; +import {setReadonlyProperty} from '../../utils/setReadonlyProperty'; + +import type { + WebSocketMockFunction, + WebSocketRouteClassTypeWithGetParamsFromUrl, +} from '../../types/internal'; + +/** + * Mock WebSocket for some API route. + * Applicable only for routes with the `getParamsFromUrlOrThrow` method. + * The mock is applied to a WebSocket that matches the route by url + * (by methods `getParamsFromUrlOrThrow` and `isMatchUrl`). + */ +export const mockWebSocketRoute = async ( + Route: WebSocketRouteClassTypeWithGetParamsFromUrl, + webSocketMockFunction: WebSocketMockFunction, + {skipLogs = false}: {skipLogs?: boolean} = {}, +): Promise => { + setCustomInspectOnFunction(webSocketMockFunction); + + const webSocketMockState = getWebSocketMockState(); + + if (!webSocketMockState.isMocksEnabled) { + return; + } + + const fullMocksState = getFullMocksState(); + + if (fullMocksState?.appliedMocks !== undefined) { + setReadonlyProperty(webSocketMockState, 'isMocksEnabled', false); + } + + let {optionsByRoute} = webSocketMockState; + + if (optionsByRoute === undefined) { + optionsByRoute = new Map(); + + setReadonlyProperty(webSocketMockState, 'optionsByRoute', optionsByRoute); + + const requestsFilter = getRequestsFilter(webSocketMockState); + + setReadonlyProperty(webSocketMockState, 'requestsFilter', requestsFilter); + } + + if (optionsByRoute.size === 0) { + const {requestsFilter} = webSocketMockState; + + assertValueIsDefined(requestsFilter, 'requestsFilter is defined', { + routeName: Route.name, + webSocketMockState, + }); + + const page = getPlaywrightPage(); + + const setResponse = getSetResponse(webSocketMockState); + + await page.routeWebSocket(requestsFilter, setResponse); + } + + optionsByRoute.set(Route, { + skipLogs, + webSocketMockFunction: webSocketMockFunction as WebSocketMockFunction, + }); + + if (skipLogs !== true) { + log( + `Mock WebSocket for route "${Route.name}"`, + {webSocketMockFunction}, + LogEventType.InternalAction, + ); + } +}; diff --git a/src/actions/mock/unmockWebSocketRoute.ts b/src/actions/mock/unmockWebSocketRoute.ts new file mode 100644 index 00000000..c5e91373 --- /dev/null +++ b/src/actions/mock/unmockWebSocketRoute.ts @@ -0,0 +1,57 @@ +import {LogEventType} from '../../constants/internal'; +import {getWebSocketMockState} from '../../context/webSocketMockState'; +import {getPlaywrightPage} from '../../useContext'; +import {assertValueIsDefined} from '../../utils/asserts'; +import {setCustomInspectOnFunction} from '../../utils/fn'; +import {log} from '../../utils/log'; + +import type { + WebSocketMockFunction, + WebSocketRouteClassTypeWithGetParamsFromUrl, +} from '../../types/internal'; + +/** + * Unmock WebSocket (remove mock, if any) for some WebSocket route. + */ +export const unmockWebSocketRoute = async ( + Route: WebSocketRouteClassTypeWithGetParamsFromUrl, +): Promise => { + const webSocketMockState = getWebSocketMockState(); + const {optionsByRoute, requestsFilter} = webSocketMockState; + let webSocketMockFunction: WebSocketMockFunction | undefined; + let routeWasMocked = false; + let skipLogs: boolean | undefined; + + if (optionsByRoute?.has(Route)) { + const options = optionsByRoute.get(Route); + + webSocketMockFunction = options?.webSocketMockFunction; + skipLogs = options?.skipLogs; + + routeWasMocked = true; + optionsByRoute.delete(Route); + } + + if (optionsByRoute?.size === 0) { + assertValueIsDefined(requestsFilter, 'requestsFilter is defined', { + routeName: Route.name, + routeWasMocked, + }); + + const page = getPlaywrightPage(); + + await page.unroute(requestsFilter); + } + + if (webSocketMockFunction) { + setCustomInspectOnFunction(webSocketMockFunction); + } + + if (skipLogs !== true) { + log( + `Unmock WebSocket for route "${Route.name}"`, + {routeWasMocked, webSocketMockFunction}, + LogEventType.InternalAction, + ); + } +}; diff --git a/src/actions/navigateToUrl.ts b/src/actions/navigateToUrl.ts index 1c380d9a..e5aec3ce 100644 --- a/src/actions/navigateToUrl.ts +++ b/src/actions/navigateToUrl.ts @@ -2,16 +2,15 @@ import {LogEventType} from '../constants/internal'; import {getPlaywrightPage} from '../useContext'; import {log} from '../utils/log'; -import type {Page} from '@playwright/test'; - -import type {Url} from '../types/internal'; - -type Options = Readonly<{skipLogs?: boolean} & Parameters[1]>; +import type {NavigateToUrlOptions, Url} from '../types/internal'; /** * Navigate to the `url` (without waiting of interface stabilization). */ -export const navigateToUrl = async (url: Url, options: Options = {}): Promise => { +export const navigateToUrl = async ( + url: Url, + options: NavigateToUrlOptions = {}, +): Promise => { const {skipLogs = false} = options; if (skipLogs !== true) { diff --git a/src/actions/pages/history/backPageHistory.ts b/src/actions/pages/history/backPageHistory.ts index 11cc75a2..fd78ed58 100644 --- a/src/actions/pages/history/backPageHistory.ts +++ b/src/actions/pages/history/backPageHistory.ts @@ -2,8 +2,6 @@ import {LogEventType} from '../../../constants/internal'; import {createClientFunction} from '../../../createClientFunction'; import {log} from '../../../utils/log'; -import {waitForInterfaceStabilization} from '../../waitFor'; - import type {AnyPageClassType} from '../../../types/internal'; const backPageHistoryClient = createClientFunction(() => window.history.back(), { @@ -22,5 +20,5 @@ export const backPageHistory = async (page: InstanceType): Pro await backPageHistoryClient(); - await waitForInterfaceStabilization(page.pageStabilizationInterval); + await page.waitForPageLoaded(); }; diff --git a/src/actions/pages/history/forwardPageHistory.ts b/src/actions/pages/history/forwardPageHistory.ts index 537cfe9e..1c5dc468 100644 --- a/src/actions/pages/history/forwardPageHistory.ts +++ b/src/actions/pages/history/forwardPageHistory.ts @@ -2,8 +2,6 @@ import {LogEventType} from '../../../constants/internal'; import {createClientFunction} from '../../../createClientFunction'; import {log} from '../../../utils/log'; -import {waitForInterfaceStabilization} from '../../waitFor'; - import type {AnyPageClassType} from '../../../types/internal'; const forwardPageHistoryClient = createClientFunction(() => window.history.forward(), { @@ -22,5 +20,5 @@ export const forwardPageHistory = async (page: InstanceType): await forwardPageHistoryClient(); - await waitForInterfaceStabilization(page.pageStabilizationInterval); + await page.waitForPageLoaded(); }; diff --git a/src/actions/pages/history/goPageHistory.ts b/src/actions/pages/history/goPageHistory.ts index 51e1a563..bde827c3 100644 --- a/src/actions/pages/history/goPageHistory.ts +++ b/src/actions/pages/history/goPageHistory.ts @@ -2,8 +2,6 @@ import {LogEventType} from '../../../constants/internal'; import {createClientFunction} from '../../../createClientFunction'; import {log} from '../../../utils/log'; -import {waitForInterfaceStabilization} from '../../waitFor'; - import type {AnyPageClassType} from '../../../types/internal'; const goPageHistoryClient = createClientFunction((delta: number) => window.history.go(delta), { @@ -25,5 +23,5 @@ export const goPageHistory = async ( await goPageHistoryClient(delta); - await waitForInterfaceStabilization(page.pageStabilizationInterval); + await page.waitForPageLoaded(); }; diff --git a/src/actions/setHeadersAndNavigateToUrl.ts b/src/actions/setHeadersAndNavigateToUrl.ts index 7bade993..a0401bec 100644 --- a/src/actions/setHeadersAndNavigateToUrl.ts +++ b/src/actions/setHeadersAndNavigateToUrl.ts @@ -7,12 +7,16 @@ import {applyHeadersMapper} from '../utils/requestHooks'; import {navigateToUrl} from './navigateToUrl'; -import type {MapOptions, Url} from '../types/internal'; +import type {MapOptions, NavigateToUrlOptions, Url} from '../types/internal'; /** * Navigate to the url and map custom response and request headers. */ -export const setHeadersAndNavigateToUrl = async (url: Url, options: MapOptions): Promise => { +export const setHeadersAndNavigateToUrl = async ( + url: Url, + options: MapOptions, + navigateToUrlOptions?: NavigateToUrlOptions, +): Promise => { const {mapRequestHeaders, mapResponseHeaders} = options; const page = getPlaywrightPage(); @@ -52,5 +56,5 @@ export const setHeadersAndNavigateToUrl = async (url: Url, options: MapOptions): ); } - await navigateToUrl(url, {skipLogs: true}); + await navigateToUrl(url, {skipLogs: true, ...navigateToUrlOptions}); }; diff --git a/src/actions/waitFor/waitForNewTab.ts b/src/actions/waitFor/waitForNewTab.ts index 2e71f131..7ac5a63c 100644 --- a/src/actions/waitFor/waitForNewTab.ts +++ b/src/actions/waitFor/waitForNewTab.ts @@ -17,7 +17,7 @@ export const waitForNewTab = async (options?: Options): Promise => { const startTimeInMs = Date.now() as UtcTimeInMs; const context = getPlaywrightPage().context(); - const timeout = options?.timeout ?? getFullPackConfig().pageRequestTimeout; + const timeout = options?.timeout ?? getFullPackConfig().navigationTimeout; const page = await context.waitForEvent('page', {timeout}); diff --git a/src/actions/waitFor/waitForStartOfPageLoad.ts b/src/actions/waitFor/waitForStartOfPageLoad.ts index e9977e61..83d9513b 100644 --- a/src/actions/waitFor/waitForStartOfPageLoad.ts +++ b/src/actions/waitFor/waitForStartOfPageLoad.ts @@ -18,7 +18,7 @@ export const waitForStartOfPageLoad = async (options?: Options): Promise => const startTimeInMs = Date.now() as UtcTimeInMs; const page = getPlaywrightPage(); - const timeout = options?.timeout ?? getFullPackConfig().pageRequestTimeout; + const timeout = options?.timeout ?? getFullPackConfig().navigationTimeout; let urlObject: URL | undefined; let wasCalled = false; diff --git a/src/config.ts b/src/config.ts index 3078e746..a54a1cd8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -102,7 +102,7 @@ const useOptions: PlaywrightTestConfig['use'] = { headless: isLocalRun ? userlandPack.enableHeadlessMode : true, isMobile: userlandPack.enableMobileDeviceMode, launchOptions: {args: [...userlandPack.browserFlags]}, - navigationTimeout: userlandPack.pageRequestTimeout, + navigationTimeout: userlandPack.navigationTimeout, trace: 'retain-on-failure', userAgent: userlandPack.userAgent, viewport: {height: userlandPack.viewportHeight, width: userlandPack.viewportWidth}, diff --git a/src/context/webSocketMockState.ts b/src/context/webSocketMockState.ts new file mode 100644 index 00000000..fbedf748 --- /dev/null +++ b/src/context/webSocketMockState.ts @@ -0,0 +1,32 @@ +import {useContext} from '../useContext'; + +import type {WebSocketMockState} from '../types/internal'; + +/** + * Raw get and set internal (maybe `undefined`) WebSocket mock state. + * @internal + */ +const [getRawWebSocketMockState, setRawWebSocketMockState] = useContext(); + +/** + * Get internal always defined WebSocket mock state (for `mockWebSocketRoute`). + * @internal + */ +export const getWebSocketMockState = (): WebSocketMockState => { + const maybeWebSocketMockState = getRawWebSocketMockState(); + + if (maybeWebSocketMockState !== undefined) { + return maybeWebSocketMockState; + } + + const webSocketMockState: WebSocketMockState = { + isMocksEnabled: true, + optionsByRoute: undefined, + optionsWithRouteByUrl: Object.create(null) as {}, + requestsFilter: undefined, + }; + + setRawWebSocketMockState(webSocketMockState); + + return webSocketMockState; +}; diff --git a/src/index.ts b/src/index.ts index 92372e07..3d7b8204 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export {PageRoute} from './PageRoute'; export {devices} from './playwright'; export {Route} from './Route'; export {getPlaywrightPage, useContext} from './useContext'; +export {WebSocketRoute} from './WebSocketRoute'; /** * Public modules, dependent on internal utils. diff --git a/src/types/config/config.ts b/src/types/config/config.ts index 10592056..7a678538 100644 --- a/src/types/config/config.ts +++ b/src/types/config/config.ts @@ -60,7 +60,6 @@ export type UserlandPackWithoutDoBeforePack< > = Readonly<{ assertionTimeout: number; concurrency: number; - pageRequestTimeout: number; port1: number; port2: number; selectorTimeout: number; diff --git a/src/types/config/ownE2edConfig.ts b/src/types/config/ownE2edConfig.ts index 98e6786d..911791c1 100644 --- a/src/types/config/ownE2edConfig.ts +++ b/src/types/config/ownE2edConfig.ts @@ -158,6 +158,11 @@ export type OwnE2edConfig< */ maxRetriesCountInDocker: number; + /** + * Default timeout for navigation to url (`navigateToPage`, `navigateToUrl` actions) in milliseconds. + */ + navigationTimeout: number; + /** * If not `null`, then this value will override fields of internal Playwright config. */ @@ -169,13 +174,6 @@ export type OwnE2edConfig< */ packTimeout: number; - /** - * After navigating to the page, `e2ed` will wait until the page is stable - * for the specified time in millisecond, and only after that it will consider the page loaded. - * This parameter can be overridden on a specific page instance. - */ - pageStabilizationInterval: number; - /** * Path to the directory where screenshots will be stored for displaying them in the HTML report. * This path must be either relative (from the HTML report file) or absolute (i.e. with http/https protocol). diff --git a/src/types/index.ts b/src/types/index.ts index c7fb99b0..cfb6d7c6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,6 +38,8 @@ export type { } from './http'; export type {KeyboardPressKey} from './keyboard'; export type {ApiMockFunction} from './mockApiRoute'; +export type {WebSocketMockFunction} from './mockWebSocketRoute'; +export type {NavigateToUrlOptions} from './navigation'; export type { AnyPageClassType, NavigateToOrAssertPageArgs, @@ -54,7 +56,12 @@ export type { PropertyKey, } from './properties'; export type {LiteReport, LiteRetry} from './report'; -export type {ApiRouteClassType, ApiRouteClassTypeWithGetParamsFromUrl} from './routes'; +export type { + ApiRouteClassType, + ApiRouteClassTypeWithGetParamsFromUrl, + WebSocketRouteClassType, + WebSocketRouteClassTypeWithGetParamsFromUrl, +} from './routes'; export type { CreateSelector, CreateSelectorFunctionOptions, diff --git a/src/types/internal.ts b/src/types/internal.ts index fedb3afe..1d970803 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -70,6 +70,10 @@ export type { export type {ApiMockFunction} from './mockApiRoute'; /** @internal */ export type {ApiMockState} from './mockApiRoute'; +export type {WebSocketMockFunction} from './mockWebSocketRoute'; +/** @internal */ +export type {WebSocketMockState} from './mockWebSocketRoute'; +export type {NavigateToUrlOptions} from './navigation'; /** @internal */ export type {NavigationDelay} from './navigation'; export type { @@ -104,7 +108,12 @@ export type { } from './report'; /** @internal */ export type {RetriesState, RunRetryOptions, VisitedTestNamesHash} from './retries'; -export type {ApiRouteClassType, ApiRouteClassTypeWithGetParamsFromUrl} from './routes'; +export type { + ApiRouteClassType, + ApiRouteClassTypeWithGetParamsFromUrl, + WebSocketRouteClassType, + WebSocketRouteClassTypeWithGetParamsFromUrl, +} from './routes'; export type {RunLabel, RunLabelObject} from './runLabel'; /** @internal */ export type {RawRunLabelObject} from './runLabel'; diff --git a/src/types/mockApiRoute.ts b/src/types/mockApiRoute.ts index d787791e..d913138a 100644 --- a/src/types/mockApiRoute.ts +++ b/src/types/mockApiRoute.ts @@ -3,6 +3,7 @@ import type {URL} from 'node:url'; import type {ApiRoute} from '../ApiRoute'; import type {Request, Response, Url} from './http'; +import type {MaybePromise} from './promise'; import type {ApiRouteClassTypeWithGetParamsFromUrl} from './routes'; /** @@ -23,10 +24,7 @@ export type ApiMockFunction< RouteParams = unknown, SomeRequest extends Request = Request, SomeResponse extends Response = Response, -> = ( - routeParams: RouteParams, - request: SomeRequest, -) => Partial | Promise>; +> = (routeParams: RouteParams, request: SomeRequest) => MaybePromise>; /** * Internal state of `mockApiRoute`/`unmockApiRoute`. diff --git a/src/types/mockWebSocketRoute.ts b/src/types/mockWebSocketRoute.ts new file mode 100644 index 00000000..34d7300c --- /dev/null +++ b/src/types/mockWebSocketRoute.ts @@ -0,0 +1,38 @@ +import type {URL} from 'node:url'; + +import type {WebSocketRoute} from '../WebSocketRoute'; + +import type {Url} from './http'; +import type {MaybePromise} from './promise'; +import type {WebSocketRouteClassTypeWithGetParamsFromUrl} from './routes'; + +/** + * Mock option with mocked route. + * @internal + */ +type MockOptionsWithRoute = MockOptions & Readonly<{route: WebSocketRoute}>; + +/** + * Mock option (`skipLogs` and `webSocketMockFunction`). + */ +type MockOptions = Readonly<{skipLogs: boolean; webSocketMockFunction: WebSocketMockFunction}>; + +/** + * WebSocket mock function, that map request to mocked response. + */ +export type WebSocketMockFunction< + RouteParams = unknown, + SomeRequest = unknown, + SomeResponse = unknown, +> = (routeParams: RouteParams, request: SomeRequest) => MaybePromise; + +/** + * Internal state of `mockWebSocketRoute`/`unmockWebSocketRoute`. + * @internal + */ +export type WebSocketMockState = Readonly<{ + isMocksEnabled: boolean; + optionsByRoute: Map | undefined; + optionsWithRouteByUrl: Record; + requestsFilter: ((urlObject: URL) => boolean) | undefined; +}>; diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 092c2776..4b9b14ac 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -1,3 +1,10 @@ +import type {Page} from '@playwright/test'; + +/** + * Options for `navigateToUrl` action. + */ +export type NavigateToUrlOptions = Readonly<{skipLogs?: boolean} & Parameters[1]>; + /** * Object with information for navigation delay. * @internal diff --git a/src/types/routes.ts b/src/types/routes.ts index 6f7eec10..145a1c54 100644 --- a/src/types/routes.ts +++ b/src/types/routes.ts @@ -1,4 +1,5 @@ import type {ApiRoute} from '../ApiRoute'; +import type {WebSocketRoute} from '../WebSocketRoute'; import type {Request, Response} from './http'; import type {Any, ZeroOrOneArg} from './utils'; @@ -16,7 +17,7 @@ export type ApiRouteClassType< }; /** - * API Route class with static method getParamsFromUrlOrThrow. + * API Route class with static method `getParamsFromUrlOrThrow`. */ export type ApiRouteClassTypeWithGetParamsFromUrl< RouteParams = Any, @@ -26,3 +27,23 @@ export type ApiRouteClassTypeWithGetParamsFromUrl< Readonly<{ getParamsFromUrlOrThrow: Exclude<(typeof ApiRoute)['getParamsFromUrlOrThrow'], undefined>; }>; + +/** + * WebSocket Route class type by route parameters type. + */ +export type WebSocketRouteClassType = { + prototype: WebSocketRoute; + new (...args: ZeroOrOneArg): WebSocketRoute; +}; + +/** + * WebSocket Route class with static method `getParamsFromUrlOrThrow`. + */ +export type WebSocketRouteClassTypeWithGetParamsFromUrl< + RouteParams = Any, + SomeRequest = unknown, + SomeResponse = unknown, +> = WebSocketRouteClassType & + Readonly<{ + getParamsFromUrlOrThrow: Exclude<(typeof WebSocketRoute)['getParamsFromUrlOrThrow'], undefined>; + }>; diff --git a/src/utils/config/assertUserlandPack.ts b/src/utils/config/assertUserlandPack.ts index 2e988f9d..1551acda 100644 --- a/src/utils/config/assertUserlandPack.ts +++ b/src/utils/config/assertUserlandPack.ts @@ -27,27 +27,19 @@ export const assertUserlandPack = (userlandPack: UserlandPack): void => { logParams, ); - assertNumberIsPositiveInteger( - userlandPack.packTimeout, - 'packTimeout is positive integer', - logParams, - ); - - if (userlandPack.pageRequestTimeout !== 0) { + if (userlandPack.navigationTimeout !== 0) { assertNumberIsPositiveInteger( - userlandPack.pageRequestTimeout, - 'pageRequestTimeout is positive integer', + userlandPack.navigationTimeout, + 'navigationTimeout is positive integer', logParams, ); } - if (userlandPack.pageStabilizationInterval !== 0) { - assertNumberIsPositiveInteger( - userlandPack.pageStabilizationInterval, - 'pageStabilizationInterval is positive integer', - logParams, - ); - } + assertNumberIsPositiveInteger( + userlandPack.packTimeout, + 'packTimeout is positive integer', + logParams, + ); assertNumberIsPositiveInteger(userlandPack.port1, 'port1 is positive integer', logParams); assertNumberIsPositiveInteger(userlandPack.port2, 'port2 is positive integer', logParams); diff --git a/src/utils/getRouteInstanceFromUrl.ts b/src/utils/getRouteInstanceFromUrl.ts index 8bc0b36c..2e48b860 100644 --- a/src/utils/getRouteInstanceFromUrl.ts +++ b/src/utils/getRouteInstanceFromUrl.ts @@ -1,10 +1,15 @@ import {E2edError} from './error'; import type {ApiRoute} from '../ApiRoute'; -import type {ApiRouteClassTypeWithGetParamsFromUrl, Url} from '../types/internal'; +import type { + ApiRouteClassTypeWithGetParamsFromUrl, + Url, + WebSocketRouteClassTypeWithGetParamsFromUrl, +} from '../types/internal'; +import type {WebSocketRoute} from '../WebSocketRoute'; type Return = - | Readonly<{route: ApiRoute; routeParams: RouteParams}> + | Readonly<{route: ApiRoute | WebSocketRoute; routeParams: RouteParams}> | undefined; /** @@ -16,9 +21,11 @@ type Return = */ export const getRouteInstanceFromUrl = ( url: Url, - Route: ApiRouteClassTypeWithGetParamsFromUrl, + Route: + | ApiRouteClassTypeWithGetParamsFromUrl + | WebSocketRouteClassTypeWithGetParamsFromUrl, ): Return => { - let route: ApiRoute | undefined; + let route: ApiRoute | WebSocketRoute | undefined; let routeParams: RouteParams | undefined; try { diff --git a/src/utils/http/getBodyAsString.ts b/src/utils/http/getBodyAsString.ts index a68ed1f9..1866c125 100644 --- a/src/utils/http/getBodyAsString.ts +++ b/src/utils/http/getBodyAsString.ts @@ -2,7 +2,7 @@ * Get request or response body as string by original body. * @internal */ -export const getBodyAsString = (originalBody: unknown, bodyIsInJsonFormat: boolean): string => { +export const getBodyAsString = (originalBody: unknown, isBodyInJsonFormat: boolean): string => { if (originalBody === undefined) { return ''; } @@ -11,5 +11,5 @@ export const getBodyAsString = (originalBody: unknown, bodyIsInJsonFormat: boole return originalBody; } - return bodyIsInJsonFormat ? JSON.stringify(originalBody) : String(originalBody); + return isBodyInJsonFormat ? JSON.stringify(originalBody) : String(originalBody); }; diff --git a/src/utils/index.ts b/src/utils/index.ts index c73072af..389b692e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -39,7 +39,7 @@ export {getDurationWithUnits} from './getDurationWithUnits'; export {getKeysCounter} from './getKeysCounter'; export {getContentJsonHeaders} from './http'; export {log} from './log'; -export {parseMaybeEmptyValueAsJson} from './parseMaybeEmptyValueAsJson'; +export {parseMaybeEmptyValueAsJson, parseValueAsJsonIfNeeded} from './parse'; export { addTimeoutToPromise, getPromiseWithResolveAndReject, diff --git a/src/utils/mockApiRoute/getRequestsFilter.ts b/src/utils/mockApiRoute/getRequestsFilter.ts index ec28098a..8033a667 100644 --- a/src/utils/mockApiRoute/getRequestsFilter.ts +++ b/src/utils/mockApiRoute/getRequestsFilter.ts @@ -5,6 +5,7 @@ import {getRouteInstanceFromUrl} from '../getRouteInstanceFromUrl'; import type {URL} from 'node:url'; +import type {ApiRoute} from '../../ApiRoute'; import type {ApiMockState, Url} from '../../types/internal'; /** @@ -30,7 +31,7 @@ export const getRequestsFilter = ({ continue; } - const {route} = maypeRouteWithRouteParams; + const {route} = maypeRouteWithRouteParams as {route: ApiRoute}; // eslint-disable-next-line no-param-reassign optionsWithRouteByUrl[url] = {apiMockFunction, route, skipLogs}; diff --git a/src/utils/mockWebSocketRoute/getRequestsFilter.ts b/src/utils/mockWebSocketRoute/getRequestsFilter.ts new file mode 100644 index 00000000..62538b70 --- /dev/null +++ b/src/utils/mockWebSocketRoute/getRequestsFilter.ts @@ -0,0 +1,42 @@ +import {AsyncLocalStorage} from 'node:async_hooks'; + +import {assertValueIsDefined} from '../asserts'; +import {getRouteInstanceFromUrl} from '../getRouteInstanceFromUrl'; + +import type {URL} from 'node:url'; + +import type {Url, WebSocketMockState} from '../../types/internal'; + +/** + * Get `requestsFilter` function for WebSocket mocks by `WebSocketMockState`. + * @internal + */ +export const getRequestsFilter = ({ + optionsByRoute, + optionsWithRouteByUrl, +}: WebSocketMockState): ((urlObject: URL) => boolean) => + AsyncLocalStorage.bind((urlObject) => { + assertValueIsDefined(optionsByRoute, 'optionsByRoute is defined', {urlObject}); + + const url = urlObject.href as Url; + + for (const [Route, {skipLogs, webSocketMockFunction}] of optionsByRoute) { + const maypeRouteWithRouteParams = getRouteInstanceFromUrl(url, Route); + + if (maypeRouteWithRouteParams === undefined) { + // eslint-disable-next-line no-param-reassign + optionsWithRouteByUrl[url] = undefined; + + continue; + } + + const {route} = maypeRouteWithRouteParams; + + // eslint-disable-next-line no-param-reassign + optionsWithRouteByUrl[url] = {route, skipLogs, webSocketMockFunction}; + + return true; + } + + return false; + }); diff --git a/src/utils/mockWebSocketRoute/getSetResponse.ts b/src/utils/mockWebSocketRoute/getSetResponse.ts new file mode 100644 index 00000000..6974dc66 --- /dev/null +++ b/src/utils/mockWebSocketRoute/getSetResponse.ts @@ -0,0 +1,59 @@ +import {AsyncLocalStorage} from 'node:async_hooks'; + +import {LogEventStatus, LogEventType} from '../../constants/internal'; + +import {assertValueIsDefined} from '../asserts'; +import {getBodyAsString} from '../http'; +import {log} from '../log'; +import {parseValueAsJsonIfNeeded} from '../parse'; + +import type {WebSocketRoute as PlaywrightWebSocketRoute} from '@playwright/test'; + +import type {Url, WebSocketMockState} from '../../types/internal'; + +/** + * Get `setResponse` function for WebSocket mocks by `WebSocketMockState`. + * @internal + */ +export const getSetResponse = ({ + optionsWithRouteByUrl, +}: WebSocketMockState): ((playwrightRoute: PlaywrightWebSocketRoute) => void) => + AsyncLocalStorage.bind((playwrightRoute) => { + const url = playwrightRoute.url() as Url; + const optionsWithRoute = optionsWithRouteByUrl[url]; + + assertValueIsDefined(optionsWithRoute, 'optionsWithRoute is defined', {url}); + + const {skipLogs, route, webSocketMockFunction} = optionsWithRoute; + const isRequestBodyInJsonFormat = route.getIsRequestBodyInJsonFormat(); + const isResponseBodyInJsonFormat = route.getIsResponseBodyInJsonFormat(); + + playwrightRoute.onMessage(async (message) => { + const {value: request, hasParseError} = parseValueAsJsonIfNeeded( + String(message), + isRequestBodyInJsonFormat, + ); + + if (hasParseError && skipLogs !== true) { + log( + 'WebSocket message is not in JSON format', + {logEventStatus: LogEventStatus.Failed, message, url}, + LogEventType.InternalUtil, + ); + } + + const response = await webSocketMockFunction(route.routeParams, request); + + const responseAsString = getBodyAsString(response, isResponseBodyInJsonFormat); + + playwrightRoute.send(responseAsString); + + if (skipLogs !== true) { + log( + `A mock was applied to the WebSocket route "${route.constructor.name}"`, + {request, response, route, webSocketMockFunction}, + LogEventType.InternalUtil, + ); + } + }); + }); diff --git a/src/utils/mockWebSocketRoute/index.ts b/src/utils/mockWebSocketRoute/index.ts new file mode 100644 index 00000000..f02dbe63 --- /dev/null +++ b/src/utils/mockWebSocketRoute/index.ts @@ -0,0 +1,4 @@ +/** @internal */ +export {getRequestsFilter} from './getRequestsFilter'; +/** @internal */ +export {getSetResponse} from './getSetResponse'; diff --git a/src/utils/parse/index.ts b/src/utils/parse/index.ts new file mode 100644 index 00000000..e5b85903 --- /dev/null +++ b/src/utils/parse/index.ts @@ -0,0 +1,2 @@ +export {parseMaybeEmptyValueAsJson} from './parseMaybeEmptyValueAsJson'; +export {parseValueAsJsonIfNeeded} from './parseValueAsJsonIfNeeded'; diff --git a/src/utils/parseMaybeEmptyValueAsJson.ts b/src/utils/parse/parseMaybeEmptyValueAsJson.ts similarity index 81% rename from src/utils/parseMaybeEmptyValueAsJson.ts rename to src/utils/parse/parseMaybeEmptyValueAsJson.ts index efcf8488..87cbcbc9 100644 --- a/src/utils/parseMaybeEmptyValueAsJson.ts +++ b/src/utils/parse/parseMaybeEmptyValueAsJson.ts @@ -1,5 +1,5 @@ /** - * Parses maybe empty value (undefined, empty string or empty buffer) as JSON. + * Parses maybe empty value (`undefined`, empty string or empty buffer) as JSON. */ export const parseMaybeEmptyValueAsJson = (value: unknown): Return | undefined => { // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions diff --git a/src/utils/parse/parseValueAsJsonIfNeeded.ts b/src/utils/parse/parseValueAsJsonIfNeeded.ts new file mode 100644 index 00000000..2979f0b9 --- /dev/null +++ b/src/utils/parse/parseValueAsJsonIfNeeded.ts @@ -0,0 +1,31 @@ +import {parseMaybeEmptyValueAsJson} from './parseMaybeEmptyValueAsJson'; + +type Return = Readonly<{hasParseError: boolean; value: unknown}>; + +/** + * Parses `unknown` value as JSON, if needed. + * If `isoValueInJsonFormat` is `true`, then parses value as JSON and saves parse error. + * If `isoValueInJsonFormat` is `false`, then returns value as is. + * If `isoValueInJsonFormat` is `undefined`, then safely tries to parse value as JSON. + */ +export const parseValueAsJsonIfNeeded = ( + originalValue: unknown, + isoValueInJsonFormat?: boolean, +): Return => { + let hasParseError = false; + let value = originalValue; + + if (isoValueInJsonFormat === true) { + try { + value = parseMaybeEmptyValueAsJson(originalValue); + } catch { + hasParseError = true; + } + } else if (isoValueInJsonFormat !== false) { + try { + value = parseMaybeEmptyValueAsJson(originalValue); + } catch {} + } + + return {hasParseError, value}; +}; diff --git a/src/utils/request/oneTryOfRequest.ts b/src/utils/request/oneTryOfRequest.ts index e84d492d..ba6c6a9d 100644 --- a/src/utils/request/oneTryOfRequest.ts +++ b/src/utils/request/oneTryOfRequest.ts @@ -5,7 +5,7 @@ import {cloneWithoutUndefinedProperties} from '../clone'; import {E2edError} from '../error'; import {getDurationWithUnits} from '../getDurationWithUnits'; import {log} from '../log'; -import {parseMaybeEmptyValueAsJson} from '../parseMaybeEmptyValueAsJson'; +import {parseMaybeEmptyValueAsJson} from '../parse'; import {getQuery} from './getQuery'; diff --git a/src/utils/requestHooks/getRequestFromPlaywrightRequest.ts b/src/utils/requestHooks/getRequestFromPlaywrightRequest.ts index 10b427fb..2573ffe3 100644 --- a/src/utils/requestHooks/getRequestFromPlaywrightRequest.ts +++ b/src/utils/requestHooks/getRequestFromPlaywrightRequest.ts @@ -1,10 +1,10 @@ import {parse} from 'node:querystring'; import {URL} from 'node:url'; -import {LogEventType} from '../../constants/internal'; +import {LogEventStatus, LogEventType} from '../../constants/internal'; import {log} from '../log'; -import {parseMaybeEmptyValueAsJson} from '../parseMaybeEmptyValueAsJson'; +import {parseValueAsJsonIfNeeded} from '../parse'; import type {Request as PlaywrightRequest} from '@playwright/test'; @@ -25,29 +25,20 @@ export const getRequestFromPlaywrightRequest = ( const {search} = new URL(url); const method = playwrightRequest.method().toUpperCase() as Method; - const query = parse(search ? search.slice(1) : ''); - - let requestBody: unknown; - const body = playwrightRequest.postData(); - if (isRequestBodyInJsonFormat === true) { - try { - requestBody = parseMaybeEmptyValueAsJson(body); - } catch { - log('Request body is not in JSON format', {body, url}, LogEventType.InternalUtil); - - requestBody = body; - } - } else if (isRequestBodyInJsonFormat === false) { - requestBody = body; - } else { - try { - requestBody = parseMaybeEmptyValueAsJson(body); - } catch { - requestBody = body; - } + const {value: requestBody, hasParseError} = parseValueAsJsonIfNeeded( + body, + isRequestBodyInJsonFormat, + ); + + if (hasParseError) { + log( + 'Request body is not in JSON format', + {body, logEventStatus: LogEventStatus.Failed, url}, + LogEventType.InternalUtil, + ); } const requestHeaders = playwrightRequest.headers(); diff --git a/src/utils/requestHooks/getResponseFromPlaywrightResponse.ts b/src/utils/requestHooks/getResponseFromPlaywrightResponse.ts index 5b8a2f39..82e8eb8d 100644 --- a/src/utils/requestHooks/getResponseFromPlaywrightResponse.ts +++ b/src/utils/requestHooks/getResponseFromPlaywrightResponse.ts @@ -1,7 +1,7 @@ import {BAD_REQUEST_STATUS_CODE, MULTIPLE_CHOICES_STATUS_CODE} from '../../constants/internal'; import {getDurationWithUnits} from '../getDurationWithUnits'; -import {parseMaybeEmptyValueAsJson} from '../parseMaybeEmptyValueAsJson'; +import {parseMaybeEmptyValueAsJson} from '../parse'; import {getRequestFromPlaywrightRequest} from './getRequestFromPlaywrightRequest'; diff --git a/src/utils/waitForEvents/addNotCompleteRequest.ts b/src/utils/waitForEvents/addNotCompleteRequest.ts index 8d2f9897..b12ad0bd 100644 --- a/src/utils/waitForEvents/addNotCompleteRequest.ts +++ b/src/utils/waitForEvents/addNotCompleteRequest.ts @@ -1,3 +1,6 @@ +import {assertValueIsDefined} from '../asserts'; + +import {isReRequest} from './isReRequest'; import {processAllRequestsCompletePredicates} from './processAllRequestsCompletePredicates'; import type { @@ -19,5 +22,25 @@ export const addNotCompleteRequest = async ( hashOfNotCompleteRequests[requestHookContextId] = request; + for (const previousRequestId of Object.keys( + hashOfNotCompleteRequests, + ) as RequestHookContextId[]) { + if (previousRequestId === requestHookContextId) { + continue; + } + + const previousRequest = hashOfNotCompleteRequests[previousRequestId]; + + assertValueIsDefined(previousRequest, 'previousRequest is defined', { + hashOfNotCompleteRequests, + url: request.url, + }); + + if (isReRequest(request, previousRequest)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete hashOfNotCompleteRequests[previousRequestId]; + } + } + await processAllRequestsCompletePredicates(requestHookContextId, waitForEventsState); }; diff --git a/src/utils/waitForEvents/isReRequest.ts b/src/utils/waitForEvents/isReRequest.ts new file mode 100644 index 00000000..882446ad --- /dev/null +++ b/src/utils/waitForEvents/isReRequest.ts @@ -0,0 +1,36 @@ +import type {RequestWithUtcTimeInMs} from '../../types/internal'; + +/** + * Returns `true` if request is re-request of base request, and `false` otherwise. + * We should not wait for such requests to complete because they will not receive a response. + * @internal + */ +export const isReRequest = ( + reRequest: RequestWithUtcTimeInMs, + baseRequest: RequestWithUtcTimeInMs, +): boolean => { + if (reRequest.url !== baseRequest.url) { + return false; + } + + const baseHeaders = baseRequest.requestHeaders; + const headers = reRequest.requestHeaders; + + const headersNames = Object.keys(headers); + const baseHeadersNames = Object.keys(baseHeaders); + + if (headersNames.length < baseHeadersNames.length) { + return false; + } + + for (const headerName of baseHeadersNames) { + if ( + !(headerName in headers) || + String(baseHeaders[headerName]) !== String(headers[headerName]) + ) { + return false; + } + } + + return true; +};