diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 8feb2371..d0a995c6 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -90,7 +90,6 @@ rules: - src/config.ts - src/createLocator.ts - src/getModulesGraph.ts - - src/globby.ts - src/index.ts missingExports: true unusedExports: true @@ -193,7 +192,7 @@ rules: '@typescript-eslint/no-loop-func': error '@typescript-eslint/no-magic-numbers': - error - - ignore: [-2, -1, 0, 1, 2, 1024] + - ignore: [-2, -1, 0, 1, 2, 1000, 1024] ignoreArrayIndexes: true ignoreDefaultValues: true ignoreEnums: true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 150e2ac4..b38d8151 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,6 +39,7 @@ jobs: - run: npm run build - run: npm run test:local - uses: actions/upload-artifact@v4 + if: ${{ always() }} with: name: reports path: build/autotests/reports diff --git a/README.md b/README.md index 4469ad32..8b63abc5 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ After the run, a detailed HTML report and a summary lite report in JSON format a ## Adding e2ed to a project -Prerequisites: [node](https://nodejs.org/en/) >=20, +Prerequisites: [node](https://nodejs.org/en/) >=22, [TypeScript](https://www.typescriptlang.org/) >=5. All commands below are run from the root directory of the project. @@ -365,7 +365,7 @@ You can define the `SkipTests` type and `skipTests` processing rules in the hook at the time of the test error, for display in the HTML report. `testFileGlobs: readonly string[]`: an array of globs with pack test (task) files. -https://www.npmjs.com/package/globby is used for matching globs. +`fs.glob` from `nodejs` is used for matching globs. `testIdleTimeout: number`: timeout (in milliseconds) for each individual test step. If the test step (interval between two `log` function calls) takes longer than this timeout, diff --git a/autotests/entities/device.ts b/autotests/entities/device.ts index f60fa5ee..24c9d5c9 100644 --- a/autotests/entities/device.ts +++ b/autotests/entities/device.ts @@ -16,6 +16,7 @@ export const createDevice = async ({ cookies, input: 7, model, + title: model, version, }); diff --git a/autotests/entities/product.ts b/autotests/entities/product.ts index fc4c5b67..e5ce12b8 100644 --- a/autotests/entities/product.ts +++ b/autotests/entities/product.ts @@ -8,11 +8,12 @@ import type {ClientFunction} from 'e2ed/types'; */ export const addProduct: ClientFunction<[Product], Promise> = createClientFunction( (product: Product) => - fetch(`https://reqres.in/api/product/${product.id}?size=${product.size}`, { + fetch(`https://dummyjson.com/products/add?id=${product.id}&size=${product.size}`, { body: JSON.stringify({ cookies: [], input: product.input, model: product.model, + title: product.model, version: product.version, }), headers: {'Content-Type': 'application/json; charset=UTF-8'}, diff --git a/autotests/entities/worker.ts b/autotests/entities/worker.ts index f209b235..a317a231 100644 --- a/autotests/entities/worker.ts +++ b/autotests/entities/worker.ts @@ -4,30 +4,52 @@ import {log} from 'e2ed/utils'; import type {UserWorker} from 'autotests/types'; import type {ClientFunction} from 'e2ed/types'; -const clientGetUsers = createClientFunction( - (delay: number) => - fetch(`https://reqres.in/api/users?delay=${delay}`, {method: 'GET'}).then((res) => res.json()), - {name: 'getUsers', timeout: 6_000}, -); +type GetUsersOptions = Readonly<{delay?: number; retries?: number}> | undefined; + +let clientGetUsers: ClientFunction<[number], unknown> | undefined; +let clientGetUsersRetries: number | undefined; /** * Adds user-worker. */ -export const addUser: ClientFunction<[UserWorker, number?], Promise> = createClientFunction( - (user: UserWorker, delay?: number) => - fetch(`https://reqres.in/api/users${delay !== undefined ? `?delay=${delay}` : ''}`, { +export const addUser: ClientFunction< + [Readonly<{delay?: number; user: UserWorker}>], + Promise +> = createClientFunction( + ({delay, user}) => + fetch(`https://dummyjson.com/users/add${delay !== undefined ? `?delay=${delay}` : ''}`, { body: JSON.stringify(user), headers: {'Content-Type': 'application/json; charset=UTF-8'}, method: 'POST', - }), + }) + .then((res) => res.json()) + .then((result: UserWorker) => { + // eslint-disable-next-line no-console + console.log('addUser return', result); + + return result; + }), + {name: 'addUser', timeout: 3_000}, ); /** * Get list of user-workers. */ -export const getUsers = (delay: number): Promise => { +export const getUsers = ({delay = 0, retries = 0}: GetUsersOptions = {}): Promise => { log(`Send API request with delay = ${delay}s`); + if (clientGetUsers === undefined || clientGetUsersRetries !== retries) { + clientGetUsersRetries = retries; + + clientGetUsers = createClientFunction( + (clientDelay: number) => + fetch(`https://dummyjson.com/users?delay=${clientDelay}`, {method: 'GET'}).then( + (res) => res.json() as unknown, + ), + {name: 'getUsers', retries, timeout: 6_000}, + ); + } + return clientGetUsers(delay); }; diff --git a/autotests/fixtures/fullMocks/mr-iHTD7Lp.json b/autotests/fixtures/fullMocks/mr-iHTD7Lp.json deleted file mode 100644 index a197eb8a..00000000 --- a/autotests/fixtures/fullMocks/mr-iHTD7Lp.json +++ /dev/null @@ -1 +0,0 @@ -{"/api/product/135865":[{"completionTimeInMs":1729850053899,"duration":"4ms","request":{"method":"POST","query":{"size":"13"},"requestBody":{"cookies":[],"input":17,"model":"samsung","version":"12"},"requestHeaders":{"accept":"*/*","accept-language":"en-US","content-type":"application/json; charset=UTF-8","referer":"https://joomcode.github.io/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.35 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.35","sec-ch-ua":"\"Chromium\";v=\"130\", \"HeadlessChrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Linux\""},"url":"https://reqres.in/api/product/135865?size=13","utcTimeInMs":1729850053895},"responseBody":{"id":135865,"method":"POST","output":"17","payload":{"id":"135865","cookies":[],"input":17,"model":"samsung","version":"12"},"query":{"size":"13"},"url":"https://reqres.in/api/product/135865?size=13"},"responseHeaders":{"content-type":"application/json; charset=UTF-8","content-length":"201"},"statusCode":200},{"completionTimeInMs":1729850054453,"duration":"4ms","request":{"method":"POST","query":{"size":"13"},"requestBody":{"cookies":[],"input":17,"model":"samsung","version":"12"},"requestHeaders":{"accept":"*/*","accept-language":"en-US","content-type":"application/json; charset=UTF-8","referer":"https://joomcode.github.io/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.35 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.35","sec-ch-ua":"\"Chromium\";v=\"130\", \"HeadlessChrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Linux\""},"url":"https://reqres.in/api/product/135865?size=13","utcTimeInMs":1729850054449},"responseBody":{"cookies":[],"input":17,"model":"samsung","version":"12","id":"816","createdAt":"2024-10-25T09:54:14.388Z"},"responseHeaders":{"reporting-endpoints":"heroku-nel=https://nel.heroku.com/reports?ts=1729850054&sid=c4c9725f-1ab0-44d8-820f-430df2718e11&s=LZBdsiG2rgFml4T6awFYmhftTlXLyvdfcMob39wiK2I%3D","nel":"{\"report_to\":\"heroku-nel\",\"max_age\":3600,\"success_fraction\":0.005,\"failure_fraction\":0.05,\"response_headers\":[\"Via\"]}","cf-cache-status":"DYNAMIC","etag":"W/\"6c-uS8VtSQALUKVvQbVlIe2ZwBwLRE\"","report-to":"{\"group\":\"heroku-nel\",\"max_age\":3600,\"endpoints\":[{\"url\":\"https://nel.heroku.com/reports?ts=1729850054&sid=c4c9725f-1ab0-44d8-820f-430df2718e11&s=LZBdsiG2rgFml4T6awFYmhftTlXLyvdfcMob39wiK2I%3D\"}]}","via":"1.1 vegur","cf-ray":"8d8152f7a8d43cff-CDG","access-control-allow-origin":"*","content-length":"108","date":"Fri, 25 Oct 2024 09:54:14 GMT","content-type":"application/json; charset=utf-8","x-powered-by":"Express","server":"cloudflare"},"statusCode":201}]} \ No newline at end of file diff --git a/autotests/fixtures/fullMocks/nAsmmzYTv6.json b/autotests/fixtures/fullMocks/nAsmmzYTv6.json new file mode 100644 index 00000000..3514e0cc --- /dev/null +++ b/autotests/fixtures/fullMocks/nAsmmzYTv6.json @@ -0,0 +1 @@ +{"/products/add":[{"completionTimeInMs":1747533319483,"duration":"3ms","request":{"method":"POST","query":{"id":"135865","size":"13"},"requestBody":{"cookies":[],"input":17,"model":"samsung","title":"samsung","version":"12"},"requestHeaders":{"accept":"*/*","accept-language":"en-US","content-type":"application/json; charset=UTF-8","referer":"https://joomcode.github.io/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36","sec-ch-ua":"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"HeadlessChrome\";v=\"134\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Linux\""},"url":"https://dummyjson.com/products/add?id=135865&size=13","utcTimeInMs":1747533319480},"responseBody":{"id":135865,"method":"POST","output":"17","payload":{"id":"135865","cookies":[],"input":17,"model":"samsung","title":"samsung","version":"12"},"query":{"id":"135865","size":"13"},"url":"https://dummyjson.com/products/add?id=135865&size=13"},"responseHeaders":{"content-type":"application/json; charset=UTF-8","content-length":"241"},"statusCode":200},{"completionTimeInMs":1747533319780,"duration":"3ms","request":{"method":"POST","query":{"id":"135865","size":"13"},"requestBody":{"cookies":[],"input":17,"model":"samsung","title":"samsung","version":"12"},"requestHeaders":{"accept":"*/*","accept-language":"en-US","content-type":"application/json; charset=UTF-8","referer":"https://joomcode.github.io/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36","sec-ch-ua":"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"HeadlessChrome\";v=\"134\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Linux\""},"url":"https://dummyjson.com/products/add?id=135865&size=13","utcTimeInMs":1747533319777},"responseBody":{"id":195,"title":"samsung"},"responseHeaders":{"etag":"W/\"1c-hP2nNXEenyGq06xPgJ9a0RJCH9E\"","x-content-type-options":"nosniff","date":"Sun, 18 May 2025 01:55:19 GMT","content-type":"application/json; charset=utf-8","vary":"Accept-Encoding","x-frame-options":"SAMEORIGIN","strict-transport-security":"max-age=15552000; includeSubDomains","x-dns-prefetch-control":"off","x-ratelimit-reset":"1747533329","x-download-options":"noopen","x-ratelimit-remaining":"99","access-control-allow-origin":"*","content-length":"28","x-xss-protection":"1; mode=block","x-ratelimit-limit":"100","x-railway-request-id":"cHuLc3G1SFSKT7u4m3z_FQ","x-powered-by":"Cats on Keyboards","x-railway-edge":"railway/europe-west4-drams3a","server":"railway-edge"},"statusCode":201}]} \ No newline at end of file diff --git a/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts b/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts index 659c3945..1c725928 100644 --- a/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts +++ b/autotests/pageObjects/pages/E2edReportExample/E2edReportExample.ts @@ -4,7 +4,7 @@ import { } from 'autotests/actions'; import {setPageCookies, setPageRequestHeaders} from 'autotests/context'; import {E2edReportExample as E2edReportExampleRoute} from 'autotests/routes/pageRoutes'; -import {createSelector, locator} from 'autotests/selectors'; +import {locator} from 'autotests/selectors'; import {Page} from 'e2ed'; import {setReadonlyProperty} from 'e2ed/utils'; @@ -23,7 +23,7 @@ export class E2edReportExample extends Page { /** * Page header. */ - readonly header: Selector = createSelector('.header'); + readonly header: Selector = locator('header'); /** * Navigation bar with test retries. diff --git a/autotests/pageObjects/pages/Main.ts b/autotests/pageObjects/pages/Main.ts index 23ea6322..0ae1f1e5 100644 --- a/autotests/pageObjects/pages/Main.ts +++ b/autotests/pageObjects/pages/Main.ts @@ -67,7 +67,9 @@ export class Main extends Page { await waitForAllRequestsComplete( ({url}) => { if ( + url.startsWith('https://browser.events.data.msn.com/') || url.startsWith('https://img-s-msn-com.akamaized.net/') || + url.startsWith('https://rewards.bing.com/widget/') || url.startsWith('https://www.bing.com/th?id=') ) { return false; diff --git a/autotests/routes/apiRoutes/AddUser.ts b/autotests/routes/apiRoutes/AddUser.ts index 643b0581..ab1cc8d3 100644 --- a/autotests/routes/apiRoutes/AddUser.ts +++ b/autotests/routes/apiRoutes/AddUser.ts @@ -6,7 +6,7 @@ import type {Url} from 'e2ed/types'; type Params = Readonly<{delay?: number}>; -const pathStart = '/api/users'; +const pathStart = '/users/add'; /** * Client API route for adding user-worker. @@ -37,7 +37,7 @@ export class AddUser extends ApiRoute; -const pathStart = '/api/product/'; +const pathStart = '/products/add'; /** * Test API route for creating a product. @@ -27,7 +27,7 @@ export class CreateProduct extends ApiRoute< {urlObject}, ); - const id = Number(urlObject.pathname.slice(pathStart.length)) as ProductId; + const id = Number(urlObject.searchParams.get('id')) as ProductId; const size = Number(urlObject.searchParams.get('size')); assertValueIsTrue(Number.isInteger(id), 'url has correct id', {id, size, urlObject}); @@ -43,6 +43,6 @@ export class CreateProduct extends ApiRoute< getPath(): string { const {id, size} = this.routeParams; - return `${pathStart}${id}?size=${size}`; + return `${pathStart}?id=${id}&size=${size}`; } } diff --git a/autotests/routes/apiRoutes/GetUsers.ts b/autotests/routes/apiRoutes/GetUsers.ts index 50c44523..7bd185b2 100644 --- a/autotests/routes/apiRoutes/GetUsers.ts +++ b/autotests/routes/apiRoutes/GetUsers.ts @@ -1,21 +1,48 @@ import {ApiRoute} from 'autotests/routes'; +import {assertValueIsTrue} from 'e2ed/utils'; import type {ApiGetUsersRequest, ApiGetUsersResponse} from 'autotests/types'; import type {Url} from 'e2ed/types'; +type Params = Readonly<{delay?: number}> | undefined; + +const pathStart = '/users'; + /** * Client API route for getting users list. */ -export class GetUsers extends ApiRoute { +export class GetUsers extends ApiRoute { + static override getParamsFromUrlOrThrow(url: Url): Params { + const urlObject = new URL(url); + + assertValueIsTrue( + urlObject.pathname.startsWith(pathStart), + 'url pathname starts with correct path', + {urlObject}, + ); + + const delay = Number(urlObject.searchParams.get('delay')); + + if (delay >= 0) { + assertValueIsTrue(Number.isInteger(delay), 'url has correct delay', {delay, urlObject}); + + return {delay}; + } + + return {}; + } + getMethod(): 'GET' { return 'GET'; } override getOrigin(): Url { - return 'https://reqres.in' as Url; + return 'https://dummyjson.com' as Url; } getPath(): string { - return '/api/users?delay=3'; + const {delay} = this.routeParams ?? {}; + + return delay !== undefined ? `${pathStart}?delay=${delay}` : pathStart; } } diff --git a/autotests/tests/e2edReportExample/fullMocks.ts b/autotests/tests/e2edReportExample/fullMocks.ts index f2881a48..ebd645d4 100644 --- a/autotests/tests/e2edReportExample/fullMocks.ts +++ b/autotests/tests/e2edReportExample/fullMocks.ts @@ -35,7 +35,7 @@ test('full mocks works correctly', {enableCsp: false, meta: {testId: '18'}}, asy const mockedProduct = await addProduct(product); - const fetchUrl = `https://reqres.in/api/product/${productId}?size=${product.size}` as Url; + const fetchUrl = `https://dummyjson.com/products/add?id=${productId}&size=${product.size}` as Url; await expect(mockedProduct, 'mocked API returns correct result').eql({ id: productId, @@ -46,9 +46,10 @@ test('full mocks works correctly', {enableCsp: false, meta: {testId: '18'}}, asy id: String(productId) as DeviceId, input: product.input, model: product.model, + title: product.model, version: product.version, }, - query: {size: product.size}, + query: {id: String(productId), size: product.size}, url: fetchUrl, }); @@ -56,5 +57,8 @@ test('full mocks works correctly', {enableCsp: false, meta: {testId: '18'}}, asy const newMockedProduct = await addProduct(product); - await expect('createdAt' in newMockedProduct, 'API mock on CreateProductRoute was umocked').ok(); + await expect( + 'title' in newMockedProduct && newMockedProduct.title === product.model, + 'API mock on CreateProductRoute was umocked', + ).ok(); }); diff --git a/autotests/tests/internalTypeTests/waitForEvents.skip.ts b/autotests/tests/internalTypeTests/waitForEvents.skip.ts index 89847d4b..ed0437f7 100644 --- a/autotests/tests/internalTypeTests/waitForEvents.skip.ts +++ b/autotests/tests/internalTypeTests/waitForEvents.skip.ts @@ -93,7 +93,7 @@ void waitForRequestToRoute(AddUser, () => {}, {skipLogs: true}); // ok void waitForRequestToRoute(AddUser, { predicate: ({delay}, {requestBody, url}) => { - if (delay !== undefined && delay > 0 && requestBody.job !== 'foo') { + if (delay !== undefined && delay > 0 && requestBody.firstName !== 'foo') { return url.startsWith('https'); } @@ -101,7 +101,7 @@ void waitForRequestToRoute(AddUser, { }, }).then( ({request, routeParams}) => - request.requestBody.job === 'foo' && 'delay' in routeParams && routeParams.delay > 0, + request.requestBody.lastName === 'foo' && 'delay' in routeParams && routeParams.delay > 0, ); // @ts-expect-error: waitForRequestToRoute does not accept routes without `getParamsFromUrlOrThrow` method @@ -122,8 +122,8 @@ void waitForResponseToRoute(AddUser, { if ( delay !== undefined && delay > 0 && - requestBody.job !== 'foo' && - responseBody.job !== 'bar' + requestBody.firstName !== 'foo' && + responseBody.firstName !== 'bar' ) { return url.startsWith('https'); } @@ -132,8 +132,8 @@ void waitForResponseToRoute(AddUser, { }, }).then( ({response, routeParams}) => - response.request.requestBody.job === 'foo' && - response.responseBody.name === 'bar' && + response.request.requestBody.firstName === 'foo' && + response.responseBody.lastName === 'bar' && 'delay' in routeParams && routeParams.delay > 0, ); diff --git a/autotests/tests/mockApiRoute.ts b/autotests/tests/mockApiRoute.ts index a7204de9..fbd26737 100644 --- a/autotests/tests/mockApiRoute.ts +++ b/autotests/tests/mockApiRoute.ts @@ -35,7 +35,8 @@ test( const mockedProduct = await addProduct(product); - const fetchUrl = `https://reqres.in/api/product/${productId}?size=${product.size}` as Url; + const fetchUrl = + `https://dummyjson.com/products/add?id=${productId}&size=${product.size}` as Url; const productRouteParams = CreateProductRoute.getParamsFromUrlOrThrow(fetchUrl); @@ -54,9 +55,10 @@ test( id: String(productRouteFromUrl.routeParams.id) as DeviceId, input: product.input, model: product.model, + title: product.model, version: product.version, }, - query: {size: product.size}, + query: {id: String(productId), size: product.size}, url: fetchUrl, }); @@ -65,7 +67,7 @@ test( const newMockedProduct = await addProduct(product); await expect( - 'createdAt' in newMockedProduct, + 'title' in newMockedProduct && newMockedProduct.title === product.model, 'API mock on CreateProductRoute was umocked', ).ok(); }, diff --git a/autotests/tests/request.ts b/autotests/tests/request.ts index fcdb60aa..123cbd3f 100644 --- a/autotests/tests/request.ts +++ b/autotests/tests/request.ts @@ -8,13 +8,15 @@ test( {meta: {testId: '7'}, testIdleTimeout: 6_000}, async () => { const { - responseBody: {data}, - } = await request(GetUsers); + responseBody: {users}, + } = await request(GetUsers, { + routeParams: {delay: 3_000}, + }); - await expect(data.length, 'request returns some users').gt(0); + await expect(users.length, 'request returns some users').gt(0); await assertFunctionThrows(async () => { - await request(GetUsers, {maxRetriesCount: 1, timeout: 2_000}); + await request(GetUsers, {maxRetriesCount: 1, routeParams: {delay: 3_000}, timeout: 2_000}); }, 'request function throws an error on timeout'); }, ); diff --git a/autotests/tests/switchingPagesForRequests.ts b/autotests/tests/switchingPagesForRequests.ts new file mode 100644 index 00000000..38aa80dd --- /dev/null +++ b/autotests/tests/switchingPagesForRequests.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ + +import {test} from 'autotests'; +import {getUsers} from 'autotests/entities'; +import {E2edReportExample} from 'autotests/pageObjects/pages'; +import {GetUsers} from 'autotests/routes/apiRoutes'; +import {expect} from 'e2ed'; +import { + click, + navigateToPage, + switchToTab, + waitForNewTab, + waitForRequestToRoute, + waitForTimeout, +} from 'e2ed/actions'; +import {log} from 'e2ed/utils'; + +const maxNumberOfRequests = 15; + +const timeout = (maxNumberOfRequests + 10) * 1_000; + +test( + 'support switching of tabs for waitForRequest', + {enableCsp: false, meta: {testId: '21'}, testTimeout: timeout + 1_000}, + async () => { + let numberOfCaughtRequests = 0; + let numberOfSentRequests = 0; + + setInterval(() => { + if (numberOfSentRequests < maxNumberOfRequests) { + numberOfSentRequests += 1; + + log(`Sent request number ${numberOfSentRequests}`); + + void getUsers({retries: 1}); + } + }, 1_000); + + void waitForRequestToRoute(GetUsers, { + predicate: (routeParams, request) => { + numberOfCaughtRequests += 1; + + log(`Caught request number ${numberOfCaughtRequests}`, {request, routeParams}); + + return false; + }, + timeout, + }); + + await waitForTimeout(maxNumberOfRequests * 333); + + const reportPage = await navigateToPage(E2edReportExample); + + await waitForTimeout(maxNumberOfRequests * 333); + + const npmPageTab = await waitForNewTab(async () => { + await click(reportPage.header); + }); + + await switchToTab(npmPageTab); + + await waitForTimeout(maxNumberOfRequests * 333 + 1_000); + + await expect( + numberOfSentRequests === numberOfCaughtRequests || + numberOfSentRequests === numberOfCaughtRequests + 1 || + numberOfSentRequests === numberOfCaughtRequests - 1, + `almost all responses were caught (${numberOfCaughtRequests} of ${numberOfSentRequests})`, + ).ok(); + }, +); diff --git a/autotests/tests/switchingPagesForResponses.ts b/autotests/tests/switchingPagesForResponses.ts new file mode 100644 index 00000000..30493eba --- /dev/null +++ b/autotests/tests/switchingPagesForResponses.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ + +import {test} from 'autotests'; +import {getUsers} from 'autotests/entities'; +import {E2edReportExample} from 'autotests/pageObjects/pages'; +import {GetUsers} from 'autotests/routes/apiRoutes'; +import {expect} from 'e2ed'; +import { + click, + navigateToPage, + switchToTab, + waitForNewTab, + waitForResponseToRoute, + waitForTimeout, +} from 'e2ed/actions'; +import {log} from 'e2ed/utils'; + +const maxNumberOfRequests = 15; + +const timeout = (maxNumberOfRequests + 10) * 1_000; + +test( + 'support switching of tabs for waitForResponse', + {enableCsp: false, meta: {testId: '22'}, testTimeout: timeout + 1_000}, + async () => { + let numberOfCaughtResponses = 0; + let numberOfSentRequests = 0; + + setInterval(() => { + if (numberOfSentRequests < maxNumberOfRequests) { + numberOfSentRequests += 1; + + log(`Sent request number ${numberOfSentRequests}`); + + void getUsers({retries: 1}); + } + }, 1_000); + + void waitForResponseToRoute(GetUsers, { + predicate: (routeParams, response) => { + numberOfCaughtResponses += 1; + + log(`Caught response number ${numberOfCaughtResponses}`, {response, routeParams}); + + return false; + }, + timeout, + }); + + await waitForTimeout(maxNumberOfRequests * 333); + + const reportPage = await navigateToPage(E2edReportExample); + + await waitForTimeout(maxNumberOfRequests * 333); + + const npmPageTab = await waitForNewTab(async () => { + await click(reportPage.header); + }); + + await switchToTab(npmPageTab); + + await waitForTimeout(maxNumberOfRequests * 333 + 1_000); + + await expect( + numberOfSentRequests === numberOfCaughtResponses || + numberOfSentRequests === numberOfCaughtResponses + 1 || + numberOfSentRequests === numberOfCaughtResponses - 1, + `almost all responses were caught (${numberOfCaughtResponses} of ${numberOfSentRequests})`, + ).ok(); + }, +); diff --git a/autotests/tests/waitForAllRequestsComplete.ts b/autotests/tests/waitForAllRequestsComplete.ts index 12bfc9b5..3d66843c 100644 --- a/autotests/tests/waitForAllRequestsComplete.ts +++ b/autotests/tests/waitForAllRequestsComplete.ts @@ -16,7 +16,7 @@ test( let waitedInMs = Date.now() - startRequestInMs; - if (waitedInMs < 300 || waitedInMs > 400) { + if (waitedInMs < 250 || waitedInMs > 450) { throw new E2edError( 'waitForAllRequestsComplete did not wait for maxIntervalBetweenRequestsInMs in the beginning', {waitedInMs}, @@ -33,7 +33,7 @@ test( let promise = waitForAllRequestsComplete(() => true, {timeout: 1000}); - void getUsers(2); + void getUsers({delay: 2_000}); await assertFunctionThrows( () => promise, @@ -46,12 +46,12 @@ test( throw new E2edError('waitForAllRequestsComplete did not wait for timeout', {waitedInMs}); } - void getUsers(2); + void getUsers({delay: 2_000}); startRequestInMs = Date.now(); await waitForAllRequestsComplete( - ({url}) => !url.includes('https://reqres.in/api/users?delay='), + ({url}) => !url.includes('https://dummyjson.com/users?delay='), {timeout: 1000}, ); @@ -66,9 +66,9 @@ test( promise = waitForAllRequestsComplete(() => true); - await getUsers(1); + await getUsers({delay: 1_000}); await waitForTimeout(400); - await getUsers(1); + await getUsers({delay: 1_000}); startRequestInMs = Date.now(); @@ -85,7 +85,7 @@ test( promise = waitForAllRequestsComplete(() => true, {maxIntervalBetweenRequestsInMs: 300}); - await getUsers(1); + await getUsers({delay: 1_000}); startRequestInMs = Date.now(); diff --git a/autotests/tests/waitForRequest.ts b/autotests/tests/waitForRequest.ts index e0d069d9..364098cf 100644 --- a/autotests/tests/waitForRequest.ts +++ b/autotests/tests/waitForRequest.ts @@ -1,4 +1,7 @@ +/* eslint-disable max-lines */ + import {test} from 'autotests'; +import {getPageCookies} from 'autotests/context'; import {addUser} from 'autotests/entities'; import {AddUser} from 'autotests/routes/apiRoutes'; import {expect} from 'e2ed'; @@ -7,16 +10,23 @@ import {assertFunctionThrows} from 'e2ed/utils'; import type {ApiAddUserRequest, UserWorker} from 'autotests/types'; -const worker: UserWorker = {job: 'leader', name: 'John'}; +const worker: UserWorker = {firstName: 'John', lastName: 'Doe'}; test( 'waitForRequest/waitForRequestToRoute gets correct request body and rejects on timeout', {meta: {testId: '2'}, testIdleTimeout: 3_000}, + // eslint-disable-next-line max-lines-per-function async () => { const request = await waitForRequest( - ({requestBody}: ApiAddUserRequest) => requestBody.name === 'John', + ({requestBody}: ApiAddUserRequest) => { + getPageCookies(); + + return requestBody.firstName === worker.firstName; + }, async () => { - await addUser(worker); + getPageCookies(); + + await addUser({user: worker}); }, ); @@ -32,17 +42,30 @@ test( throw new Error('foo'); }, () => { - void addUser(worker); + void addUser({user: worker}); }, - ).catch((error: Error & {cause?: {message?: string}}) => { - if (error.cause?.message === 'foo') { + ).catch((error: Error) => { + if (error.cause instanceof Error && error.cause.message === 'foo') { throw error; } }); }, 'waitForRequest throws an error from predicate'); + await assertFunctionThrows(async () => { + await waitForRequest( + () => true, + () => { + throw new Error('foo'); + }, + ).catch((error: unknown) => { + if (error instanceof Error && error.message === 'foo') { + throw error; + } + }); + }, 'waitForRequest throws an error from trigger'); + let {request: routeRequest, routeParams} = await waitForRequestToRoute(AddUser, async () => { - await addUser(worker, 1); + await addUser({delay: 1_000, user: worker}); }); await expect( @@ -50,46 +73,80 @@ test( 'request from waitForRequestToRoute has correct body', ).eql(worker); - await expect(routeParams, 'routeParams from waitForRequestToRoute is correct').eql({delay: 1}); + await expect(routeParams, 'routeParams from waitForRequestToRoute is correct').eql({ + delay: 1_000, + }); ({request: routeRequest, routeParams} = await waitForRequestToRoute( AddUser, async () => { - await addUser(worker, 1); + await addUser({delay: 1_000, user: worker}); }, { predicate: ({delay}, {requestBody, url}) => - delay === 1 && url.endsWith('delay=1') && requestBody.name === worker.name, + delay === 1_000 && + url.endsWith('delay=1000') && + requestBody.firstName === worker.firstName, }, )); await expect( routeParams, 'routeParams from waitForRequestToRoute with predicate is correct', - ).eql({delay: 1}); + ).eql({delay: 1_000}); await assertFunctionThrows(async () => { await waitForRequestToRoute( AddUser, async () => { - await addUser(worker); + await addUser({user: worker}); }, - {predicate: ({delay}) => delay === 1, timeout: 2_000}, + {predicate: ({delay}) => delay === 1_000, timeout: 2_000}, ); }, 'waitForRequestToRoute throws an error on timeout'); - void addUser(worker); + void addUser({user: worker}); await assertFunctionThrows(async () => { await waitForRequestToRoute(AddUser, { predicate: () => { throw new Error('foo'); }, - }).catch((error: Error & {cause?: {message?: string}}) => { - if (error.cause?.message === 'foo') { + }).catch((error: Error) => { + if (error.cause instanceof Error && error.cause.message === 'foo') { throw error; } }); }, 'waitForRequestToRoute throws an error from predicate'); + + await assertFunctionThrows(async () => { + await waitForRequestToRoute(AddUser, async () => { + await Promise.resolve(); + + throw new Error('foo'); + }).catch((error: unknown) => { + if (error instanceof Error && error.message === 'foo') { + throw error; + } + }); + }, 'waitForRequestToRoute throws an error from trigger'); + + await assertFunctionThrows(async () => { + await waitForRequestToRoute( + AddUser, + async () => { + await addUser({user: worker}); + }, + { + predicate: () => { + throw new Error('foo'); + }, + }, + ).catch((error: Error) => { + if (error.cause instanceof Error && error.cause.message === 'foo') { + throw error; + } + }); + }, 'waitForRequestToRoute throws an error from predicate with trigger'); }, ); diff --git a/autotests/tests/waitForResponse.ts b/autotests/tests/waitForResponse.ts index 0ff92cd3..70f07ee5 100644 --- a/autotests/tests/waitForResponse.ts +++ b/autotests/tests/waitForResponse.ts @@ -1,4 +1,7 @@ +/* eslint-disable max-lines */ + import {test} from 'autotests'; +import {getPageCookies} from 'autotests/context'; import {addUser} from 'autotests/entities'; import {AddUser} from 'autotests/routes/apiRoutes'; import {expect} from 'e2ed'; @@ -7,16 +10,23 @@ import {assertFunctionThrows} from 'e2ed/utils'; import type {ApiAddUserRequest, ApiAddUserResponse, UserWorker} from 'autotests/types'; -const worker: UserWorker = {job: 'leader', name: 'John'}; +const worker: UserWorker = {firstName: 'John', lastName: 'Doe'}; test( 'waitForResponse/waitForResponseToRoute gets correct response body and rejects on timeout', {meta: {testId: '3'}, testIdleTimeout: 3_000}, + // eslint-disable-next-line max-lines-per-function async () => { let response = await waitForResponse( - ({responseBody}) => responseBody.name === 'John', + ({responseBody}) => { + getPageCookies(); + + return responseBody.firstName === worker.firstName; + }, async () => { - await addUser(worker); + getPageCookies(); + + await addUser({user: worker}); }, ); @@ -27,9 +37,9 @@ test( }, 'waitForResponse throws an error on timeout'); response = await waitForResponse( - ({request}) => request.url === 'https://reqres.in/api/users', + ({request}) => request.url === 'https://dummyjson.com/users/add', async () => { - await addUser(worker); + await addUser({user: worker}); }, ); @@ -41,17 +51,32 @@ test( throw new Error('foo'); }, () => { - void addUser(worker); + void addUser({user: worker}); }, - ).catch((error: Error & {cause?: {message?: string}}) => { - if (error.cause?.message === 'foo') { + ).catch((error: Error) => { + if (error.cause instanceof Error && error.cause.message === 'foo') { throw error; } }); }, 'waitForResponse throws an error from predicate'); + await assertFunctionThrows(async () => { + await waitForResponse( + () => true, + async () => { + await Promise.resolve(); + + throw new Error('foo'); + }, + ).catch((error: unknown) => { + if (error instanceof Error && error.message === 'foo') { + throw error; + } + }); + }, 'waitForResponse throws an error from trigger'); + let {response: routeResponse, routeParams} = await waitForResponseToRoute(AddUser, async () => { - await addUser(worker, 1); + await addUser({delay: 1_000, user: worker}); }); await expect( @@ -59,52 +84,78 @@ test( 'request from waitForResponseToRoute has correct body', ).eql(worker); - await expect(routeParams, 'routeParams from waitForResponseToRoute is correct').eql({delay: 1}); + await expect(routeParams, 'routeParams from waitForResponseToRoute is correct').eql({ + delay: 1_000, + }); ({response: routeResponse, routeParams} = await waitForResponseToRoute( AddUser, async () => { - await addUser(worker, 1); + await addUser({delay: 1_000, user: worker}); }, { predicate: ({delay}, {request, responseBody}) => - delay === 1 && - request.requestBody.job === worker.job && - responseBody.name === worker.name, + delay === 1_000 && + request.requestBody.firstName === worker.firstName && + responseBody.lastName === worker.lastName, }, )); await expect( routeParams, 'routeParams from waitForRequestToRoute with predicate is correct', - ).eql({delay: 1}); + ).eql({delay: 1_000}); await assertFunctionThrows(async () => { await waitForResponseToRoute( AddUser, async () => { - await addUser(worker); + await addUser({user: worker}); }, - {predicate: ({delay}) => delay === 1, timeout: 2_000}, + {predicate: ({delay}) => delay === 1_000, timeout: 2_000}, ); }, 'waitForResponseToRoute throws an error on timeout'); + void addUser({user: worker}); + + await assertFunctionThrows(async () => { + await waitForResponseToRoute(AddUser, { + predicate: () => { + throw new Error('foo'); + }, + }).catch((error: Error) => { + if (error.cause instanceof Error && error.cause.message === 'foo') { + throw error; + } + }); + }, 'waitForResponseToRoute throws an error from predicate'); + + await assertFunctionThrows(async () => { + await waitForResponseToRoute(AddUser, () => { + throw new Error('foo'); + }).catch((error: unknown) => { + if (error instanceof Error && error.message === 'foo') { + throw error; + } + }); + }, 'waitForResponseToRoute throws an error from trigger'); + await assertFunctionThrows(async () => { await waitForResponseToRoute( AddUser, async () => { - await addUser(worker); + await addUser({user: worker}); }, { predicate: () => { throw new Error('foo'); }, }, - ).catch((error: Error & {cause?: {message?: string}}) => { - if (error.cause?.message === 'foo') { + ).catch((error: Error) => { + if (error.cause instanceof Error && error.cause.message === 'foo') { throw error; } }); - }, 'waitForResponseToRoute throws an error from predicate'); + }, 'waitForResponseToRoute throws an error from predicate with trigger'); }, ); diff --git a/autotests/types/api/GetUsers.ts b/autotests/types/api/GetUsers.ts index ad26ec8c..a4c29cd7 100644 --- a/autotests/types/api/GetUsers.ts +++ b/autotests/types/api/GetUsers.ts @@ -3,7 +3,7 @@ import type {Request, Response} from 'e2ed/types'; type RequestBody = undefined; type ResponseBody = Readonly<{ - data: readonly object[]; + users: readonly object[]; }>; /** diff --git a/autotests/types/entities/device.ts b/autotests/types/entities/device.ts index 9c364757..b7033a6a 100644 --- a/autotests/types/entities/device.ts +++ b/autotests/types/entities/device.ts @@ -12,6 +12,7 @@ export type ApiDeviceParams = Readonly<{ cookies: readonly string[]; input: number; model: MobileDeviceModel; + title: string; version: string; }>; diff --git a/autotests/types/entities/worker.ts b/autotests/types/entities/worker.ts index 61b83513..f632b68b 100644 --- a/autotests/types/entities/worker.ts +++ b/autotests/types/entities/worker.ts @@ -1,4 +1,4 @@ /** * User-worker. */ -export type UserWorker = Readonly<{job: string; name: string}>; +export type UserWorker = Readonly<{firstName: string; lastName: string}>; diff --git a/package-lock.json b/package-lock.json index 1641b7fe..0b1fb554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,9 @@ "version": "0.20.9", "license": "MIT", "dependencies": { - "@playwright/test": "1.51.1", + "@playwright/test": "1.52.0", "create-locator": "0.0.27", "get-modules-graph": "0.0.11", - "globby": "11.1.0", "sort-json-keys": "1.0.3" }, "bin": { @@ -21,15 +20,15 @@ "e2ed-install-browsers": "bin/installBrowsers.js" }, "devDependencies": { - "@playwright/browser-chromium": "1.51.1", - "@types/node": "22.14.0", + "@playwright/browser-chromium": "1.52.0", + "@types/node": "22.15.19", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "assert-modules-support-case-insensitive-fs": "1.0.1", "assert-package-lock-is-consistent": "1.0.0", "eslint": "8.57.1", "eslint-config-airbnb-base": "15.0.0", - "eslint-config-prettier": "10.1.1", + "eslint-config-prettier": "10.1.5", "eslint-plugin-import": "2.31.0", "eslint-plugin-simple-import-sort": "12.1.1", "eslint-plugin-typescript-sort-keys": "3.3.0", @@ -38,7 +37,7 @@ "typescript": "5.8.3" }, "engines": { - "node": ">=20.16.0" + "node": ">=22.14.0" }, "peerDependencies": { "@types/node": ">=20", @@ -152,6 +151,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -164,6 +164,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "engines": { "node": ">= 8" } @@ -172,6 +173,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -181,26 +183,26 @@ } }, "node_modules/@playwright/browser-chromium": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.51.1.tgz", - "integrity": "sha512-Xebxk0SrDKttd8VGiUwLxOMbuH/Lf/+vFyzFG7QHVvqsAOw3Ec7Xdl1HRB4dnVP/RTEytkH4OgQ4OFy6K2c1xw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.52.0.tgz", + "integrity": "sha512-n2/e2Q0dFACFg/1JZ0t2IYLorDdno6q1QwKnNbPICHwCkAtW7+fSMqCvJ9FSMWSyPugxZqIFhownSpyATxtiTw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.51.1" + "playwright-core": "1.52.0" }, "engines": { "node": ">=18" } }, "node_modules/@playwright/test": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", - "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.51.1" + "playwright": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -229,9 +231,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.14.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", - "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", + "version": "22.15.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz", + "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", "dev": true, "license": "MIT", "dependencies": { @@ -553,6 +555,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, "engines": { "node": ">=8" } @@ -697,6 +700,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -821,9 +825,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -884,6 +888,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, "dependencies": { "path-type": "^4.0.0" }, @@ -1126,14 +1131,17 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz", - "integrity": "sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw==", + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, "peerDependencies": { "eslint": ">=7.0.0" } @@ -1690,6 +1698,7 @@ "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -1717,6 +1726,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.1.tgz", "integrity": "sha512-HOnr8Mc60eNYl1gzwp6r5RoUyAn5/glBolUzP/Ez6IFVPMPirxn/9phgL6zhOtaTy7ISwPvQ+wT+hfcRZh/bzw==", + "dev": true, "dependencies": { "reusify": "^1.0.4" } @@ -1737,6 +1747,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -1918,6 +1929,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -1959,6 +1971,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", @@ -2089,6 +2102,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, "engines": { "node": ">= 4" } @@ -2254,6 +2268,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2262,6 +2277,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -2286,6 +2302,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -2477,6 +2494,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "engines": { "node": ">= 8" } @@ -2485,6 +2503,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -2719,6 +2738,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, "engines": { "node": ">=8" } @@ -2727,6 +2747,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -2736,12 +2757,12 @@ } }, "node_modules/playwright": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", - "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.51.1" + "playwright-core": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -2754,9 +2775,9 @@ } }, "node_modules/playwright-core": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", - "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -2813,6 +2834,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -2877,6 +2899,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -2886,6 +2909,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -2942,9 +2966,9 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -3032,6 +3056,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "engines": { "node": ">=8" } @@ -3146,6 +3171,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, diff --git a/package.json b/package.json index d3cf39f9..9d10f72b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "bugs": "https://github.com/joomcode/e2ed/issues", "engines": { - "node": ">=20.16.0" + "node": ">=22.14.0" }, "packageManager": "npm@10", "homepage": "https://github.com/joomcode/e2ed#readme", @@ -25,22 +25,21 @@ "url": "git+https://github.com/joomcode/e2ed.git" }, "dependencies": { - "@playwright/test": "1.51.1", + "@playwright/test": "1.52.0", "create-locator": "0.0.27", "get-modules-graph": "0.0.11", - "globby": "11.1.0", "sort-json-keys": "1.0.3" }, "devDependencies": { - "@playwright/browser-chromium": "1.51.1", - "@types/node": "22.14.0", + "@playwright/browser-chromium": "1.52.0", + "@types/node": "22.15.19", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "assert-modules-support-case-insensitive-fs": "1.0.1", "assert-package-lock-is-consistent": "1.0.0", "eslint": "8.57.1", "eslint-config-airbnb-base": "15.0.0", - "eslint-config-prettier": "10.1.1", + "eslint-config-prettier": "10.1.5", "eslint-plugin-import": "2.31.0", "eslint-plugin-simple-import-sort": "12.1.1", "eslint-plugin-typescript-sort-keys": "3.3.0", @@ -61,7 +60,6 @@ "./createLocator": "./createLocator.js", "./generators": "./generators/index.js", "./getModulesGraph": "./getModulesGraph.js", - "./globby": "./globby.js", "./selectors": "./selectors/index.js", "./types": "./types/index.js", "./utils": "./utils/index.js" diff --git a/src/actions/switchToMainTab.ts b/src/actions/switchToMainTab.ts index 72e38a9b..f234fe3a 100644 --- a/src/actions/switchToMainTab.ts +++ b/src/actions/switchToMainTab.ts @@ -2,15 +2,18 @@ import {LogEventType} from '../constants/internal'; import {clearTab} from '../context/tab'; import {getPlaywrightPage} from '../useContext'; import {log} from '../utils/log'; +import {switchPlaywrightPage} from '../utils/playwrightPage'; /** * Switches page context to the specified tab. */ -export const switchToMainTab = (): void => { +export const switchToMainTab = async (): Promise => { + clearTab(); + const page = getPlaywrightPage(); const url = page.url(); log(`Switch page context to the main tab at ${url}`, LogEventType.InternalAction); - clearTab(); + await switchPlaywrightPage(page); }; diff --git a/src/actions/switchToTab.ts b/src/actions/switchToTab.ts index 591b2bbe..b3cdaff2 100644 --- a/src/actions/switchToTab.ts +++ b/src/actions/switchToTab.ts @@ -1,17 +1,20 @@ import {LogEventType} from '../constants/internal'; import {setTab} from '../context/tab'; import {log} from '../utils/log'; +import {switchPlaywrightPage} from '../utils/playwrightPage'; import type {InternalTab, Tab} from '../types/internal'; /** * Switches page context to the specified tab. */ -export const switchToTab = (tab: Tab): void => { +export const switchToTab = async (tab: Tab): Promise => { const {page} = tab as InternalTab; const url = page.url(); log(`Switch page context to the specified tab at ${url}`, LogEventType.InternalAction); setTab(tab); + + await switchPlaywrightPage(page); }; diff --git a/src/actions/waitFor/waitForRequest.ts b/src/actions/waitFor/waitForRequest.ts index 57d3e92e..b434f19b 100644 --- a/src/actions/waitFor/waitForRequest.ts +++ b/src/actions/waitFor/waitForRequest.ts @@ -1,3 +1,5 @@ +import {AsyncLocalStorage} from 'node:async_hooks'; + import {LogEventType, MAX_TIMEOUT_IN_MS} from '../../constants/internal'; import {getTestRunPromise} from '../../context/testRunPromise'; import {getPlaywrightPage} from '../../useContext'; @@ -6,9 +8,12 @@ import {E2edError} from '../../utils/error'; import {setCustomInspectOnFunction} from '../../utils/fn'; import {getDurationWithUnits} from '../../utils/getDurationWithUnits'; import {log} from '../../utils/log'; +import {pageWaitForRequest} from '../../utils/playwrightPage'; import {addTimeoutToPromise} from '../../utils/promise'; import {getRequestFromPlaywrightRequest} from '../../utils/requestHooks'; +import type {Request as PlaywrightRequest} from '@playwright/test'; + import type { Request, RequestPredicate, @@ -63,9 +68,13 @@ export const waitForRequest = (async ( const timeoutWithUnits = getDurationWithUnits(timeout); + let finalError: unknown; + let hasError = false; + const promise = addTimeoutToPromise( - page.waitForRequest( - async (playwrightRequest) => { + pageWaitForRequest( + page, + AsyncLocalStorage.bind(async (playwrightRequest: PlaywrightRequest) => { try { const request = getRequestFromPlaywrightRequest(playwrightRequest); @@ -73,21 +82,28 @@ export const waitForRequest = (async ( return result; } catch (cause) { - throw new E2edError('waitForRequest predicate threw an exception', { - cause, - timeout, - trigger, - }); + if (!isTestRunCompleted) { + finalError = new E2edError('waitForRequest predicate threw an exception', { + cause, + timeout, + trigger, + }); + hasError = true; + } + + return true; } - }, + }), {timeout: MAX_TIMEOUT_IN_MS}, ), timeout, new E2edError(`waitForRequest promise rejected after ${timeoutWithUnits} timeout`), ) .then( - (playwrightRequest) => - getRequestFromPlaywrightRequest(playwrightRequest) as RequestWithUtcTimeInMs, + AsyncLocalStorage.bind( + (playwrightRequest: PlaywrightRequest) => + getRequestFromPlaywrightRequest(playwrightRequest) as RequestWithUtcTimeInMs, + ), ) .catch((error: unknown) => { if (isTestRunCompleted) { @@ -109,6 +125,10 @@ export const waitForRequest = (async ( const request = await promise; + if (hasError) { + throw finalError; + } + if (finalOptions?.skipLogs !== true) { const waitWithUnits = getDurationWithUnits(Date.now() - startTimeInMs); diff --git a/src/actions/waitFor/waitForResponse.ts b/src/actions/waitFor/waitForResponse.ts index 57b199a5..498f7e64 100644 --- a/src/actions/waitFor/waitForResponse.ts +++ b/src/actions/waitFor/waitForResponse.ts @@ -8,10 +8,13 @@ import {E2edError} from '../../utils/error'; import {setCustomInspectOnFunction} from '../../utils/fn'; import {getDurationWithUnits} from '../../utils/getDurationWithUnits'; import {log} from '../../utils/log'; +import {pageWaitForResponse} from '../../utils/playwrightPage'; import {addTimeoutToPromise} from '../../utils/promise'; import {getResponseFromPlaywrightResponse} from '../../utils/requestHooks'; import {getWaitForResponsePredicate} from '../../utils/waitForEvents'; +import type {Response as PlaywrightResponse} from '@playwright/test'; + import type { Request, Response, @@ -37,6 +40,7 @@ type Options = Readonly<{includeNavigationRequest?: boolean; skipLogs?: boolean; * Waits for some response (from browser) filtered by the response predicate. * If the function runs longer than the specified timeout, it is rejected. */ +// eslint-disable-next-line max-statements export const waitForResponse = (async < SomeRequest extends Request = Request, SomeResponse extends Response = Response, @@ -70,15 +74,35 @@ export const waitForResponse = (async < const timeoutWithUnits = getDurationWithUnits(timeout); + const finalPredicate = getWaitForResponsePredicate( + predicate as ResponsePredicate, + finalOptions?.includeNavigationRequest ?? false, + ); + + let finalError: unknown; + let hasError = false; + const promise = addTimeoutToPromise( - page.waitForResponse( - AsyncLocalStorage.bind( - getWaitForResponsePredicate( - predicate as ResponsePredicate, - finalOptions?.includeNavigationRequest ?? false, - timeout, - ), - ), + pageWaitForResponse( + page, + AsyncLocalStorage.bind(async (playwrightResponse: PlaywrightResponse) => { + try { + const result = await finalPredicate(playwrightResponse); + + return result; + } catch (cause) { + if (!isTestRunCompleted) { + finalError = new E2edError('waitForResponse predicate threw an exception', { + cause, + timeout, + trigger, + }); + hasError = true; + } + + return true; + } + }), {timeout: MAX_TIMEOUT_IN_MS}, ), timeout, @@ -110,6 +134,10 @@ export const waitForResponse = (async < const response = await promise; + if (hasError) { + throw finalError; + } + if (finalOptions?.skipLogs !== true) { const waitWithUnits = getDurationWithUnits(Date.now() - startTimeInMs); diff --git a/src/config.ts b/src/config.ts index bb0ab535..7c6133cc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,8 +20,12 @@ import { import {assertValueIsTrue} from './utils/asserts'; // eslint-disable-next-line import/no-internal-modules import {assertUserlandPack} from './utils/config/assertUserlandPack'; +// eslint-disable-next-line import/no-internal-modules +import {updateConfig} from './utils/config/updateConfig'; import {getPathToPack} from './utils/environment'; import {setCustomInspectOnFunction} from './utils/fn'; +// eslint-disable-next-line import/no-internal-modules +import {readStartInfoSync} from './utils/fs/readStartInfoSync'; import {setReadonlyProperty} from './utils/object'; import {isUiMode} from './utils/uiMode'; import {isLocalRun} from './configurator'; @@ -116,31 +120,27 @@ const playwrightConfig = defineConfig({ pathTemplate: `${ABSOLUTE_PATH_TO_PROJECT_ROOT_DIRECTORY}/${EXPECTED_SCREENSHOTS_DIRECTORY_PATH}/{arg}.png`, }, }, - fullyParallel: true, - globalTimeout: userlandPack.packTimeout, - outputDir: join(relativePathFromInstalledE2edToRoot, INTERNAL_REPORTS_DIRECTORY_PATH), - projects: [{name: userlandPack.browserName, use: useOptions}], - retries: isLocalRun ? 0 : userlandPack.maxRetriesCountInDocker - 1, - testDir: join(relativePathFromInstalledE2edToRoot, TESTS_DIRECTORY_PATH), testIgnore: ['**/node_modules/**', '**/*.skip.ts'], testMatch: userlandPack.testFileGlobs as Mutable, - timeout: userlandPack.testTimeout, - workers: userlandPack.concurrency, - ...userlandPack.overriddenConfigFields, - use: useOptions, }); const config: FullPackConfig = Object.assign(playwrightConfig, userlandPack); +try { + const startInfo = readStartInfoSync(); + + updateConfig(config, startInfo); +} catch {} + // eslint-disable-next-line import/no-default-export export default config; diff --git a/src/constants/internal.ts b/src/constants/internal.ts index 12cad0d6..0238a03e 100644 --- a/src/constants/internal.ts +++ b/src/constants/internal.ts @@ -63,6 +63,8 @@ export { TMP_DIRECTORY_PATH, } from './paths'; /** @internal */ +export {TEST_ENDED_ERROR_MESSAGE} from './playwright'; +/** @internal */ export {RESOLVED_PROMISE} from './promise'; /** @internal */ export {RETRY_KEY} from './selector'; diff --git a/src/constants/playwright.ts b/src/constants/playwright.ts new file mode 100644 index 00000000..8be18157 --- /dev/null +++ b/src/constants/playwright.ts @@ -0,0 +1,5 @@ +/** + * Playwright error message for already ended test. + * @internal + */ +export const TEST_ENDED_ERROR_MESSAGE = 'Test ended.'; diff --git a/src/context/clearPage.ts b/src/context/clearPage.ts new file mode 100644 index 00000000..f9cda55c --- /dev/null +++ b/src/context/clearPage.ts @@ -0,0 +1,7 @@ +import {useContext} from '../useContext'; + +/** + * Get and set internal (maybe `undefined`) `clearPage` function. + * @internal + */ +export const [getClearPage, setClearPage] = useContext<() => Promise>(); diff --git a/src/createClientFunction.ts b/src/createClientFunction.ts index 7ff439fb..0a390413 100644 --- a/src/createClientFunction.ts +++ b/src/createClientFunction.ts @@ -1,3 +1,4 @@ +import {TEST_ENDED_ERROR_MESSAGE} from './constants/internal'; import {getTestIdleTimeout} from './context/testIdleTimeout'; import {E2edError} from './utils/error'; import {setCustomInspectOnFunction} from './utils/fn'; @@ -9,14 +10,17 @@ import {getPlaywrightPage} from './useContext'; import type {ClientFunction} from './types/internal'; -type Options = Readonly<{name?: string; timeout?: number}>; +type Options = Readonly<{name?: string; retries?: number; timeout?: number}>; + +const contextErrorMessage = 'Execution context was destroyed'; +const targetErrorMessage = 'Target page, context or browser has been closed'; /** * Creates a client function. */ export const createClientFunction = ( originalFn: (...args: Args) => Result, - {name: nameFromOptions, timeout}: Options = {}, + {name: nameFromOptions, retries = 0, timeout}: Options = {}, ): ClientFunction => { setCustomInspectOnFunction(originalFn); @@ -42,10 +46,50 @@ export const createClientFunction = ( page.evaluate(func, args).catch(async (evaluateError: unknown) => { const errorString = String(evaluateError); - if (errorString.includes('Execution context was destroyed')) { + if ( + errorString.includes(contextErrorMessage) || + errorString.includes(targetErrorMessage) || + errorString.includes(TEST_ENDED_ERROR_MESSAGE) + ) { await page.waitForLoadState(); - return page.evaluate(func, args); + return page.evaluate(func, args).catch((suberror: unknown) => { + const suberrorString = String(suberror); + + if ( + suberrorString.includes(contextErrorMessage) || + suberrorString.includes(targetErrorMessage) || + suberrorString.includes(TEST_ENDED_ERROR_MESSAGE) + ) { + return new Promise(() => {}); + } + + throw suberror; + }); + } + + if (retries > 0) { + let retryIndex = 1; + + while (retryIndex <= retries) { + retryIndex += 1; + + try { + return page.evaluate(func, args).catch((suberror: unknown) => { + const suberrorString = String(suberror); + + if ( + suberrorString.includes(contextErrorMessage) || + suberrorString.includes(targetErrorMessage) || + suberrorString.includes(TEST_ENDED_ERROR_MESSAGE) + ) { + return new Promise(() => {}); + } + + throw suberror; + }); + } catch {} + } } throw evaluateError; diff --git a/src/globby.ts b/src/globby.ts deleted file mode 100644 index 62e955da..00000000 --- a/src/globby.ts +++ /dev/null @@ -1 +0,0 @@ -export {generateGlobTasks, gitignore, default as globby, hasMagic, stream, sync} from 'globby'; diff --git a/src/test.ts b/src/test.ts index a8beea80..e7a34f60 100644 --- a/src/test.ts +++ b/src/test.ts @@ -7,8 +7,6 @@ import type {TestFunction} from './types/internal'; import {test as playwrightTest} from '@playwright/test'; -process.removeAllListeners('unhandledRejection'); - process.on('uncaughtException', getGlobalErrorHandler('TestUncaughtException')); process.on('unhandledRejection', getGlobalErrorHandler('TestUnhandledRejection')); diff --git a/src/types/config/ownE2edConfig.ts b/src/types/config/ownE2edConfig.ts index 8aca1b10..61567d8b 100644 --- a/src/types/config/ownE2edConfig.ts +++ b/src/types/config/ownE2edConfig.ts @@ -233,7 +233,7 @@ export type OwnE2edConfig< /** * An array of globs with pack test (task) files. - * {@link https://www.npmjs.com/package/globby} is used for matching globs. + * `fs.glob` from `nodejs` is used for matching globs. */ testFileGlobs: readonly string[]; diff --git a/src/types/report.ts b/src/types/report.ts index e25bffc6..a330d899 100644 --- a/src/types/report.ts +++ b/src/types/report.ts @@ -93,7 +93,7 @@ export type ReportClientData = Readonly<{ export type ReportClientState = { clickListeners?: Record void>; readonly createLocatorOptions: CreateLocatorOptions; - readonly e2edRightColumnContainer: HTMLElement; + readonly e2edRightColumnContainer: HTMLElement | undefined; readonly fullTestRuns: readonly FullTestRun[]; readonly internalDirectoryName: string; lengthOfReadedJsonReportDataParts: number; diff --git a/src/utils/config/updateConfig.ts b/src/utils/config/updateConfig.ts index 5c7e6efa..3117aeda 100644 --- a/src/utils/config/updateConfig.ts +++ b/src/utils/config/updateConfig.ts @@ -14,7 +14,7 @@ const skippedFields: readonly (keyof FullPackConfig)[] = [ * @internal */ export const updateConfig = (fullPackConfig: FullPackConfig, startInfo: StartInfo): void => { - for (const field of getKeys(fullPackConfig)) { + for (const field of new Set([...getKeys(fullPackConfig), ...getKeys(startInfo.fullPackConfig)])) { if (skippedFields.includes(field)) { continue; } @@ -26,4 +26,9 @@ export const updateConfig = (fullPackConfig: FullPackConfig, startInfo: StartInf // @ts-expect-error: full pack config have different types of field values fullPackConfig[field] = startInfo.fullPackConfig[field]; // eslint-disable-line no-param-reassign } + + Object.assign(fullPackConfig, { + ...fullPackConfig.overriddenConfigFields, + use: {...fullPackConfig.use, ...fullPackConfig.overriddenConfigFields?.use}, + }); }; diff --git a/src/utils/error/getStackTrace.ts b/src/utils/error/getStackTrace.ts index 45b1d5d0..c2068731 100644 --- a/src/utils/error/getStackTrace.ts +++ b/src/utils/error/getStackTrace.ts @@ -9,6 +9,7 @@ const getStackTraceBody = function getStackTrace(): readonly StackFrame[] | unde Error.stackTraceLimit = 5000; + // eslint-disable-next-line @typescript-eslint/unbound-method const originalPrepareStackTrace = Error.prepareStackTrace; // eslint-disable-next-line no-restricted-syntax diff --git a/src/utils/generalLog/logFile.ts b/src/utils/generalLog/logFile.ts index 228e9506..0ef83aa6 100644 --- a/src/utils/generalLog/logFile.ts +++ b/src/utils/generalLog/logFile.ts @@ -11,19 +11,11 @@ import {getFullPackConfig} from '../config'; */ const logs: string[] = []; -/** - * Adds log message to pack logs (for later saving to the pack logs file). - * @internal - */ -export const addLogToLogFile = (logMessage: string): void => { - logs.push(logMessage); -}; - /** * Writes pack logs to logs file (if the pack config requires it and there are unwritten logs). * @internal */ -export const writeLogsToFile = async (): Promise => { +const writeLogsToFile = async (): Promise => { if (logs.length === 0) { return; } @@ -41,3 +33,24 @@ export const writeLogsToFile = async (): Promise => { await appendFile(logsFilePath, logsText); }; + +const baseWritingInternal = 4_000; +const deltaWritingInterval = 4_000; + +setInterval( + () => { + void writeLogsToFile(); + }, + baseWritingInternal + Math.random() * deltaWritingInterval, +); + +/** + * Adds log message to pack logs (for later saving to the pack logs file). + * @internal + */ +export const addLogToLogFile = (logMessage: string): void => { + logs.push(logMessage); +}; + +/** @internal */ +export {writeLogsToFile}; diff --git a/src/utils/getGlobalErrorHandler.ts b/src/utils/getGlobalErrorHandler.ts index f08a1ecd..fed60f21 100644 --- a/src/utils/getGlobalErrorHandler.ts +++ b/src/utils/getGlobalErrorHandler.ts @@ -1,9 +1,6 @@ -import {getMeta} from '../context/meta'; -import {getRunId} from '../context/runId'; - import {E2edError} from './error'; import {writeGlobalError} from './fs'; -import {generalLog} from './generalLog'; +import {writeLogsToFile} from './generalLog'; import type {GlobalErrorType} from '../types/internal'; @@ -14,20 +11,8 @@ import type {GlobalErrorType} from '../types/internal'; export const getGlobalErrorHandler = (type: GlobalErrorType) => (cause: unknown): void => { - const message = `Caught ${type}`; - - if (type.startsWith('Test')) { - const meta = getMeta(); - const runId = getRunId(); - - generalLog(message, {cause, meta, runId}); - - return; - } - - generalLog(message, {cause}); - - const error = new E2edError(message, {cause}); + const error = new E2edError(`Caught ${type}`, {cause}); - void writeGlobalError(error.toString()); + void writeGlobalError(error.toString()).catch(() => {}); + void writeLogsToFile().catch(() => {}); }; diff --git a/src/utils/playwrightPage/index.ts b/src/utils/playwrightPage/index.ts new file mode 100644 index 00000000..9e31947d --- /dev/null +++ b/src/utils/playwrightPage/index.ts @@ -0,0 +1,6 @@ +/** @internal */ +export {switchPlaywrightPage} from './switchPlaywrightPage'; +/** @internal */ +export {pageWaitForRequest} from './waitForRequest'; +/** @internal */ +export {pageWaitForResponse} from './waitForResponse'; diff --git a/src/utils/playwrightPage/switchPlaywrightPage.ts b/src/utils/playwrightPage/switchPlaywrightPage.ts new file mode 100644 index 00000000..8faaa2a6 --- /dev/null +++ b/src/utils/playwrightPage/switchPlaywrightPage.ts @@ -0,0 +1,41 @@ +import {getClearPage} from '../../context/clearPage'; + +import {preparePage} from '../test'; + +import {pageWaitForRequest, waitForRequestCalls} from './waitForRequest'; +import {pageWaitForResponse, waitForResponseCalls} from './waitForResponse'; + +import type {Page} from '@playwright/test'; + +/** + * Switches internal Playwright page from old to new. + * Removes all event handlers from the old page and moves them to the new one. + * @internal + */ +export const switchPlaywrightPage = async (newPage: Page): Promise => { + const clearPage = getClearPage(); + + await clearPage?.(); + + await preparePage(newPage); + + const oldRequestCalls = [...waitForRequestCalls]; + + waitForRequestCalls.length = 0; + + for (const {disablePredicate, options, predicate, reject, resolve} of oldRequestCalls) { + disablePredicate(); + + pageWaitForRequest(newPage, predicate, options).then(resolve, reject); + } + + const oldResponseCalls = [...waitForResponseCalls]; + + waitForResponseCalls.length = 0; + + for (const {disablePredicate, options, predicate, reject, resolve} of oldResponseCalls) { + disablePredicate(); + + pageWaitForResponse(newPage, predicate, options).then(resolve, reject); + } +}; diff --git a/src/utils/playwrightPage/waitForRequest.ts b/src/utils/playwrightPage/waitForRequest.ts new file mode 100644 index 00000000..3d70ee8f --- /dev/null +++ b/src/utils/playwrightPage/waitForRequest.ts @@ -0,0 +1,62 @@ +import {TEST_ENDED_ERROR_MESSAGE} from '../../constants/internal'; + +import type {Page, Request} from '@playwright/test'; + +import type {MaybePromise} from '../../types/internal'; + +type Call = Readonly<{ + disablePredicate: () => void; + options: Options; + predicate: Predicate; + reject: (error: unknown) => void; + resolve: (request: Request) => void; +}>; +type Options = Readonly<{timeout?: number}>; +type Predicate = (request: Request) => MaybePromise; + +/** + * `pageWaitForRequest` calls. + * @internal + */ +export const waitForRequestCalls: Call[] = []; + +/** + * `page.waitForRequest` wrapper to support tab switching. + * @internal + */ +export const pageWaitForRequest = ( + page: Page, + predicate: Predicate, + options: Options, +): Promise => { + let isDisabled = false; + + const disablePredicate = (): void => { + isDisabled = true; + }; + + const disableablePredicate: Predicate = (request) => { + if (isDisabled) { + return true; + } + + return predicate(request); + }; + + return new Promise((resolve, reject) => { + waitForRequestCalls.push({disablePredicate, options, predicate, reject, resolve}); + + page.waitForRequest(disableablePredicate, options).then( + (request) => { + if (!isDisabled) { + resolve(request); + } + }, + (error) => { + if (!isDisabled && !String(error).includes(TEST_ENDED_ERROR_MESSAGE)) { + reject(error); + } + }, + ); + }); +}; diff --git a/src/utils/playwrightPage/waitForResponse.ts b/src/utils/playwrightPage/waitForResponse.ts new file mode 100644 index 00000000..f1144183 --- /dev/null +++ b/src/utils/playwrightPage/waitForResponse.ts @@ -0,0 +1,62 @@ +import {TEST_ENDED_ERROR_MESSAGE} from '../../constants/internal'; + +import type {Page, Response} from '@playwright/test'; + +import type {MaybePromise} from '../../types/internal'; + +type Call = Readonly<{ + disablePredicate: () => void; + options: Options; + predicate: Predicate; + reject: (error: unknown) => void; + resolve: (response: Response) => void; +}>; +type Options = Readonly<{timeout?: number}>; +type Predicate = (response: Response) => MaybePromise; + +/** + * `pageWaitForResponse` calls. + * @internal + */ +export const waitForResponseCalls: Call[] = []; + +/** + * `page.waitForResponse` wrapper to support tab switching. + * @internal + */ +export const pageWaitForResponse = ( + page: Page, + predicate: Predicate, + options: Options, +): Promise => { + let isDisabled = false; + + const disablePredicate = (): void => { + isDisabled = true; + }; + + const disableablePredicate: Predicate = (response) => { + if (isDisabled) { + return true; + } + + return predicate(response); + }; + + return new Promise((resolve, reject) => { + waitForResponseCalls.push({disablePredicate, options, predicate, reject, resolve}); + + page.waitForResponse(disableablePredicate, options).then( + (response) => { + if (!isDisabled) { + resolve(response); + } + }, + (error) => { + if (!isDisabled && !String(error).includes(TEST_ENDED_ERROR_MESSAGE)) { + reject(error); + } + }, + ); + }); +}; diff --git a/src/utils/report/client/chooseTestRun.ts b/src/utils/report/client/chooseTestRun.ts index 8e7324cb..79b90f52 100644 --- a/src/utils/report/client/chooseTestRun.ts +++ b/src/utils/report/client/chooseTestRun.ts @@ -25,6 +25,16 @@ declare const reportClientState: ReportClientState; // eslint-disable-next-line max-statements export function chooseTestRun(runHash: RunHash): void { const {e2edRightColumnContainer} = reportClientState; + + if (e2edRightColumnContainer === undefined) { + // eslint-disable-next-line no-console + console.error( + 'Cannot find right column container (id="e2edRightColumnContainer"). Probably page not yet completely loaded. Please try click again later', + ); + + return; + } + const previousHash = window.location.hash as RunHash; window.location.hash = runHash; diff --git a/src/utils/report/client/initialScript.ts b/src/utils/report/client/initialScript.ts index dce15933..63a47cab 100644 --- a/src/utils/report/client/initialScript.ts +++ b/src/utils/report/client/initialScript.ts @@ -5,7 +5,6 @@ import { import {addDomContentLoadedHandler as clientAddDomContentLoadedHandler} from './addDomContentLoadedHandler'; import {addOnClickOnClass as clientAddOnClickOnClass} from './addOnClickOnClass'; -import {assertValueIsDefined as clientAssertValueIsDefined} from './assertValueIsDefined'; import {clickOnRetry as clientClickOnRetry} from './clickOnRetry'; import {clickOnStep as clientClickOnStep} from './clickOnStep'; import {clickOnTestRun as clientClickOnTestRun} from './clickOnTestRun'; @@ -22,7 +21,6 @@ declare const reportClientState: ReportClientState; const addDomContentLoadedHandler = clientAddDomContentLoadedHandler; const addOnClickOnClass = clientAddOnClickOnClass; -const assertValueIsDefined: typeof clientAssertValueIsDefined = clientAssertValueIsDefined; const clickOnRetry = clientClickOnRetry; const clickOnStep = clientClickOnStep; const clickOnTestRun = clientClickOnTestRun; @@ -40,18 +38,11 @@ const setReadJsonReportDataObservers = clientSetReadJsonReportDataObservers; export function initialScript(): void { jsx = createJsxRuntime(); - const e2edRightColumnContainer = document.getElementById('e2edRightColumnContainer') ?? undefined; - - assertValueIsDefined(e2edRightColumnContainer); - const {locator: locatorAttributes} = createSimpleLocator(reportClientState.createLocatorOptions); const locator: LocatorFunction = (...args) => renderAttributes(locatorAttributes(...(args as [string]))); - Object.assign>(reportClientState, { - e2edRightColumnContainer, - locator, - }); + Object.assign>(reportClientState, {locator}); addOnClickOnClass('nav-tabs__button', clickOnRetry); addOnClickOnClass('step-expanded', clickOnStep); diff --git a/src/utils/report/client/onDomContentLoad.ts b/src/utils/report/client/onDomContentLoad.ts index 48cff803..ae1521fa 100644 --- a/src/utils/report/client/onDomContentLoad.ts +++ b/src/utils/report/client/onDomContentLoad.ts @@ -12,6 +12,19 @@ const readJsonReportData = clientReadJsonReportData; * @internal */ export function onDomContentLoad(): void { + const e2edRightColumnContainer = document.getElementById('e2edRightColumnContainer') ?? undefined; + + if (e2edRightColumnContainer === undefined) { + // eslint-disable-next-line no-console + console.error( + 'Cannot find right column container (id="e2edRightColumnContainer") after DOMContentLoaded.', + ); + } else { + Object.assign>(reportClientState, { + e2edRightColumnContainer, + }); + } + readJsonReportData(true); for (const observer of reportClientState.readJsonReportDataObservers) { diff --git a/src/utils/report/client/onFirstJsonReportDataLoad.ts b/src/utils/report/client/onFirstJsonReportDataLoad.ts index d0e5da2a..9a11790b 100644 --- a/src/utils/report/client/onFirstJsonReportDataLoad.ts +++ b/src/utils/report/client/onFirstJsonReportDataLoad.ts @@ -27,14 +27,15 @@ export function onFirstJsonReportDataLoad(): void { clickOnTestRun(buttonForFailedTestRun as HTMLElement); const buttonOfOpenStep = document.querySelector('.step-expanded[aria-expanded="true"]'); - const {e2edRightColumnContainer} = reportClientState; const scrollDelayInMs = 8; if (buttonOfOpenStep) { const {top} = buttonOfOpenStep.getBoundingClientRect(); setTimeout(() => { - e2edRightColumnContainer.scrollTop = top; + if (reportClientState.e2edRightColumnContainer !== undefined) { + reportClientState.e2edRightColumnContainer.scrollTop = top; + } }, scrollDelayInMs); } } diff --git a/src/utils/test/afterTest.ts b/src/utils/test/afterTest.ts index 065d13ce..da847fa6 100644 --- a/src/utils/test/afterTest.ts +++ b/src/utils/test/afterTest.ts @@ -1,21 +1,23 @@ +import {getClearPage} from '../../context/clearPage'; + import {registerEndTestRunEvent} from '../events'; import {generalLog, writeLogsToFile} from '../generalLog'; import type {EndTestRunEvent, UtcTimeInMs} from '../../types/internal'; -type Options = Readonly<{clearPage: (() => Promise) | undefined}> & - Omit; +type Options = Omit; /** * Internal after test hook. * @internal */ export const afterTest = async (options: Options): Promise => { - const {clearPage, ...optionsWithoutClearPage} = options; const utcTimeInMs = Date.now() as UtcTimeInMs; - const endTestRunEvent: EndTestRunEvent = {...optionsWithoutClearPage, utcTimeInMs}; + const endTestRunEvent: EndTestRunEvent = {...options, utcTimeInMs}; try { + const clearPage = getClearPage(); + await clearPage?.(); await registerEndTestRunEvent(endTestRunEvent); diff --git a/src/utils/test/beforeTest.ts b/src/utils/test/beforeTest.ts index 7db78439..3d94f87b 100644 --- a/src/utils/test/beforeTest.ts +++ b/src/utils/test/beforeTest.ts @@ -12,6 +12,7 @@ import {addResponseToApiStatistics} from '../apiStatistics'; import {getFullPackConfig} from '../config'; import {getRunLabel} from '../environment'; import {registerStartTestRunEvent} from '../events'; +import {writeLogEventTime} from '../fs'; import {mapBackendResponseForLogs} from '../log'; import {isUiMode} from '../uiMode'; import {getUserlandHooks} from '../userland'; @@ -97,4 +98,6 @@ export const beforeTest = ({ }; registerStartTestRunEvent(testRunEvent); + + void writeLogEventTime().catch(() => {}); }; diff --git a/src/utils/test/getRunTest.ts b/src/utils/test/getRunTest.ts index 81f7c0b6..2a819f54 100644 --- a/src/utils/test/getRunTest.ts +++ b/src/utils/test/getRunTest.ts @@ -27,7 +27,6 @@ export const getRunTest = const retryIndex = testInfo.retry + 1; const runId = createRunId(test, retryIndex); - let clearPage: (() => Promise) | undefined; let hasRunError = false; let shouldRunTest = false; let testStaticOptions: TestStaticOptions | undefined; @@ -52,7 +51,7 @@ export const getRunTest = testStaticOptions, }; - clearPage = await preparePage(page); + await preparePage(page); beforeTest(testUnit); @@ -68,7 +67,7 @@ export const getRunTest = throw error; } finally { if (shouldRunTest) { - await afterTest({clearPage, hasRunError, runId, unknownRunError}); + await afterTest({hasRunError, runId, unknownRunError}); } } }; diff --git a/src/utils/test/index.ts b/src/utils/test/index.ts index ef9593b4..c4bb4d64 100644 --- a/src/utils/test/index.ts +++ b/src/utils/test/index.ts @@ -1,2 +1,4 @@ /** @internal */ export {getRunTest} from './getRunTest'; +/** @internal */ +export {preparePage} from './preparePage'; diff --git a/src/utils/test/preparePage.ts b/src/utils/test/preparePage.ts index d37f324e..15ecf291 100644 --- a/src/utils/test/preparePage.ts +++ b/src/utils/test/preparePage.ts @@ -1,5 +1,6 @@ import {AsyncLocalStorage} from 'node:async_hooks'; +import {setClearPage} from '../../context/clearPage'; import {getConsoleMessagesFromContext} from '../../context/consoleMessages'; import {setIsPageNavigatingNow} from '../../context/isPageNavigatingNow'; import {getJsErrorsFromContext} from '../../context/jsError'; @@ -29,7 +30,7 @@ const afterNavigationRequestsDelayInMs = 300; * Prepares page before test. * @internal */ -export const preparePage = async (page: Page): Promise<() => Promise> => { +export const preparePage = async (page: Page): Promise => { const consoleMessages = getConsoleMessagesFromContext() as ConsoleMessage[]; const jsErrors = getJsErrorsFromContext() as JsError[]; const navigationDelay = getNavigationDelay(); @@ -133,7 +134,7 @@ export const preparePage = async (page: Page): Promise<() => Promise> => { page.on('response', responseListener); page.on('requestfinished', requestfinishedListener); - return async () => { + const clearPage = async (): Promise => { page.removeListener('console', consoleListener); page.removeListener('pageerror', pageerrorListener); page.removeListener('request', requestListener); @@ -142,4 +143,6 @@ export const preparePage = async (page: Page): Promise<() => Promise> => { await page.unrouteAll({behavior: 'ignoreErrors'}).catch(() => {}); }; + + setClearPage(clearPage); }; diff --git a/src/utils/testFilePaths/collectTestFilePaths.ts b/src/utils/testFilePaths/collectTestFilePaths.ts index bfbc5eff..fe9c0281 100644 --- a/src/utils/testFilePaths/collectTestFilePaths.ts +++ b/src/utils/testFilePaths/collectTestFilePaths.ts @@ -1,7 +1,6 @@ +import {glob} from 'node:fs/promises'; import {normalize} from 'node:path'; -import globby from 'globby'; - import {TESTS_DIRECTORY_PATH} from '../../constants/internal'; import {getFullPackConfig} from '../config'; @@ -15,8 +14,12 @@ import type {TestFilePath} from '../../types/internal'; */ export const collectTestFilePaths = async (): Promise => { const {testFileGlobs} = getFullPackConfig(); + const rawTestFilesPaths: string[] = []; + + for await (const directory of glob(testFileGlobs as string[])) { + rawTestFilesPaths.push(directory); + } - const rawTestFilesPaths = await globby(testFileGlobs); const testFilesPaths = rawTestFilesPaths .map(normalize as (path: string) => TestFilePath) .filter( diff --git a/src/utils/uiMode/fixSourceCode.ts b/src/utils/uiMode/fixSourceCode.ts index 058c6e68..3f27815e 100644 --- a/src/utils/uiMode/fixSourceCode.ts +++ b/src/utils/uiMode/fixSourceCode.ts @@ -78,6 +78,7 @@ if (isUiMode) { // eslint-disable-next-line @typescript-eslint/unbound-method Error.captureStackTrace = OriginalError.captureStackTrace; + // eslint-disable-next-line @typescript-eslint/unbound-method Error.prepareStackTrace = OriginalError.prepareStackTrace; Error.stackTraceLimit = OriginalError.stackTraceLimit; Error.toString = () => originalErrorString; diff --git a/src/utils/waitForEvents/getWaitForResponsePredicate.ts b/src/utils/waitForEvents/getWaitForResponsePredicate.ts index f54c4d94..00e4c6e0 100644 --- a/src/utils/waitForEvents/getWaitForResponsePredicate.ts +++ b/src/utils/waitForEvents/getWaitForResponsePredicate.ts @@ -2,7 +2,6 @@ import {getIsPageNavigatingNow} from '../../context/isPageNavigatingNow'; import {getNavigationDelay} from '../../context/navigationDelay'; import {getFullPackConfig} from '../config'; -import {E2edError} from '../error'; import {setReadonlyProperty} from '../object'; import {getPromiseWithResolveAndReject, getTimeoutPromise} from '../promise'; import {getResponseFromPlaywrightResponse} from '../requestHooks'; @@ -21,7 +20,6 @@ const navigationDelayAfterLastEventInMs = 300; export const getWaitForResponsePredicate = ( predicate: ResponsePredicate, includeNavigationRequest: boolean, - rejectTimeout: number, ): ((playwrightResponse: PlaywrightResponse) => Promise) => { const {testIdleTimeout} = getFullPackConfig(); const navigationDelay = getNavigationDelay(); @@ -74,12 +72,6 @@ export const getWaitForResponsePredicate = ( return false; } - try { - const result = await predicate(response); - - return result; - } catch (cause) { - throw new E2edError('waitForResponse predicate threw an exception', {cause, rejectTimeout}); - } + return predicate(response); }; };