diff --git a/README.md b/README.md index ce19018e..4469ad32 100644 --- a/README.md +++ b/README.md @@ -196,42 +196,44 @@ The `npx e2ed-init` command is already creating such a file as an example. For debugging tests, the local run of the pack is intended. -For example, you can use the `debug` action from `e2ed/actions` in the test code: +For example, you can use the `pause` action from `e2ed/actions` in the test code: ```ts -await debug(); +await pause(); ``` -When calling the `debug` action, `e2ed` will stop the test execution and enter +When calling the `pause` action, `e2ed` will stop the test execution and enter the debug-mode. -After debugging is complete, remember to remove the call of the `debug` action. +After debugging is complete, remember to remove the call of the `pause` action. -In addition, when run pack locally tests can be debugged using the usual `nodejs` debugging flags -(`--inspect-brk`, `--inspect`), as `nodejs` application (for brevity, we omit the setting of -environment variables before commands): +[pause](https://playwright.dev/docs/api/class-page#page-pause) is a function of the `Playwright` itself. + +In addition, you can set any non-empty value to the `E2ED_DEBUG` environment variable, +which will also run `e2ed` in debug mode. If this variable has the form `inspect-brk:9229`, +then additionally `nodejs` debugging is started on port `9229` (via `--inspect-brk=9229`): ```sh -npm run e2ed:allTests ./autotests/tests/main/exists.ts -- --inspect-brk +E2ED_DEBUG=true npm run e2ed:allTests ./autotests/tests/main/exists.ts ``` -You can use the `debugger` instruction to stop execution at the desired line. - -Or you can set any non-empty value to the `E2ED_DEBUG` environment variable, -which will also run `e2ed` in `nodejs` debug mode -(this is equivalentto passing the `--inspect-brk` flag): - ```sh -E2ED_DEBUG=true npm run e2ed:allTests ./autotests/tests/main/exists.ts +E2ED_DEBUG=inspect-brk:8230 npm run e2ed:allTests ./autotests/tests/main/exists.ts ``` `E2ED_DEBUG` also works for run in docker, and allows you to connect a debugger to the `e2ed` running in docker container. +When developing a test, you can run it in [UI-mode](https://playwright.dev/docs/test-ui-mode) +by passing the `--ui` parameter (for local run only): + ```sh -npm run e2ed:allTests ./autotests/tests/main/exists.ts -- --debug-on-fail +npm run e2ed:allTests ./autotests/tests/main/exists.ts -- --ui ``` +As a result, `Playwright` will launch its [UI-mode](https://playwright.dev/docs/test-ui-mode), +allowing you to quickly rerun tests and see errors. + ### Basic fields of pack config The [pack](autotests/packs/allTests.ts) is a single `ts` file @@ -334,7 +336,7 @@ For example, if it is equal to three, the test will be run no more than three ti (`navigateToPage`, `navigateToUrl` actions) in milliseconds. `overriddenConfigFields: PlaywrightTestConfig | null`: if not `null`, then this value will override -fields of internal Playwright config. +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. @@ -419,9 +421,8 @@ You can pass the following optional environment variables to the `e2ed` process `E2ED_ORIGIN`: origin-part of the url (`protocol` + `host`) on which the tests will be run. For example, `https://bing.com`. -`E2ED_DEBUG`: run `e2ed` in `nodejs` debug mode (`--inspect-brk=0.0.0.0`) if this variable is not empty. - -`E2ED_DOCKER_DEBUG_PORT`: debug port when run in docker (`9229` by default). +`E2ED_DEBUG`: run `e2ed` in debug mode if this variable is not empty. If this variable has the form `inspect-brk:9229`, +then `nodejs` debugging is started on port `9229` (via `--inspect-brk=9229`). `E2ED_TERMINATION_SIGNAL`: the termination signal received by the `e2ed` process (if any). Typically this value is `'SIGUSR1'`. diff --git a/autotests/bin/runDocker.sh b/autotests/bin/runDocker.sh index 0a21209c..ce03ae51 100755 --- a/autotests/bin/runDocker.sh +++ b/autotests/bin/runDocker.sh @@ -3,11 +3,11 @@ set -eo pipefail set +u CONTAINER_LABEL="e2ed" -DEBUG_PORT="${E2ED_DOCKER_DEBUG_PORT:-9229}" +DEBUG_PORT=$([[ $E2ED_DEBUG == inspect-brk:* ]] && echo "${E2ED_DEBUG#inspect-brk:}" || echo "") DIR="${E2ED_WORKDIR:-$PWD}" E2ED_TIMEOUT_FOR_GRACEFUL_SHUTDOWN_IN_SECONDS=16 MOUNTDIR="${E2ED_MOUNTDIR:-$DIR}" -WITH_DEBUG=$([[ -z $E2ED_DEBUG ]] && echo "" || echo "--env E2ED_DEBUG=$DEBUG_PORT --publish $DEBUG_PORT:$DEBUG_PORT --publish $((DEBUG_PORT + 1)):$((DEBUG_PORT + 1))") +WITH_DEBUG=$([[ -z $DEBUG_PORT ]] && echo "" || echo "--publish $DEBUG_PORT:$DEBUG_PORT --publish $((DEBUG_PORT + 1)):$((DEBUG_PORT + 1))") VERSION=$(grep -m1 \"e2ed\": $DIR/package.json | cut -d '"' -f 4) source ./autotests/variables.env @@ -47,6 +47,7 @@ echo "Run docker image $E2ED_DOCKER_IMAGE:$VERSION" trap "onExit" EXIT docker run \ + --env E2ED_DEBUG=$E2ED_DEBUG \ --env E2ED_ORIGIN=$E2ED_ORIGIN \ --env E2ED_TIMEOUT_FOR_GRACEFUL_SHUTDOWN_IN_SECONDS=$E2ED_TIMEOUT_FOR_GRACEFUL_SHUTDOWN_IN_SECONDS \ --env __INTERNAL_E2ED_PATH_TO_PACK=$1 \ diff --git a/autotests/tests/internalTypeTests/expect.skip.ts b/autotests/tests/internalTypeTests/expect.skip.ts index 842f5c25..e3a95125 100644 --- a/autotests/tests/internalTypeTests/expect.skip.ts +++ b/autotests/tests/internalTypeTests/expect.skip.ts @@ -28,3 +28,9 @@ void expect(htmlElementSelector.textContent, '').toMatchScreenshot('some id'); // @ts-expect-error: eql is acceptable only for non-selectors void expect(htmlElementSelector, '').eql(htmlElementSelector); + +// ok +void (expect('foo', 'foo is correct').toBe('foo') satisfies Promise); + +// ok +void (expect('foo', 'foo is correct').toBeDefined() satisfies Promise); diff --git a/bin/dockerEntrypoint.sh b/bin/dockerEntrypoint.sh index 083a480a..4758ef2d 100755 --- a/bin/dockerEntrypoint.sh +++ b/bin/dockerEntrypoint.sh @@ -18,11 +18,13 @@ onExit() { trap "onExit" EXIT -if [[ -z $E2ED_DEBUG ]] +DEBUG_PORT=$([[ $E2ED_DEBUG == inspect-brk:* ]] && echo "${E2ED_DEBUG#inspect-brk:}" || echo "") + +if [[ -z $DEBUG_PORT ]] then /node_modules/e2ed/bin/runE2edInDockerEnvironment.js & PID=$! else - node --inspect-brk=0.0.0.0:$E2ED_DEBUG /node_modules/e2ed/bin/runE2edInDockerEnvironment.js & PID=$! + node --inspect-brk=0.0.0.0:$DEBUG_PORT /node_modules/e2ed/bin/runE2edInDockerEnvironment.js & PID=$! fi wait $PID diff --git a/package-lock.json b/package-lock.json index e37f23b5..78059637 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.20.7", "license": "MIT", "dependencies": { - "@playwright/test": "1.51.0", + "@playwright/test": "1.51.1", "create-locator": "0.0.27", "get-modules-graph": "0.0.11", "globby": "11.1.0", @@ -21,8 +21,8 @@ "e2ed-install-browsers": "bin/installBrowsers.js" }, "devDependencies": { - "@playwright/browser-chromium": "1.51.0", - "@types/node": "22.13.10", + "@playwright/browser-chromium": "1.51.1", + "@types/node": "22.14.0", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "assert-modules-support-case-insensitive-fs": "1.0.1", @@ -35,7 +35,7 @@ "eslint-plugin-typescript-sort-keys": "3.3.0", "husky": "9.1.7", "prettier": "3.5.3", - "typescript": "5.8.2" + "typescript": "5.8.3" }, "engines": { "node": ">=20.16.0" @@ -181,26 +181,26 @@ } }, "node_modules/@playwright/browser-chromium": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.51.0.tgz", - "integrity": "sha512-ANaU19rQbK+lmmXhvBQQIZ6VHjtMxX99NBPeVBI5G7W/CI35UtWIlnmc+DEknbJd0fBWOhtWAzIBhFpDVgtWpA==", + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.51.1.tgz", + "integrity": "sha512-Xebxk0SrDKttd8VGiUwLxOMbuH/Lf/+vFyzFG7QHVvqsAOw3Ec7Xdl1HRB4dnVP/RTEytkH4OgQ4OFy6K2c1xw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.51.0" + "playwright-core": "1.51.1" }, "engines": { "node": ">=18" } }, "node_modules/@playwright/test": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.0.tgz", - "integrity": "sha512-dJ0dMbZeHhI+wb77+ljx/FeC8VBP6j/rj9OAojO08JI80wTZy6vRk9KvHKiDCUh4iMpEiseMgqRBIeW+eKX6RA==", + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.51.0" + "playwright": "1.51.1" }, "bin": { "playwright": "cli.js" @@ -229,13 +229,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.13.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", - "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/semver": { @@ -2736,12 +2736,12 @@ } }, "node_modules/playwright": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz", - "integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==", + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.51.0" + "playwright-core": "1.51.1" }, "bin": { "playwright": "cli.js" @@ -2754,9 +2754,9 @@ } }, "node_modules/playwright-core": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz", - "integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==", + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -3321,9 +3321,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3350,9 +3350,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index fc9f3dc3..3c365f0b 100644 --- a/package.json +++ b/package.json @@ -25,15 +25,15 @@ "url": "git+https://github.com/joomcode/e2ed.git" }, "dependencies": { - "@playwright/test": "1.51.0", + "@playwright/test": "1.51.1", "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.0", - "@types/node": "22.13.10", + "@playwright/browser-chromium": "1.51.1", + "@types/node": "22.14.0", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "assert-modules-support-case-insensitive-fs": "1.0.1", @@ -46,7 +46,7 @@ "eslint-plugin-typescript-sort-keys": "3.3.0", "husky": "9.1.7", "prettier": "3.5.3", - "typescript": "5.8.2" + "typescript": "5.8.3" }, "peerDependencies": { "@types/node": ">=20", diff --git a/src/README.md b/src/README.md index 0ce12e40..8a7cb959 100644 --- a/src/README.md +++ b/src/README.md @@ -39,16 +39,17 @@ Modules in the dependency graph should only import the modules above them: 32. `utils/promise` 33. `utils/resourceUsage` 34. `utils/fs` -35. `utils/tests` -36. `utils/end` -37. `utils/pack` -38. `useContext` -39. `context` -40. `utils/apiStatistics` -41. `utils/selectors` -42. `selectors` -43. `utils/log` -44. `utils/waitForEvents` -45. `utils/expect` -46. `expect` -47. ... +35. `utils/getGlobalErrorHandler` +36. `utils/tests` +37. `utils/end` +38. `utils/pack` +39. `useContext` +40. `context` +41. `utils/apiStatistics` +42. `utils/selectors` +43. `selectors` +44. `utils/log` +45. `utils/waitForEvents` +46. `utils/expect` +47. `expect` +48. ... diff --git a/src/actions/waitFor/waitForNewTab.ts b/src/actions/waitFor/waitForNewTab.ts index 15d1116d..e8c81cc3 100644 --- a/src/actions/waitFor/waitForNewTab.ts +++ b/src/actions/waitFor/waitForNewTab.ts @@ -9,21 +9,22 @@ import type {InternalTab, Tab, Trigger, UtcTimeInMs} from '../../types/internal' type Options = Readonly<{skipLogs?: boolean; timeout?: number}>; -type WaitForNewTab = ((trigger: Trigger, options?: Options) => Promise) & +type WaitForNewTab = ((trigger: Trigger | undefined, options?: Options) => Promise) & ((options?: Options) => Promise); /** * Waits for opening of new tab and returns this tab. */ export const waitForNewTab = (async ( - triggerOrOptions?: Options | Trigger, + triggerOrOptions?: Options | Trigger | undefined, options?: Options, ): Promise => { const startTimeInMs = Date.now() as UtcTimeInMs; const context = getPlaywrightPage().context(); const trigger = typeof triggerOrOptions === 'function' ? triggerOrOptions : undefined; - const finalOptions = typeof triggerOrOptions === 'function' ? options : triggerOrOptions; + const finalOptions = + typeof triggerOrOptions === 'function' ? options : (triggerOrOptions ?? options); const timeout = finalOptions?.timeout ?? getFullPackConfig().navigationTimeout; const timeoutWithUnits = getDurationWithUnits(timeout); diff --git a/src/actions/waitFor/waitForRequest.ts b/src/actions/waitFor/waitForRequest.ts index f776a0a3..28ae69a4 100644 --- a/src/actions/waitFor/waitForRequest.ts +++ b/src/actions/waitFor/waitForRequest.ts @@ -1,4 +1,5 @@ import {LogEventType} from '../../constants/internal'; +import {getTestRunPromise} from '../../context/testRunPromise'; import {getPlaywrightPage} from '../../useContext'; import {getFullPackConfig} from '../../utils/config'; import {E2edError} from '../../utils/error'; @@ -33,7 +34,7 @@ type Options = Readonly<{skipLogs?: boolean; timeout?: number}>; */ export const waitForRequest = (async ( predicate: RequestPredicate, - triggerOrOptions?: Options | Trigger, + triggerOrOptions?: Options | Trigger | undefined, options?: Options, ): Promise> => { const startTimeInMs = Date.now() as UtcTimeInMs; @@ -41,7 +42,8 @@ export const waitForRequest = (async ( setCustomInspectOnFunction(predicate); const trigger = typeof triggerOrOptions === 'function' ? triggerOrOptions : undefined; - const finalOptions = typeof triggerOrOptions === 'function' ? options : triggerOrOptions; + const finalOptions = + typeof triggerOrOptions === 'function' ? options : (triggerOrOptions ?? options); const timeout = finalOptions?.timeout ?? getFullPackConfig().waitForRequestTimeout; @@ -50,6 +52,13 @@ export const waitForRequest = (async ( } const page = getPlaywrightPage(); + const testRunPromise = getTestRunPromise(); + + let isTestRunCompleted = false; + + void testRunPromise.then(() => { + isTestRunCompleted = true; + }); const promise = page .waitForRequest( @@ -73,7 +82,14 @@ export const waitForRequest = (async ( .then( (playwrightRequest) => getRequestFromPlaywrightRequest(playwrightRequest) as RequestWithUtcTimeInMs, - ); + ) + .catch((error: unknown) => { + if (isTestRunCompleted) { + return new Promise>(() => {}); + } + + throw error; + }); const timeoutWithUnits = getDurationWithUnits(timeout); diff --git a/src/actions/waitFor/waitForRequestToRoute.ts b/src/actions/waitFor/waitForRequestToRoute.ts index 965c93c8..30e87963 100644 --- a/src/actions/waitFor/waitForRequestToRoute.ts +++ b/src/actions/waitFor/waitForRequestToRoute.ts @@ -53,13 +53,14 @@ export const waitForRequestToRoute = (async < SomeResponse extends Response, >( Route: ApiRouteClassTypeWithGetParamsFromUrl, - triggerOrOptions?: Options | Trigger, + triggerOrOptions?: Options | Trigger | undefined, options?: Options, ): Return => { const startTimeInMs = Date.now() as UtcTimeInMs; const trigger = typeof triggerOrOptions === 'function' ? triggerOrOptions : undefined; - const finalOptions = typeof triggerOrOptions === 'function' ? options : triggerOrOptions; + const finalOptions = + typeof triggerOrOptions === 'function' ? options : (triggerOrOptions ?? options); const {predicate = () => true} = finalOptions ?? {}; const timeout = finalOptions?.timeout ?? getFullPackConfig().waitForRequestTimeout; diff --git a/src/actions/waitFor/waitForResponse.ts b/src/actions/waitFor/waitForResponse.ts index 78244ac4..d8f97265 100644 --- a/src/actions/waitFor/waitForResponse.ts +++ b/src/actions/waitFor/waitForResponse.ts @@ -1,6 +1,7 @@ import {AsyncLocalStorage} from 'node:async_hooks'; import {LogEventType} from '../../constants/internal'; +import {getTestRunPromise} from '../../context/testRunPromise'; import {getPlaywrightPage} from '../../useContext'; import {getFullPackConfig} from '../../utils/config'; import {setCustomInspectOnFunction} from '../../utils/fn'; @@ -39,7 +40,7 @@ export const waitForResponse = (async < SomeResponse extends Response = Response, >( predicate: ResponsePredicate, - triggerOrOptions?: Options | Trigger, + triggerOrOptions?: Options | Trigger | undefined, options?: Options, ): Promise> => { const startTimeInMs = Date.now() as UtcTimeInMs; @@ -47,7 +48,8 @@ export const waitForResponse = (async < setCustomInspectOnFunction(predicate); const trigger = typeof triggerOrOptions === 'function' ? triggerOrOptions : undefined; - const finalOptions = typeof triggerOrOptions === 'function' ? options : triggerOrOptions; + const finalOptions = + typeof triggerOrOptions === 'function' ? options : (triggerOrOptions ?? options); const timeout = finalOptions?.timeout ?? getFullPackConfig().waitForResponseTimeout; @@ -56,6 +58,13 @@ export const waitForResponse = (async < } const page = getPlaywrightPage(); + const testRunPromise = getTestRunPromise(); + + let isTestRunCompleted = false; + + void testRunPromise.then(() => { + isTestRunCompleted = true; + }); const promise = page .waitForResponse( @@ -73,7 +82,14 @@ export const waitForResponse = (async < getResponseFromPlaywrightResponse(playwrightResponse) as Promise< ResponseWithRequest >, - ); + ) + .catch((error: unknown) => { + if (isTestRunCompleted) { + return new Promise>(() => {}); + } + + throw error; + }); const timeoutWithUnits = getDurationWithUnits(timeout); diff --git a/src/actions/waitFor/waitForResponseToRoute.ts b/src/actions/waitFor/waitForResponseToRoute.ts index d09b1be9..da539204 100644 --- a/src/actions/waitFor/waitForResponseToRoute.ts +++ b/src/actions/waitFor/waitForResponseToRoute.ts @@ -53,13 +53,14 @@ export const waitForResponseToRoute = (async < SomeResponse extends Response, >( Route: ApiRouteClassTypeWithGetParamsFromUrl, - triggerOrOptions?: Options | Trigger, + triggerOrOptions?: Options | Trigger | undefined, options?: Options, ): Return => { const startTimeInMs = Date.now() as UtcTimeInMs; const trigger = typeof triggerOrOptions === 'function' ? triggerOrOptions : undefined; - const finalOptions = typeof triggerOrOptions === 'function' ? options : triggerOrOptions; + const finalOptions = + typeof triggerOrOptions === 'function' ? options : (triggerOrOptions ?? options); const {predicate = () => true} = finalOptions ?? {}; const timeout = finalOptions?.timeout ?? getFullPackConfig().waitForResponseTimeout; diff --git a/src/bin/localEntrypoint.ts b/src/bin/localEntrypoint.ts index acb12a35..0014c751 100644 --- a/src/bin/localEntrypoint.ts +++ b/src/bin/localEntrypoint.ts @@ -1,7 +1,7 @@ import {fork} from 'node:child_process'; import {join} from 'node:path'; -import {INSTALLED_E2ED_DIRECTORY_PATH, isDebug} from '../constants/internal'; +import {DEBUG_PORT, INSTALLED_E2ED_DIRECTORY_PATH} from '../constants/internal'; const entrypoitFilePath = join( INSTALLED_E2ED_DIRECTORY_PATH, @@ -9,7 +9,7 @@ const entrypoitFilePath = join( 'runE2edInLocalEnvironment.js', ); -const execArgv = isDebug ? ['--inspect-brk'] : []; +const execArgv = DEBUG_PORT !== undefined ? [`--inspect-brk=${DEBUG_PORT}`] : []; const e2edProcess = fork(entrypoitFilePath, process.argv.slice(2), {execArgv, stdio: 'inherit'}); diff --git a/src/bin/runE2edInDockerEnvironment.ts b/src/bin/runE2edInDockerEnvironment.ts index 58948a34..f96d511e 100644 --- a/src/bin/runE2edInDockerEnvironment.ts +++ b/src/bin/runE2edInDockerEnvironment.ts @@ -2,8 +2,12 @@ import {RunEnvironment, setRunEnvironment} from '../configurator'; import {setProcessEndHandlers} from '../utils/end'; import {registerEndE2edRunEvent, registerStartE2edRunEvent} from '../utils/events'; import {logStartE2edError} from '../utils/generalLog'; +import {getGlobalErrorHandler} from '../utils/getGlobalErrorHandler'; import {runPackWithRetries} from '../utils/retry'; +process.on('uncaughtException', getGlobalErrorHandler('E2edUncaughtException')); +process.on('unhandledRejection', getGlobalErrorHandler('E2edUnhandledRejection')); + setProcessEndHandlers(); setRunEnvironment(RunEnvironment.Docker); diff --git a/src/bin/runE2edInLocalEnvironment.ts b/src/bin/runE2edInLocalEnvironment.ts index fd31bf7b..e5305563 100644 --- a/src/bin/runE2edInLocalEnvironment.ts +++ b/src/bin/runE2edInLocalEnvironment.ts @@ -4,6 +4,7 @@ import {setProcessEndHandlers} from '../utils/end'; import {setPathToPack} from '../utils/environment'; import {registerEndE2edRunEvent, registerStartE2edRunEvent} from '../utils/events'; import {logStartE2edError} from '../utils/generalLog'; +import {getGlobalErrorHandler} from '../utils/getGlobalErrorHandler'; import {runPackWithArgs} from '../utils/pack'; import {setUiMode} from '../utils/uiMode'; @@ -21,6 +22,9 @@ const [pathToPack] = process.argv.splice(2, 1); assertValueIsDefined(pathToPack, 'pathToPack is defined', {argv: process.argv}); +process.on('uncaughtException', getGlobalErrorHandler('E2edUncaughtException')); +process.on('unhandledRejection', getGlobalErrorHandler('E2edUnhandledRejection')); + setPathToPack(pathToPack as FilePathFromRoot); setProcessEndHandlers(); setRunEnvironment(RunEnvironment.Local); diff --git a/src/bin/runTestsSubprocess.ts b/src/bin/runTestsSubprocess.ts index c41de8ec..35c904fd 100644 --- a/src/bin/runTestsSubprocess.ts +++ b/src/bin/runTestsSubprocess.ts @@ -1,4 +1,5 @@ import {getFullPackConfig} from '../utils/config'; +import {getGlobalErrorHandler} from '../utils/getGlobalErrorHandler'; import {exitFromTestsSubprocess, runTests} from '../utils/tests'; import type {RunRetryOptions} from '../types/internal'; @@ -9,6 +10,9 @@ const testIdleTimeoutObject = setInterval(() => process.send?.(null), testIdleTi testIdleTimeoutObject.unref(); +process.on('uncaughtException', getGlobalErrorHandler('SubprocessUncaughtException')); +process.on('unhandledRejection', getGlobalErrorHandler('SubprocessUnhandledRejection')); + /** * Returns exit code `0`, if all tests passed, and `1` otherwise. */ diff --git a/src/config.ts b/src/config.ts index 4896b79c..bb0ab535 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,7 +12,8 @@ import { e2edEnvironment, EXPECTED_SCREENSHOTS_DIRECTORY_PATH, INTERNAL_REPORTS_DIRECTORY_PATH, - isDebug, + IS_DEBUG, + MAX_TIMEOUT_IN_MS, PATH_TO_TEST_FILE_VARIABLE_NAME, TESTS_DIRECTORY_PATH, } from './constants/internal'; @@ -29,8 +30,6 @@ import type {FullPackConfig, Mutable, UserlandPack} from './types/internal'; import {defineConfig, type PlaywrightTestConfig} from '@playwright/test'; -const maxTimeoutInMs = 3600_000; - const pathToPack = getPathToPack(); const relativePathFromInstalledE2edToRoot = relative( ABSOLUTE_PATH_TO_INSTALLED_E2ED_DIRECTORY, @@ -87,10 +86,10 @@ setCustomInspectOnFunction(mapLogPayloadInConsole); setCustomInspectOnFunction(mapLogPayloadInLogFile); setCustomInspectOnFunction(mapLogPayloadInReport); -if (isDebug || isUiMode) { - setReadonlyProperty(userlandPack, 'packTimeout', maxTimeoutInMs); - setReadonlyProperty(userlandPack, 'testIdleTimeout', maxTimeoutInMs); - setReadonlyProperty(userlandPack, 'testTimeout', maxTimeoutInMs); +if (IS_DEBUG || isUiMode) { + setReadonlyProperty(userlandPack, 'packTimeout', MAX_TIMEOUT_IN_MS); + setReadonlyProperty(userlandPack, 'testIdleTimeout', MAX_TIMEOUT_IN_MS); + setReadonlyProperty(userlandPack, 'testTimeout', MAX_TIMEOUT_IN_MS); } const useOptions: PlaywrightTestConfig['use'] = { diff --git a/src/constants/debug.ts b/src/constants/debug.ts new file mode 100644 index 00000000..a9968d23 --- /dev/null +++ b/src/constants/debug.ts @@ -0,0 +1,6 @@ +/** + * Maximum timeout in milliseconds for debug mode (24 hours). + * In debug mode, all timers are set to this value. + * @internal + */ +export const MAX_TIMEOUT_IN_MS = 86_400_000; diff --git a/src/constants/environment.ts b/src/constants/environment.ts index 90c2c7c7..dfddbb6f 100644 --- a/src/constants/environment.ts +++ b/src/constants/environment.ts @@ -6,10 +6,17 @@ import type {E2edEnvironment} from '../types/internal'; */ export const e2edEnvironment = process.env as E2edEnvironment; +/** + * Debug port (for `--inspect-brk`) or `undefined`. + */ +export const DEBUG_PORT: number | undefined = e2edEnvironment.E2ED_DEBUG?.startsWith('inspect-brk:') + ? Number(e2edEnvironment.E2ED_DEBUG.slice('inspect-brk:'.length)) || undefined + : undefined; + /** * `true` if e2ed run in debug mode, and `false` otherwise. */ -export const isDebug: boolean = Boolean(e2edEnvironment.E2ED_DEBUG); +export const IS_DEBUG: boolean = Boolean(e2edEnvironment.E2ED_DEBUG); /** * Name of e2ed environment variable with path to pack. diff --git a/src/constants/index.ts b/src/constants/index.ts index e5e443ec..1ca71274 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,5 +1,5 @@ export {EndE2edReason, ExitCode} from './end'; -export {isDebug} from './environment'; +export {DEBUG_PORT, IS_DEBUG} from './environment'; export {READ_FILE_OPTIONS} from './fs'; export { BAD_REQUEST_STATUS_CODE, diff --git a/src/constants/internal.ts b/src/constants/internal.ts index aba526f7..12cad0d6 100644 --- a/src/constants/internal.ts +++ b/src/constants/internal.ts @@ -4,8 +4,10 @@ export {attributesOptions} from './attributesOptions'; export {EXEC_FILE_OPTIONS} from './childProcess'; /** @internal */ export {ConsoleBackgroundColor} from './color'; +/** @internal */ +export {MAX_TIMEOUT_IN_MS} from './debug'; export {EndE2edReason, ExitCode} from './end'; -export {isDebug, RunEnvironment} from './environment'; +export {DEBUG_PORT, IS_DEBUG, RunEnvironment} from './environment'; /** @internal */ export { e2edEnvironment, @@ -50,6 +52,7 @@ export { DOT_ENV_PATH, EVENTS_DIRECTORY_PATH, EXPECTED_SCREENSHOTS_DIRECTORY_PATH, + GLOBAL_ERRORS_PATH, INSTALLED_E2ED_DIRECTORY_PATH, INTERNAL_DIRECTORY_NAME, INTERNAL_REPORTS_DIRECTORY_PATH, diff --git a/src/constants/paths.ts b/src/constants/paths.ts index df16715c..68b760d2 100644 --- a/src/constants/paths.ts +++ b/src/constants/paths.ts @@ -122,6 +122,12 @@ export const EXPECTED_SCREENSHOTS_DIRECTORY_PATH = join( 'expectedScreenshots', ) as DirectoryPathFromRoot; +/** + * Relative (from root) path to file with global errors of run. + * @internal + */ +export const GLOBAL_ERRORS_PATH = join(TMP_DIRECTORY_PATH, 'globalErrors.txt') as FilePathFromRoot; + /** * Relative (from root) path to directory with tests screenshots. * @internal diff --git a/src/expect.ts b/src/expect.ts index 342c566e..377bbc75 100644 --- a/src/expect.ts +++ b/src/expect.ts @@ -5,13 +5,14 @@ import type {IsEqual, Selector} from './types/internal'; type ExpectFunction = SelectorExpect & NotSelectorExpect; type NotSelectorExpect = ( + this: void, actual: IsEqual extends true ? 'You should call some property or method on the selector' : Actual | Promise, description: string, ) => NonSelectorMatchers; -type SelectorExpect = (actual: Selector, description: string) => SelectorMatchers; +type SelectorExpect = (this: void, actual: Selector, description: string) => SelectorMatchers; /** * Wraps a value or promised value to assertion for further checks. diff --git a/src/generators/createRunId.ts b/src/generators/createRunId.ts deleted file mode 100644 index c2d86684..00000000 --- a/src/generators/createRunId.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {getHash} from '../utils/getHash'; - -import type {RunId, Test} from '../types/internal'; - -/** - * Creates new RunId for TestRun. - * @internal - */ -export const createRunId = (test: Test, retryIndex: number): RunId => { - const data = {...test, testFn: test.testFn.toString()}; - const text = JSON.stringify(data); - - const base = getHash(text); - - return `${base}-${retryIndex}` as RunId; -}; diff --git a/src/generators/getRandomId.ts b/src/generators/getRandomId.ts index fd10a25b..dab553a7 100644 --- a/src/generators/getRandomId.ts +++ b/src/generators/getRandomId.ts @@ -1,7 +1,7 @@ import {randomUUID} from 'node:crypto'; /** - * Get random id string like "2021-04-21T20:24:19.937Z-30def025-8cb7-4f1e-b38d-2ad76a3b4815". + * Get random id string like `"2021-04-21T20:24:19.937Z-30def025-8cb7-4f1e-b38d-2ad76a3b4815"`. */ -export const getRandomId = (): T => - `${new Date().toISOString()}-${randomUUID()}` as T; +export const getRandomId = (): Type => + `${new Date().toISOString()}-${randomUUID()}` as Type; diff --git a/src/generators/internal.ts b/src/generators/internal.ts index 1708c5ab..0e2ebcf3 100644 --- a/src/generators/internal.ts +++ b/src/generators/internal.ts @@ -1,4 +1,2 @@ -/** @internal */ -export {createRunId} from './createRunId'; export {getRandomId} from './getRandomId'; export {getRandomIntegerInRange} from './getRandomIntegerInRange'; diff --git a/src/test.ts b/src/test.ts index efa6045a..a8beea80 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,4 +1,5 @@ import {getFullPackConfig} from './utils/config'; +import {getGlobalErrorHandler} from './utils/getGlobalErrorHandler'; import {getRunTest} from './utils/test'; import {isUiMode} from './utils/uiMode'; @@ -6,6 +7,11 @@ 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')); + /** * Creates test with name, metatags, options and test function. * @internal diff --git a/src/types/errors.ts b/src/types/errors.ts index 30b7f65b..8551f8ee 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -12,6 +12,13 @@ export type E2edPrintedFields = Readonly<{ stackTrace: readonly string[]; }>; +/** + * Global error type. + * @internal + */ +export type GlobalErrorType = + `${'E2ed' | 'Subprocess' | 'Test'}${'UncaughtException' | 'UnhandledRejection'}`; + /** * JS error from browser. */ diff --git a/src/types/internal.ts b/src/types/internal.ts index 9a0e38e5..950f24a4 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -30,7 +30,7 @@ export type {DeepMutable, DeepPartial, DeepReadonly, DeepRequired} from './deep' export type {E2edEnvironment} from './environment'; export type {E2edPrintedFields, JsError} from './errors'; /** @internal */ -export type {MaybeWithIsTestRunBroken} from './errors'; +export type {GlobalErrorType, MaybeWithIsTestRunBroken} from './errors'; export type {LogEvent, Onlog, TestRunEvent} from './events'; /** @internal */ export type {EndTestRunEvent, FullEventsData} from './events'; diff --git a/src/types/report.ts b/src/types/report.ts index 81bf97b9..e25bffc6 100644 --- a/src/types/report.ts +++ b/src/types/report.ts @@ -96,7 +96,6 @@ export type ReportClientState = { readonly e2edRightColumnContainer: HTMLElement; readonly fullTestRuns: readonly FullTestRun[]; readonly internalDirectoryName: string; - readonly jsxRuntime: JSX.Runtime; lengthOfReadedJsonReportDataParts: number; readonly locator: LocatorFunction; readonly pathToScreenshotsDirectoryForReport: string | null; diff --git a/src/types/utils.ts b/src/types/utils.ts index 7b33f46e..a1c67dcd 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -4,7 +4,7 @@ import type {IsIncludeUndefined} from './undefined'; /** * Entry pair that `Object.entries` returns. */ -type EntryPair = [key: keyof Type, value: Values | undefined]; +type EntryPair = [key: keyof Type, value: Values]; /** * Alias for type any (to suppress the @typescript-eslint/no-explicit-any rule). diff --git a/src/utils/events/registerEndTestRunEvent.ts b/src/utils/events/registerEndTestRunEvent.ts index 2810499f..d983ab8b 100644 --- a/src/utils/events/registerEndTestRunEvent.ts +++ b/src/utils/events/registerEndTestRunEvent.ts @@ -1,5 +1,5 @@ import {isLocalRun} from '../../configurator'; -import {isDebug, TestRunStatus} from '../../constants/internal'; +import {IS_DEBUG, TestRunStatus} from '../../constants/internal'; import {getApiStatistics} from '../../context/apiStatistics'; import {getPlaywrightPage} from '../../useContext'; @@ -86,7 +86,7 @@ export const registerEndTestRunEvent = async (endTestRunEvent: EndTestRunEvent): await writeTestRunToJsonFile(fullTestRun); await writeLogsToFile(); - if (isDebug && isLocalRun) { + if (IS_DEBUG && isLocalRun) { await getPlaywrightPage().pause(); } }; diff --git a/src/utils/expect/types.ts b/src/utils/expect/types.ts index 94cc8b00..42cfa454 100644 --- a/src/utils/expect/types.ts +++ b/src/utils/expect/types.ts @@ -1,6 +1,6 @@ import type {Expect as PlaywrightExpect} from '@playwright/test'; -import type {ToMatchScreenshotOptions} from '../../types/internal'; +import type {Fn, ToMatchScreenshotOptions} from '../../types/internal'; import type {Expect} from './Expect'; @@ -10,6 +10,8 @@ type EnsureString = Type extends string ? string : never; type Extend = Type extends Extended ? Extended : never; +type PlaywrightMatchers = ReturnType; + /** * All assertion functions keys (names of assertion functions, like `eql`, `match`, etc). * @internal @@ -60,8 +62,13 @@ export type NonSelectorAdditionalMatchers = Readonly<{ /** * All matchers. */ -export type NonSelectorMatchers = NonSelectorAdditionalMatchers & - ReturnType; +export type NonSelectorMatchers = NonSelectorAdditionalMatchers & { + readonly [Key in keyof PlaywrightMatchers]: Fn< + PlaywrightMatchers[Key] extends (...args: infer Args) => unknown ? Args : never, + Promise, + void + >; +}; /** * Matchers for selector. diff --git a/src/utils/fs/index.ts b/src/utils/fs/index.ts index 0a70da8f..378abd1e 100644 --- a/src/utils/fs/index.ts +++ b/src/utils/fs/index.ts @@ -13,6 +13,8 @@ export {readEventFromFile} from './readEventFromFile'; /** @internal */ export {readEventsFromFiles} from './readEventsFromFiles'; /** @internal */ +export {readGlobalErrors} from './readGlobalErrors'; +/** @internal */ export {readStartInfo} from './readStartInfo'; /** @internal */ export {removeDirectory} from './removeDirectory'; @@ -20,6 +22,8 @@ export {removeDirectory} from './removeDirectory'; export {writeApiStatistics} from './writeApiStatistics'; export {writeFile} from './writeFile'; /** @internal */ +export {writeGlobalError} from './writeGlobalError'; +/** @internal */ export {writeStartInfo} from './writeStartInfo'; /** @internal */ export {writeTestRunToJsonFile} from './writeTestRunToJsonFile'; diff --git a/src/utils/fs/readGlobalErrors.ts b/src/utils/fs/readGlobalErrors.ts new file mode 100644 index 00000000..7147c514 --- /dev/null +++ b/src/utils/fs/readGlobalErrors.ts @@ -0,0 +1,15 @@ +import {readFile} from 'node:fs/promises'; + +import {GLOBAL_ERRORS_PATH, READ_FILE_OPTIONS} from '../../constants/internal'; + +/** + * Reads global errors of run from directory. + * @internal + */ +export const readGlobalErrors = async (): Promise => { + const globalErrorsJsonString = await readFile(GLOBAL_ERRORS_PATH, READ_FILE_OPTIONS).catch( + () => '', + ); + + return JSON.parse(`[${globalErrorsJsonString.slice(0, -2)}]`) as string[]; +}; diff --git a/src/utils/fs/writeGlobalError.ts b/src/utils/fs/writeGlobalError.ts new file mode 100644 index 00000000..67d112e2 --- /dev/null +++ b/src/utils/fs/writeGlobalError.ts @@ -0,0 +1,13 @@ +import {appendFile} from 'node:fs/promises'; + +import {GLOBAL_ERRORS_PATH} from '../../constants/internal'; + +/** + * Writes single global error of run to common file. + * @internal + */ +export const writeGlobalError = async (globalError: string): Promise => { + const globalErrorJsonString = JSON.stringify(globalError); + + await appendFile(GLOBAL_ERRORS_PATH, `${globalErrorJsonString},\n`); +}; diff --git a/src/utils/getGlobalErrorHandler.ts b/src/utils/getGlobalErrorHandler.ts new file mode 100644 index 00000000..c361c053 --- /dev/null +++ b/src/utils/getGlobalErrorHandler.ts @@ -0,0 +1,21 @@ +import {E2edError} from './error'; +import {writeGlobalError} from './fs'; +import {generalLog} from './generalLog'; + +import type {GlobalErrorType} from '../types/internal'; + +/** + * Get handler for `uncaughtException` and `unhandledRejection` errors. + * @internal + */ +export const getGlobalErrorHandler = + (type: GlobalErrorType) => + (cause: unknown): void => { + const message = `Caught ${type}`; + + generalLog(message, {cause}); + + const error = new E2edError(message, {cause}); + + void writeGlobalError(error.toString()); + }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 750947d1..bc6cca4b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -40,7 +40,7 @@ export {getKeysCounter} from './getKeysCounter'; export {getEquivalentHeadersNames, getHeadersFromHeaderEntries, getHeaderValue} from './headers'; export {getContentJsonHeaders} from './http'; export {log} from './log'; -export {deepMerge, getKeys, setReadonlyProperty} from './object'; +export {deepMerge, getEntries, getKeys, setReadonlyProperty} from './object'; export {parseMaybeEmptyValueAsJson, parseValueAsJsonIfNeeded} from './parse'; export { addTimeoutToPromise, diff --git a/src/utils/object/getEntries.ts b/src/utils/object/getEntries.ts new file mode 100644 index 00000000..076dbb0b --- /dev/null +++ b/src/utils/object/getEntries.ts @@ -0,0 +1,8 @@ +import type {ObjectEntries} from '../../types/internal'; + +/** + * Get typed array of key-value pairs (like `Object.entries`, but typed). + */ +export const getEntries = ( + value: Value, +): readonly ObjectEntries[number][] => Object.entries(value) as ObjectEntries; diff --git a/src/utils/object/index.ts b/src/utils/object/index.ts index 6bff789f..915e6f4e 100644 --- a/src/utils/object/index.ts +++ b/src/utils/object/index.ts @@ -1,3 +1,4 @@ export {deepMerge} from './deepMerge'; +export {getEntries} from './getEntries'; export {getKeys} from './getKeys'; export {setReadonlyProperty} from './setReadonlyProperty'; diff --git a/src/utils/promise/getPromiseWithResolveAndReject.ts b/src/utils/promise/getPromiseWithResolveAndReject.ts index b27245e6..927fdceb 100644 --- a/src/utils/promise/getPromiseWithResolveAndReject.ts +++ b/src/utils/promise/getPromiseWithResolveAndReject.ts @@ -1,4 +1,4 @@ -import {isDebug} from '../../constants/internal'; +import {IS_DEBUG, MAX_TIMEOUT_IN_MS} from '../../constants/internal'; import {assertValueIsDefined} from '../asserts'; import {E2edError} from '../error'; @@ -17,8 +17,6 @@ type Return = Readonly<{ setRejectTimeoutFunction: (rejectTimeoutFunction: () => AsyncVoid) => void; }>; -const maxTimeoutInMs = 3600_000; - /** * Get typed promise with his resolve and reject functions, * and with setted timeout. @@ -61,7 +59,7 @@ export const getPromiseWithResolveAndReject = < generalLog('Reject timeout function rejected with error', {error, rejectTimeoutFunction}); } }) as () => void, - isDebug || isUiMode ? maxTimeoutInMs : timeoutInMs, + IS_DEBUG || isUiMode ? MAX_TIMEOUT_IN_MS : timeoutInMs, ); const clearRejectTimeout = (): void => { diff --git a/src/utils/promise/getTimeoutPromise.ts b/src/utils/promise/getTimeoutPromise.ts index 2e3f8249..fda17c75 100644 --- a/src/utils/promise/getTimeoutPromise.ts +++ b/src/utils/promise/getTimeoutPromise.ts @@ -1,11 +1,7 @@ -import {isDebug} from '../../constants/internal'; - -const maxTimeoutInMs = 3600_000; - /** * Get promise that waits for timeout in `delayInMs` milliseconds. */ export const getTimeoutPromise = (delayInMs: number): Promise => new Promise((resolve) => { - setTimeout(resolve, isDebug ? maxTimeoutInMs : delayInMs); + setTimeout(resolve, delayInMs); }); diff --git a/src/utils/report/client/createJsxRuntime.ts b/src/utils/report/client/createJsxRuntime.ts index 71281c05..a6fc045b 100644 --- a/src/utils/report/client/createJsxRuntime.ts +++ b/src/utils/report/client/createJsxRuntime.ts @@ -1,11 +1,66 @@ +import { + createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize, + isSafeHtml as clientIsSafeHtml, + sanitizeHtml as clientSanitizeHtml, +} from './sanitizeHtml'; + +import type {SafeHtml} from '../../../types/internal'; + +const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; +const isSafeHtml: typeof clientIsSafeHtml = clientIsSafeHtml; +const sanitizeHtml = clientSanitizeHtml; + /** * Creates JSX runtime (functions `createElement` and `Fragment`). * This client function should not use scope variables (except global functions). * @internal */ export function createJsxRuntime(): JSX.Runtime { - const createElement: JSX.CreateElement = (type, properties, ...children) => ''; - const Fragment: JSX.Fragment = ({children}) => ''; + const maxDepth = 8; + + const createElement: JSX.CreateElement = (type, properties, ...children) => { + const flatChildren = children.flat(maxDepth); + + if (typeof type === 'function') { + const propertiesWithChildren = + flatChildren.length === 0 ? properties : {...properties, children: flatChildren}; + + return type(propertiesWithChildren ?? undefined); + } + + const childrenParts: readonly SafeHtml[] = flatChildren.map((child) => + isSafeHtml(child) ? child : sanitizeHtml`${child}`, + ); + const childrenHtml = createSafeHtmlWithoutSanitize`${childrenParts.join('')}`; + + if (properties == null) { + return sanitizeHtml`<${type}>${childrenHtml}`; + } + + const attributesParts: readonly SafeHtml[] = Object.entries(properties).map( + ([key, value]) => sanitizeHtml`${key}="${value}"`, + ); + const attributesHtml = createSafeHtmlWithoutSanitize`${attributesParts.join('')}`; + + return sanitizeHtml`<${type} ${attributesHtml}>${childrenHtml}`; + }; + + const Fragment: JSX.Fragment = (properties) => { + if (properties?.children == null) { + return createSafeHtmlWithoutSanitize``; + } + + if (!Array.isArray(properties.children)) { + return sanitizeHtml`${properties.children}`; + } + + const flatChildren: unknown[] = properties.children.flat(maxDepth); + const childrenParts: readonly SafeHtml[] = flatChildren.map((child) => + isSafeHtml(child) ? child : sanitizeHtml`${child}`, + ); + + return createSafeHtmlWithoutSanitize`${childrenParts.join('')}`; + }; - return {createElement, Fragment}; + return {Fragment, createElement}; } diff --git a/src/utils/report/client/index.ts b/src/utils/report/client/index.ts index 44ffcf06..780020d0 100644 --- a/src/utils/report/client/index.ts +++ b/src/utils/report/client/index.ts @@ -41,6 +41,12 @@ export { renderTestRunError, } from './render'; /** @internal */ -export {createSafeHtmlWithoutSanitize, sanitizeHtml, sanitizeJson} from './sanitizeHtml'; +export { + createSafeHtmlWithoutSanitize, + isSafeHtml, + sanitizeHtml, + sanitizeJson, + sanitizeValue, +} from './sanitizeHtml'; /** @internal */ export {setReadJsonReportDataObservers} from './setReadJsonReportDataObservers'; diff --git a/src/utils/report/client/initialScript.ts b/src/utils/report/client/initialScript.ts index 80c070cf..dce15933 100644 --- a/src/utils/report/client/initialScript.ts +++ b/src/utils/report/client/initialScript.ts @@ -16,6 +16,8 @@ import {setReadJsonReportDataObservers as clientSetReadJsonReportDataObservers} import type {ReportClientState, SafeHtml} from '../../../types/internal'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +declare let jsx: JSX.Runtime; declare const reportClientState: ReportClientState; const addDomContentLoadedHandler = clientAddDomContentLoadedHandler; @@ -36,7 +38,8 @@ const setReadJsonReportDataObservers = clientSetReadJsonReportDataObservers; * @internal */ export function initialScript(): void { - const jsxRuntime = createJsxRuntime(); + jsx = createJsxRuntime(); + const e2edRightColumnContainer = document.getElementById('e2edRightColumnContainer') ?? undefined; assertValueIsDefined(e2edRightColumnContainer); @@ -47,7 +50,6 @@ export function initialScript(): void { Object.assign>(reportClientState, { e2edRightColumnContainer, - jsxRuntime, locator, }); diff --git a/src/utils/report/client/render/renderApiStatistics.ts b/src/utils/report/client/render/renderApiStatistics.ts index 7730ee17..9968afc1 100644 --- a/src/utils/report/client/render/renderApiStatistics.ts +++ b/src/utils/report/client/render/renderApiStatistics.ts @@ -2,7 +2,12 @@ import {createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize} fr import {renderApiStatisticsItem as clientRenderApiStatisticsItem} from './renderApiStatisticsItem'; -import type {ApiStatistics, ApiStatisticsReportHash, SafeHtml} from '../../../../types/internal'; +import type { + ApiStatistics, + ApiStatisticsReportHash, + ObjectEntries, + SafeHtml, +} from '../../../../types/internal'; const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; const renderApiStatisticsItem = clientRenderApiStatisticsItem; @@ -62,15 +67,17 @@ export function renderApiStatistics({apiStatistics, hash}: Options): SafeHtml { } else { header = 'Resources'; - for (const [url, byStatusCode] of Object.entries(apiStatistics.resources)) { + for (const [url, byStatusCode] of Object.entries(apiStatistics.resources) as ObjectEntries< + typeof apiStatistics.resources + >) { for (const [statusCode, {count, duration, size}] of Object.entries(byStatusCode)) { items.push( renderApiStatisticsItem({ count, duration, - isUrl: true, name: `${url} ${statusCode}`, size, + url, }), ); } diff --git a/src/utils/report/client/render/renderApiStatisticsItem.tsx b/src/utils/report/client/render/renderApiStatisticsItem.tsx index cfe4e69d..29c8378a 100644 --- a/src/utils/report/client/render/renderApiStatisticsItem.tsx +++ b/src/utils/report/client/render/renderApiStatisticsItem.tsx @@ -1,18 +1,18 @@ import {renderDuration as clientRenderDuration} from './renderDuration'; -import type {ReportClientState, SafeHtml} from '../../../../types/internal'; +import type {SafeHtml, Url} from '../../../../types/internal'; const renderDuration = clientRenderDuration; -declare const reportClientState: ReportClientState; +declare const jsx: JSX.Runtime; type Options = Readonly<{ count: number; duration: number; isHeader?: boolean; - isUrl?: boolean; name: string; size?: number; + url?: Url; }>; /** @@ -24,23 +24,22 @@ export function renderApiStatisticsItem({ count, duration, isHeader, - isUrl, name, size, + url, }: Options): SafeHtml { const bytesInKiB = 1_024; const durationHtml = renderDuration(duration / count); const countHtml = `${count}x`; const sizeHtml = size === undefined ? '' : `${(size / count / bytesInKiB).toFixed(2)} KiB / `; - const {createElement, Fragment} = reportClientState.jsxRuntime; let nameHtml: SafeHtml; if (isHeader) { nameHtml = {name}; - } else if (isUrl) { + } else if (url !== undefined) { nameHtml = ( - + {name} ); diff --git a/src/utils/report/client/sanitizeHtml.ts b/src/utils/report/client/sanitizeHtml.ts index 9d1a32a5..257c0326 100644 --- a/src/utils/report/client/sanitizeHtml.ts +++ b/src/utils/report/client/sanitizeHtml.ts @@ -41,6 +41,30 @@ export function createSafeHtmlWithoutSanitize( return safeHtml; } +/** + * Returns `true`, if value is `SafeHtml`, and `false` otherwise. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export function isSafeHtml(value: unknown): value is SafeHtml { + const key = Symbol.for('e2ed:SafeHtml:key'); + + return typeof value === 'object' && value !== null && key in value; +} + +/** + * Sanitizes arbitrary value. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export function sanitizeValue(value: unknown): string { + return String(value) + .replace(/&/g, '&') + .replace(/ - String(value) - .replace(/&/g, '&') - .replace(/ => { const {testFileGlobs} = getFullPackConfig(); - const errors: string[] = []; + const errors = (await readGlobalErrors()) as string[]; const allTestFilePaths = await collectTestFilePaths(); const unsuccessfulTestFilePaths = await getUnsuccessfulTestFilePaths( diff --git a/src/utils/report/render/renderScript.ts b/src/utils/report/render/renderScript.ts index 9ece03f2..4cd85744 100644 --- a/src/utils/report/render/renderScript.ts +++ b/src/utils/report/render/renderScript.ts @@ -1,7 +1,7 @@ import {createSafeHtmlWithoutSanitize, initialScript} from '../client'; -import {renderScriptConstants} from './renderScriptConstants'; import {renderScriptFunctions} from './renderScriptFunctions'; +import {renderScriptGlobals} from './renderScriptGlobals'; import type {SafeHtml} from '../../../types/internal'; @@ -11,7 +11,7 @@ import type {SafeHtml} from '../../../types/internal'; */ export const renderScript = (): SafeHtml => createSafeHtmlWithoutSanitize` `; diff --git a/src/utils/report/render/renderScriptConstants.ts b/src/utils/report/render/renderScriptGlobals.ts similarity index 89% rename from src/utils/report/render/renderScriptConstants.ts rename to src/utils/report/render/renderScriptGlobals.ts index 8e6f2d45..3fa39a41 100644 --- a/src/utils/report/render/renderScriptConstants.ts +++ b/src/utils/report/render/renderScriptGlobals.ts @@ -12,9 +12,8 @@ import type {ReportClientState, SafeHtml} from '../../../types/internal'; * Renders JS constants for report page. * @internal */ -export const renderScriptConstants = (): SafeHtml => { +export const renderScriptGlobals = (): SafeHtml => { const e2edRightColumnContainer = {} as unknown as HTMLElement; - const jsxRuntime = {} as unknown as JSX.Runtime; const locator = {} as unknown as ReportClientState['locator']; const {pathToScreenshotsDirectoryForReport} = getFullPackConfig(); @@ -23,7 +22,6 @@ export const renderScriptConstants = (): SafeHtml => { e2edRightColumnContainer, fullTestRuns: [], internalDirectoryName: INTERNAL_DIRECTORY_NAME, - jsxRuntime, lengthOfReadedJsonReportDataParts: 0, locator, pathToScreenshotsDirectoryForReport, @@ -31,5 +29,6 @@ export const renderScriptConstants = (): SafeHtml => { }; return createSafeHtmlWithoutSanitize` +var jsx; const reportClientState = ${JSON.stringify(reportClientState)};`; }; diff --git a/src/utils/retry/getTestsSubprocessForkOptions.ts b/src/utils/retry/getTestsSubprocessForkOptions.ts index 4b8029e3..35f36716 100644 --- a/src/utils/retry/getTestsSubprocessForkOptions.ts +++ b/src/utils/retry/getTestsSubprocessForkOptions.ts @@ -1,4 +1,4 @@ -import {e2edEnvironment, isDebug} from '../../constants/internal'; +import {DEBUG_PORT} from '../../constants/internal'; import {assertNumberIsPositiveInteger} from '../asserts'; @@ -9,14 +9,15 @@ import type {ForkOptions} from 'node:child_process'; * @internal */ export const getTestsSubprocessForkOptions = (): ForkOptions | undefined => { - if (!isDebug) { + if (DEBUG_PORT === undefined) { return undefined; } const execArgvWithoutInspect = process.execArgv.filter((arg) => !arg.startsWith('--inspect')); - const port = Number(e2edEnvironment.E2ED_DEBUG) + 1; + const port = DEBUG_PORT + 1; - assertNumberIsPositiveInteger(port, 'port is positive integer', {e2edEnvironment}); + // eslint-disable-next-line @typescript-eslint/naming-convention + assertNumberIsPositiveInteger(port, 'port is positive integer', {DEBUG_PORT}); return {execArgv: [...execArgvWithoutInspect, `--inspect-brk=0.0.0.0:${port}`]}; }; diff --git a/src/utils/selectors/Selector.ts b/src/utils/selectors/Selector.ts index 2e753af7..368f7e01 100644 --- a/src/utils/selectors/Selector.ts +++ b/src/utils/selectors/Selector.ts @@ -245,7 +245,9 @@ class Selector { .locator(getPlaywrightPage().locator(String(args[0]))); case 'nth': - return selector.getPlaywrightLocator().nth(Number(args[0])); + return args[0] === -1 + ? selector.getPlaywrightLocator().last() + : selector.getPlaywrightLocator().nth(Number(args[0])); case 'parent': return selector.getPlaywrightLocator().locator('xpath=..'); diff --git a/src/utils/startInfo/getStartInfo.ts b/src/utils/startInfo/getStartInfo.ts index 6da21884..c11da64e 100644 --- a/src/utils/startInfo/getStartInfo.ts +++ b/src/utils/startInfo/getStartInfo.ts @@ -6,7 +6,7 @@ import { ABSOLUTE_PATH_TO_PROJECT_ROOT_DIRECTORY, e2edEnvironment, INSTALLED_E2ED_DIRECTORY_PATH, - isDebug, + IS_DEBUG, } from '../../constants/internal'; import {getFullPackConfig} from '../config'; @@ -48,7 +48,7 @@ export const getStartInfo = ({configCompileTimeWithUnits}: Options): StartInfo = e2edEnvironmentVariables, fullPackConfig: getFullPackConfig(), installedE2edDirectoryPath: INSTALLED_E2ED_DIRECTORY_PATH, - isDebug, + isDebug: IS_DEBUG, isUiMode, nodeVersion: process.version, pathToPack: getPathToPack(), diff --git a/src/utils/test/beforeTest.ts b/src/utils/test/beforeTest.ts index 096d49a1..7db78439 100644 --- a/src/utils/test/beforeTest.ts +++ b/src/utils/test/beforeTest.ts @@ -1,4 +1,4 @@ -import {TestRunStatus} from '../../constants/internal'; +import {IS_DEBUG, MAX_TIMEOUT_IN_MS, TestRunStatus} from '../../constants/internal'; import {setMeta} from '../../context/meta'; import {getOnResponseCallbacks} from '../../context/onResponseCallbacks'; import {setOutputDirectoryName} from '../../context/outputDirectoryName'; @@ -13,6 +13,7 @@ import {getFullPackConfig} from '../config'; import {getRunLabel} from '../environment'; import {registerStartTestRunEvent} from '../events'; import {mapBackendResponseForLogs} from '../log'; +import {isUiMode} from '../uiMode'; import {getUserlandHooks} from '../userland'; import {getTestFnAndReject} from './getTestFnAndReject'; @@ -50,8 +51,12 @@ export const beforeTest = ({ const {testIdleTimeout: testIdleTimeoutFromConfig, testTimeout: testTimeoutFromConfig} = getFullPackConfig(); - const testIdleTimeout = options.testIdleTimeout ?? testIdleTimeoutFromConfig; - const testTimeout = options.testTimeout ?? testTimeoutFromConfig; + const testIdleTimeout = + IS_DEBUG || isUiMode + ? MAX_TIMEOUT_IN_MS + : (options.testIdleTimeout ?? testIdleTimeoutFromConfig); + const testTimeout = + IS_DEBUG || isUiMode ? MAX_TIMEOUT_IN_MS : (options.testTimeout ?? testTimeoutFromConfig); test.setTimeout(testTimeout + additionToPlaywrightTestTimeout + (Date.now() - startTimeInMs)); diff --git a/src/utils/test/createRunId.ts b/src/utils/test/createRunId.ts new file mode 100644 index 00000000..03080e4c --- /dev/null +++ b/src/utils/test/createRunId.ts @@ -0,0 +1,19 @@ +import {randomUUID} from 'node:crypto'; + +import {getHash} from '../getHash'; +import {isUiMode} from '../uiMode'; + +import type {RunId, Test} from '../../types/internal'; + +/** + * Creates new `RunId` for test run. + * @internal + */ +export const createRunId = (test: Test, retryIndex: number): RunId => { + const data = {...test, testFn: test.testFn.toString()}; + const text = JSON.stringify(data); + + const base = getHash(isUiMode ? randomUUID() : text); + + return `${base}-${retryIndex}` as RunId; +}; diff --git a/src/utils/test/getRunTest.ts b/src/utils/test/getRunTest.ts index 8c1b90ca..81f7c0b6 100644 --- a/src/utils/test/getRunTest.ts +++ b/src/utils/test/getRunTest.ts @@ -1,4 +1,3 @@ -import {createRunId} from '../../generators/internal'; import {pageStorage} from '../../useContext'; import {assertValueIsDefined} from '../asserts'; @@ -6,6 +5,7 @@ import {assertValueIsDefined} from '../asserts'; import {afterErrorInTest} from './afterErrorInTest'; import {afterTest} from './afterTest'; import {beforeTest} from './beforeTest'; +import {createRunId} from './createRunId'; import {getOutputDirectoryName} from './getOutputDirectoryName'; import {getShouldRunTest} from './getShouldRunTest'; import {getTestStaticOptions} from './getTestStaticOptions'; diff --git a/src/utils/tests/runTests.ts b/src/utils/tests/runTests.ts index 9d390150..ec0a4fbe 100644 --- a/src/utils/tests/runTests.ts +++ b/src/utils/tests/runTests.ts @@ -1,7 +1,7 @@ import {fork} from 'node:child_process'; import {isLocalRun} from '../../configurator'; -import {CONFIG_PATH, e2edEnvironment, isDebug} from '../../constants/internal'; +import {CONFIG_PATH, e2edEnvironment, IS_DEBUG} from '../../constants/internal'; import {getFullPackConfig} from '../config'; import {getRunLabel, setRunLabel} from '../environment'; @@ -44,7 +44,7 @@ export const runTests = async ({runLabel}: RunRetryOptions): Promise => { await new Promise((resolve, reject) => { const playwrightArgs = ['test', `--config=${CONFIG_PATH}`]; - if (isDebug && isLocalRun) { + if (IS_DEBUG && isLocalRun) { e2edEnvironment.PWDEBUG = 'console'; playwrightArgs.push('--debug'); } diff --git a/src/utils/waitForEvents/getInitialIdsForAllRequestsCompletePredicate.ts b/src/utils/waitForEvents/getInitialIdsForAllRequestsCompletePredicate.ts index 10325d59..e9ad8fdc 100644 --- a/src/utils/waitForEvents/getInitialIdsForAllRequestsCompletePredicate.ts +++ b/src/utils/waitForEvents/getInitialIdsForAllRequestsCompletePredicate.ts @@ -1,8 +1,8 @@ import {assertValueIsDefined} from '../asserts'; import {E2edError} from '../error'; +import {getEntries} from '../object'; import type { - ObjectEntries, RequestHookContextId, RequestPredicate, WaitForEventsState, @@ -20,26 +20,26 @@ export const getInitialIdsForAllRequestsCompletePredicate = async ( ): Promise> => { const requestHookContextIds = new Set(); - const promises = ( - Object.entries(hashOfNotCompleteRequests) as ObjectEntries - ).map(async ([requestHookContextId, request]) => { - assertValueIsDefined(request, 'request is defined', {predicate, requestHookContextId}); + const promises = getEntries(hashOfNotCompleteRequests).map( + async ([requestHookContextId, request]) => { + assertValueIsDefined(request, 'request is defined', {predicate, requestHookContextId}); - try { - const isMatched = await predicate(request); + try { + const isMatched = await predicate(request); - if (isMatched === true) { - requestHookContextIds.add(requestHookContextId); + if (isMatched === true) { + requestHookContextIds.add(requestHookContextId); + } + } catch (cause) { + const error = new E2edError( + 'waitForAllRequestsComplete promise rejected due to error in predicate function', + {cause, predicate, request}, + ); + + throw error; } - } catch (cause) { - const error = new E2edError( - 'waitForAllRequestsComplete promise rejected due to error in predicate function', - {cause, predicate, request}, - ); - - throw error; - } - }); + }, + ); await Promise.all(promises); diff --git a/tsconfig.json b/tsconfig.json index 32d6d1de..56eb16aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,8 +9,8 @@ "incremental": true, "isolatedDeclarations": true, "jsx": "react", - "jsxFactory": "createElement", - "jsxFragmentFactory": "Fragment", + "jsxFactory": "jsx.createElement", + "jsxFragmentFactory": "jsx.Fragment", "libReplacement": false, "module": "CommonJS", "noFallthroughCasesInSwitch": true,