From b5387efdcc295f0e03d799bb670c71bbee2d5fa8 Mon Sep 17 00:00:00 2001 From: uid11 Date: Tue, 18 Aug 2026 05:55:50 +0300 Subject: [PATCH 1/2] feat: add project setting in JSON instead of variable.env --- README.md | 16 ++++-- autotests/bin/runDocker.sh | 5 +- autotests/configurator/index.ts | 1 + autotests/configurator/testIdentifierKey.ts | 11 ++++ autotests/projectSettings.json | 6 +++ autotests/types/packsTypeChecks.ts | 8 --- autotests/types/typeChecks.ts | 11 ++++ autotests/variables.env | 10 ---- src/configurator/getTestIdentifierKey.ts | 9 ++++ src/configurator/index.ts | 1 + src/constants/internal.ts | 2 +- src/constants/paths.ts | 7 ++- src/types/checks.ts | 29 +++++++++-- src/types/environment.ts | 1 - src/types/index.ts | 7 +-- src/types/internal.ts | 6 ++- src/types/projectSettings.ts | 9 ++++ src/types/userland/GetTestIdentifierKey.ts | 10 ++++ src/types/userland/index.ts | 1 + src/types/utils.ts | 15 ++---- .../environment/getDotEnvValuesObject.ts | 52 ------------------- src/utils/environment/index.ts | 2 - .../setDotEnvValuesToEnvironment.ts | 29 ----------- src/utils/events/registerStartE2edRunEvent.ts | 14 +---- src/utils/index.ts | 1 + src/utils/packCompiler/getCompilerOptions.ts | 12 ++--- src/utils/userland/getProjectSettings.ts | 20 +++++++ src/utils/userland/index.ts | 1 + 28 files changed, 144 insertions(+), 152 deletions(-) create mode 100644 autotests/configurator/testIdentifierKey.ts create mode 100644 autotests/projectSettings.json delete mode 100644 autotests/types/packsTypeChecks.ts create mode 100644 autotests/types/typeChecks.ts delete mode 100644 autotests/variables.env create mode 100644 src/configurator/getTestIdentifierKey.ts create mode 100644 src/types/projectSettings.ts create mode 100644 src/types/userland/GetTestIdentifierKey.ts delete mode 100644 src/utils/environment/getDotEnvValuesObject.ts delete mode 100644 src/utils/environment/setDotEnvValuesToEnvironment.ts create mode 100644 src/utils/userland/getProjectSettings.ts diff --git a/README.md b/README.md index 9fa04f42..ddcc7fb8 100644 --- a/README.md +++ b/README.md @@ -412,17 +412,25 @@ If the wait is longer than this timeout, then the promise returned by the `waitF `waitForResponseTimeout: number`: default timeout (in milliseconds) for `waitForResponse`/`waitForResponseToRoute` functions. If the wait is longer than this timeout, then the promise returned by the `waitForResponse`/`waitForResponseToRoute` function will be rejected. -### Environment variables +### Project settings + +General static project settings are stored in file `./autotests/projectSettings.json`. They apply to all project packs. -Required environment variables are defined in the `./autotests/variables.env` file (they cannot be deleted): +`allTestFileGlobs: string`: an array of globs covering all project test files across all packs +(used to generate a code report). -`E2ED_DOCKER_IMAGE`: the name of the docker image where the tests will run. +`dockerImage: string | null`: the name of the docker image where the tests will run. The image must be based on the `e2ed` base image. -`E2ED_PATH_TO_TS_CONFIG_OF_PROJECT_FROM_ROOT`: the path to TypeScript config file of the project +`pathToTsConfigFromRoot: string`: the path to TypeScript config file of the project from the root directory of the project. The project should have one common TypeScript config for both the application code and the autotest code. +`testIdentifierKey: Record`: an object with a single field that serves as the test identifier key in the test `meta`. +If the project does not use such a key, leave the object empty. + +### Environment variables + You can pass the following optional environment variables to the `e2ed` process in any standard way: `E2ED_ORIGIN`: origin-part of the url (`protocol` + `host`) on which the tests will be run. For example, `https://bing.com`. diff --git a/autotests/bin/runDocker.sh b/autotests/bin/runDocker.sh index ce03ae51..b7159039 100755 --- a/autotests/bin/runDocker.sh +++ b/autotests/bin/runDocker.sh @@ -5,16 +5,15 @@ set +u CONTAINER_LABEL="e2ed" DEBUG_PORT=$([[ $E2ED_DEBUG == inspect-brk:* ]] && echo "${E2ED_DEBUG#inspect-brk:}" || echo "") DIR="${E2ED_WORKDIR:-$PWD}" +E2ED_DOCKER_IMAGE=$(grep -m1 \"dockerImage\": $DIR/autotests/projectSettings.json | cut -d '"' -f 4) E2ED_TIMEOUT_FOR_GRACEFUL_SHUTDOWN_IN_SECONDS=16 MOUNTDIR="${E2ED_MOUNTDIR:-$DIR}" 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 - if [[ -z $E2ED_DOCKER_IMAGE ]] then - echo "Error: The \"autotests/variables.env\" file does not contain E2ED_DOCKER_IMAGE variable." + echo "Error: The \"autotests/projectSettings.json\" file does not contain dockerImage variable." echo "Add it so that \"runDocker.sh\" script can run the docker image." echo "Exit with code 9" exit 9 diff --git a/autotests/configurator/index.ts b/autotests/configurator/index.ts index 23fe1b8d..d0356256 100644 --- a/autotests/configurator/index.ts +++ b/autotests/configurator/index.ts @@ -9,6 +9,7 @@ export {mapLogPayloadInReport} from './mapLogPayloadInReport'; export {matchScreenshot} from './matchScreenshot'; export {regroupSteps} from './regroupSteps'; export {skipTests} from './skipTests'; +export {testIdentifierKey} from './testIdentifierKey'; export type { DoAfterPack, DoBeforePack, diff --git a/autotests/configurator/testIdentifierKey.ts b/autotests/configurator/testIdentifierKey.ts new file mode 100644 index 00000000..f37ff4b5 --- /dev/null +++ b/autotests/configurator/testIdentifierKey.ts @@ -0,0 +1,11 @@ +import {getTestIdentifierKey} from 'e2ed/configurator'; + +import projectSettings from '../projectSettings.json'; + +import type {GetTestIdentifierKey} from 'e2ed/types'; + +/** + * Project test identifier key in test meta. + */ +export const testIdentifierKey: GetTestIdentifierKey = + getTestIdentifierKey(projectSettings); diff --git a/autotests/projectSettings.json b/autotests/projectSettings.json new file mode 100644 index 00000000..adfa27ff --- /dev/null +++ b/autotests/projectSettings.json @@ -0,0 +1,6 @@ +{ + "allTestFileGlobs": ["**/autotests/tests/**/*.ts"], + "dockerImage": "e2edhub/e2ed", + "pathToTsConfigFromRoot": "./tsconfig.json", + "testIdentifierKey": {"testId": "key of the test identifier in test meta"} +} diff --git a/autotests/types/packsTypeChecks.ts b/autotests/types/packsTypeChecks.ts deleted file mode 100644 index 18d07983..00000000 --- a/autotests/types/packsTypeChecks.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type {Pack} from 'autotests/configurator'; -import type {pack as allTestsPack} from 'autotests/packs/allTests'; -import type {Expect, IsEqual} from 'e2ed/types'; - -/** - * Type checks of all project packs. - */ -export type PacksTypeChecks = [Expect>]; diff --git a/autotests/types/typeChecks.ts b/autotests/types/typeChecks.ts new file mode 100644 index 00000000..7f219b87 --- /dev/null +++ b/autotests/types/typeChecks.ts @@ -0,0 +1,11 @@ +import type {Pack, testIdentifierKey} from 'autotests/configurator'; +import type {pack as allTestsPack} from 'autotests/packs/allTests'; +import type {Expect, IsEqual, IsUnion, Not} from 'e2ed/types'; + +/** + * Type checks of all project packs and test identifier key. + */ +export type TypeChecks = [ + Expect>, + Expect>>, +]; diff --git a/autotests/variables.env b/autotests/variables.env deleted file mode 100644 index 58301734..00000000 --- a/autotests/variables.env +++ /dev/null @@ -1,10 +0,0 @@ -# This required file in standard dotenv format defines the environment variables -# with which all packs will be run. -# {@link https://www.npmjs.com/package/dotenv} - -# Required variable: the name of the docker image where the tests will run. -E2ED_DOCKER_IMAGE='e2edhub/e2ed' - -# Required variable: the path to TypeScript config file of the project -# from the root directory of the project. -E2ED_PATH_TO_TS_CONFIG_OF_PROJECT_FROM_ROOT='./tsconfig.json' diff --git a/src/configurator/getTestIdentifierKey.ts b/src/configurator/getTestIdentifierKey.ts new file mode 100644 index 00000000..9f35eb71 --- /dev/null +++ b/src/configurator/getTestIdentifierKey.ts @@ -0,0 +1,9 @@ +import type {GetTestIdentifierKey, ProjectSettings} from '../types/internal'; + +/** + * Get test identifier key from project settings. + */ +export const getTestIdentifierKey = ( + projectSettings: Settings, +): GetTestIdentifierKey => + Object.keys(projectSettings.testIdentifierKey)[0] as GetTestIdentifierKey; diff --git a/src/configurator/index.ts b/src/configurator/index.ts index 59b1a79e..770658d6 100644 --- a/src/configurator/index.ts +++ b/src/configurator/index.ts @@ -2,6 +2,7 @@ export type {UserlandPack as PackConfig} from '../types/internal'; export {getDurationWithUnits} from '../utils/getDurationWithUnits'; export {getShallowCopyOfObjectForLogs, getStringTrimmedToMaxLength} from '../utils/valueToString'; export {RunEnvironment, startTimeInMs} from './constants'; +export {getTestIdentifierKey} from './getTestIdentifierKey'; export {replaceFields} from './replaceFields'; export {isDockerRun, isLocalRun, runEnvironment} from './runEnvironment'; /** @internal */ diff --git a/src/constants/internal.ts b/src/constants/internal.ts index 12bbdef5..7e14698a 100644 --- a/src/constants/internal.ts +++ b/src/constants/internal.ts @@ -54,7 +54,6 @@ export { COMPILED_USERLAND_CONFIG_DIRECTORY, COMPLETED_TEST_RUNS_PATH, CONFIG_PATH, - DOT_ENV_PATH, EVENTS_DIRECTORY_PATH, EXPECTED_SCREENSHOTS_DIRECTORY_PATH, GLOBAL_ERRORS_PATH, @@ -63,6 +62,7 @@ export { INTERNAL_DIRECTORY_NAME, INTERNAL_REPORTS_DIRECTORY_PATH, NOT_INCLUDED_IN_PACK_TESTS_PATH, + PROJECT_SETTINGS_PATH, REPORTS_DIRECTORY_PATH, SCREENSHOTS_DIRECTORY_PATH, START_INFO_PATH, diff --git a/src/constants/paths.ts b/src/constants/paths.ts index 41173157..321202a0 100644 --- a/src/constants/paths.ts +++ b/src/constants/paths.ts @@ -37,10 +37,13 @@ export const INSTALLED_E2ED_DIRECTORY_PATH = relative( export const AUTOTESTS_DIRECTORY_PATH = 'autotests' as DirectoryPathFromRoot; /** - * Relative (from root) path to `variables.env` file in directory with autotests. + * Relative (from root) path to `projectSettings.json` file in directory with autotests. * @internal */ -export const DOT_ENV_PATH = join(AUTOTESTS_DIRECTORY_PATH, 'variables.env') as FilePathFromRoot; +export const PROJECT_SETTINGS_PATH = join( + AUTOTESTS_DIRECTORY_PATH, + 'projectSettings.json', +) as FilePathFromRoot; /** * Relative (from root) path to reports directory. diff --git a/src/types/checks.ts b/src/types/checks.ts index d945a051..c74f8c16 100644 --- a/src/types/checks.ts +++ b/src/types/checks.ts @@ -3,20 +3,41 @@ */ export type Expect = Type; +/** + * Returns `true` if type is an array (or tuple) of given element's type, and `false` otherwise. + * `IsArray<[]>` = `true`. + * `IsArray<[true, false]>` = `true`. + * `IsArray` = `true`. + * `IsArray<[1, 2], string>` = `false`. + * `IsArray` = `true`. + */ +export type IsArray = Type extends readonly Element[] ? true : false; + /** * Returns `true` if types are exactly equal and `false` otherwise. - * IsEqual<{foo: string}, {foo: string}> = true. - * IsEqual<{readonly foo: string}, {foo: string}> = false. + * `IsEqual<{foo: string}, {foo: string}>` = `true`. + * `IsEqual<{readonly foo: string}, {foo: string}>` = `false`. */ export type IsEqual = (() => Type extends X ? 1 : 2) extends () => Type extends Y ? 1 : 2 ? true : false; /** * Returns `true` if key is readonly in object and `false` otherwise. - * IsReadonlyKey<{readonly foo?: 2}, 'foo'> = true. - * IsReadonlyKey<{foo: ''}, 'foo'> = false. + * `IsReadonlyKey<{readonly foo?: 2}, 'foo'>` = `true`. + * `IsReadonlyKey<{foo: ''}, 'foo'>` = `false`. */ export type IsReadonlyKey = IsEqual< Readonly>, Pick >; + +/** + * Returns `true` if type is a union, and `false` otherwise. + * `IsUnion<0 | 1> = `true`. + * `IsUnion<'foo'> = `false`. + */ +export type IsUnion = Type extends unknown + ? [Union] extends [Type] + ? false + : true + : never; diff --git a/src/types/environment.ts b/src/types/environment.ts index a5582b72..5fbf77af 100644 --- a/src/types/environment.ts +++ b/src/types/environment.ts @@ -18,7 +18,6 @@ export type E2edEnvironment = { [key: string]: string | undefined; ['E2ED_DEBUG']?: string; ['E2ED_ORIGIN']?: string; - ['E2ED_PATH_TO_TS_CONFIG_OF_PROJECT_FROM_ROOT']?: string; ['E2ED_TERMINATION_SIGNAL']?: NodeJS.Signals; [PATH_TO_PACK_VARIABLE_NAME]?: string; [PATH_TO_TEST_FILE_VARIABLE_NAME]?: string; diff --git a/src/types/index.ts b/src/types/index.ts index b4e9ae06..73141619 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,7 +8,7 @@ export type { StatisticsUnit, } from './apiStatistics'; export type {Brand, IsBrand} from './brand'; -export type {Expect, IsEqual, IsReadonlyKey} from './checks'; +export type {Expect, IsArray, IsEqual, IsReadonlyKey, IsUnion} from './checks'; export type {Class} from './class'; export type {ClientFunction} from './clientFunction'; export type {BrowserName} from './config'; @@ -69,6 +69,7 @@ export type { FilePathFromRoot, TestFilePath, } from './paths'; +export type {ProjectSettings} from './projectSettings'; export type {AsyncVoid, MaybePromise, Thenable} from './promise'; export type { AnyObject, @@ -97,13 +98,13 @@ export type { IsIncludeUndefined, Void, } from './undefined'; -export type {CreatePackSpecificTypes} from './userland'; +export type {CreatePackSpecificTypes, GetTestIdentifierKey} from './userland'; export type { Any, GetParamsType, - IsArray, Mutable, Normalize, + Not, ObjectEntries, OptionalIfValueIncludeDefault, UnionToIntersection, diff --git a/src/types/internal.ts b/src/types/internal.ts index 4dcdb012..93f3dcc7 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -12,7 +12,7 @@ export type { /** @internal */ export type {ApiStatisticsReportHash} from './apiStatistics'; export type {Brand, IsBrand} from './brand'; -export type {Expect, IsEqual, IsReadonlyKey} from './checks'; +export type {Expect, IsArray, IsEqual, IsReadonlyKey, IsUnion} from './checks'; export type {Class} from './class'; export type {ClientFunction} from './clientFunction'; export type { @@ -111,6 +111,7 @@ export type { FilePathFromRoot, TestFilePath, } from './paths'; +export type {ProjectSettings} from './projectSettings'; export type {AsyncVoid, MaybePromise, Thenable} from './promise'; export type { AnyObject, @@ -180,6 +181,7 @@ export type { CreatePackSpecificTypes, CustomPackPropertiesPlaceholder, CustomReportPropertiesPlaceholder, + GetTestIdentifierKey, SkipTestsPlaceholder, TestMetaPlaceholder, UserlandHooks, @@ -187,9 +189,9 @@ export type { export type { Any, GetParamsType, - IsArray, Mutable, Normalize, + Not, ObjectEntries, OptionalIfValueIncludeDefault, UnionToIntersection, diff --git a/src/types/projectSettings.ts b/src/types/projectSettings.ts new file mode 100644 index 00000000..f5b67823 --- /dev/null +++ b/src/types/projectSettings.ts @@ -0,0 +1,9 @@ +/** + * Common static project settings (general for all packs). + */ +export type ProjectSettings = Readonly<{ + allTestFileGlobs: readonly string[]; + dockerImage: string | null; + pathToTsConfigFromRoot: string; + testIdentifierKey: Readonly>; +}>; diff --git a/src/types/userland/GetTestIdentifierKey.ts b/src/types/userland/GetTestIdentifierKey.ts new file mode 100644 index 00000000..9f80f211 --- /dev/null +++ b/src/types/userland/GetTestIdentifierKey.ts @@ -0,0 +1,10 @@ +import type {IsEqual} from '../checks'; +import type {ProjectSettings} from '../projectSettings'; + +/** + * Get type of test identifier key + */ +export type GetTestIdentifierKey = + IsEqual extends true + ? undefined + : keyof Settings['testIdentifierKey']; diff --git a/src/types/userland/index.ts b/src/types/userland/index.ts index a2deb4df..91f6da77 100644 --- a/src/types/userland/index.ts +++ b/src/types/userland/index.ts @@ -1,4 +1,5 @@ export type {CreatePackSpecificTypes} from './createPackSpecificTypes'; +export type {GetTestIdentifierKey} from './GetTestIdentifierKey'; export type { CustomPackPropertiesPlaceholder, CustomReportPropertiesPlaceholder, diff --git a/src/types/utils.ts b/src/types/utils.ts index c3212607..0bf20fc8 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -19,16 +19,6 @@ export type GetParamsType = Class extends {['__PARAMS_KEY']: unknown} ? Normalize : never; -/** - * Returns `true` if type is an array (or tuple) of given element's type, and `false` otherwise. - * `IsArray<[]>` = `true`. - * `IsArray<[true, false]>` = `true`. - * `IsArray` = `true`. - * `IsArray<[1, 2], string>` = `false`. - * `IsArray` = `true`. - */ -export type IsArray = Type extends readonly Element[] ? true : false; - /** * Returns a copy of the object type with mutable properties. * `Mutable<{readonly foo: string}>` = `{foo: string}`. @@ -47,6 +37,11 @@ export type Normalize = keyof Type extends never ? Type : {[Key in keyof Type]: Normalize}; +/** + * Returns `true` if type is `false`, and `false` otherwise. + */ +export type Not = Type extends true ? false : true; + /** * List of pairs that `Object.entries` returns. */ diff --git a/src/utils/environment/getDotEnvValuesObject.ts b/src/utils/environment/getDotEnvValuesObject.ts deleted file mode 100644 index bd864b48..00000000 --- a/src/utils/environment/getDotEnvValuesObject.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {readFile} from 'node:fs/promises'; - -import {DOT_ENV_PATH, READ_FILE_OPTIONS} from '../../constants/internal'; - -import {E2edError} from '../error'; - -/** - * Get object with values from `variables.env` file in directory with autotests. - * {@link https://www.npmjs.com/package/dotenv} - * @internal - */ -export const getDotEnvValuesObject = async (): Promise>> => { - const dotEnvText = await readFile(DOT_ENV_PATH, READ_FILE_OPTIONS); - - const lines = dotEnvText.split('\n'); - const result = Object.create(null) as Record; - - for (const line of lines) { - const trimmedLine = line.trim(); - - if (line === '' || line[0] === '#') { - continue; - } - - const indexOfEqualSign = trimmedLine.indexOf('='); - - if (indexOfEqualSign < 1) { - throw new E2edError('Incorrect name of environment variable in `variables.env`', {line}); - } - - const name = trimmedLine.slice(0, indexOfEqualSign).trim(); - - if (name in result) { - throw new E2edError(`Duplicate name "${name}" in \`variables.env\` file`, { - firstValue: result[name], - line, - }); - } - - const valueMaybeWithQuotes = trimmedLine.slice(indexOfEqualSign + 1).trim(); - const firstCharacter = valueMaybeWithQuotes[0]; - const isQuoted = - firstCharacter === valueMaybeWithQuotes.at(-1) && - (firstCharacter === '"' || firstCharacter === "'" || firstCharacter === '`'); - - const value = isQuoted ? valueMaybeWithQuotes.slice(1, -1) : valueMaybeWithQuotes; - - result[name] = value; - } - - return result; -}; diff --git a/src/utils/environment/index.ts b/src/utils/environment/index.ts index c7ddf9da..0d55fcf4 100644 --- a/src/utils/environment/index.ts +++ b/src/utils/environment/index.ts @@ -2,5 +2,3 @@ export {getPathToPack, setPathToPack} from './pathToPack'; /** @internal */ export {getRunLabel, setRunLabel} from './runLabel'; -/** @internal */ -export {setDotEnvValuesToEnvironment} from './setDotEnvValuesToEnvironment'; diff --git a/src/utils/environment/setDotEnvValuesToEnvironment.ts b/src/utils/environment/setDotEnvValuesToEnvironment.ts deleted file mode 100644 index 94920fb0..00000000 --- a/src/utils/environment/setDotEnvValuesToEnvironment.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {e2edEnvironment} from '../../constants/internal'; - -import {E2edError} from '../error'; - -import {getDotEnvValuesObject} from './getDotEnvValuesObject'; - -/** - * Set values from `variables.env` file in directory with autotests to environment (to `process.env`). - * @internal - */ -export const setDotEnvValuesToEnvironment = async (): Promise => { - // eslint-disable-next-line @typescript-eslint/unbound-method - const {hasOwnProperty} = Object.prototype; - const values = await getDotEnvValuesObject(); - - for (const [name, value] of Object.entries(values)) { - if (hasOwnProperty.call(e2edEnvironment, name) && e2edEnvironment[name] !== value) { - throw new E2edError( - `Environment variable "${name}" from \`variables.env\` already defined in \`process.env\` with other value`, - { - valueFromDotEnv: value, - valueFromProccessEnv: e2edEnvironment[name], - }, - ); - } - - e2edEnvironment[name] = value; - } -}; diff --git a/src/utils/events/registerStartE2edRunEvent.ts b/src/utils/events/registerStartE2edRunEvent.ts index 8650aeae..15971463 100644 --- a/src/utils/events/registerStartE2edRunEvent.ts +++ b/src/utils/events/registerStartE2edRunEvent.ts @@ -8,7 +8,7 @@ import { } from '../../constants/internal'; import {getFullPackConfig, updateConfig} from '../config'; -import {getPathToPack, setDotEnvValuesToEnvironment} from '../environment'; +import {getPathToPack} from '../environment'; import {E2edError} from '../error'; import {setGlobalExitCode} from '../exit'; import {createDirectory, removeDirectory, writeStartInfo} from '../fs'; @@ -27,12 +27,6 @@ export const registerStartE2edRunEvent = async (): Promise => { await removeDirectory(TMP_DIRECTORY_PATH); await createDirectory(EVENTS_DIRECTORY_PATH); - let errorSettingDotEnv: unknown; - - await setDotEnvValuesToEnvironment().catch((error: unknown) => { - errorSettingDotEnv = error; - }); - const pathToTestFile = process.argv[2]; if (pathToTestFile !== undefined) { @@ -65,12 +59,6 @@ export const registerStartE2edRunEvent = async (): Promise => { updateConfig(fullPackConfig, startInfo); - if (errorSettingDotEnv !== undefined) { - generalLog('Caught an error on setting environment variables from `variables.env` file', { - errorSettingDotEnv, - }); - } - if (compileErrors.length !== 0) { const pathToPack = getPathToPack(); diff --git a/src/utils/index.ts b/src/utils/index.ts index f88e200f..cd23a48f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -61,5 +61,6 @@ export {getDimensionsString, getPngDimensions} from './screenshot'; export {getPackageInfo} from './startInfo'; export {isArray, isThenable} from './typeGuards'; export {isUiMode} from './uiMode'; +export {getProjectSettings} from './userland'; export {removeStyleFromString, valueToString} from './valueToString'; export {isSelectorEntirelyInViewport, isSelectorInViewport} from './viewport'; diff --git a/src/utils/packCompiler/getCompilerOptions.ts b/src/utils/packCompiler/getCompilerOptions.ts index 104b88f9..ee7ad9a0 100644 --- a/src/utils/packCompiler/getCompilerOptions.ts +++ b/src/utils/packCompiler/getCompilerOptions.ts @@ -4,11 +4,11 @@ import { ABSOLUTE_PATH_TO_PROJECT_ROOT_DIRECTORY, AUTOTESTS_DIRECTORY_PATH, COMPILED_USERLAND_CONFIG_DIRECTORY, - e2edEnvironment, } from '../../constants/internal'; import {assertValueIsDefined} from '../asserts'; import {cloneWithoutUndefinedProperties} from '../clone'; +import {getProjectSettings} from '../userland'; import type {CompilerOptions} from 'typescript'; @@ -47,18 +47,14 @@ export const getCompilerOptions = (): Return => { let parsingTsConfigError: Record | undefined; let tsConfigOfProject: Readonly<{compilerOptions: CompilerOptions}> = {compilerOptions: {}}; - const pathToTsConfigOfProjectFromRoot = - e2edEnvironment.E2ED_PATH_TO_TS_CONFIG_OF_PROJECT_FROM_ROOT; + const {pathToTsConfigFromRoot} = getProjectSettings(); try { - assertValueIsDefined( - pathToTsConfigOfProjectFromRoot, - 'pathToTsConfigOfProjectFromRoot is defined', - ); + assertValueIsDefined(pathToTsConfigFromRoot, 'pathToTsConfigFromRoot is defined'); const absoluteTsConfigPath = join( ABSOLUTE_PATH_TO_PROJECT_ROOT_DIRECTORY, - pathToTsConfigOfProjectFromRoot, + pathToTsConfigFromRoot, ); // eslint-disable-next-line global-require, import/no-dynamic-require diff --git a/src/utils/userland/getProjectSettings.ts b/src/utils/userland/getProjectSettings.ts new file mode 100644 index 00000000..0cb3d978 --- /dev/null +++ b/src/utils/userland/getProjectSettings.ts @@ -0,0 +1,20 @@ +import {join} from 'node:path'; + +import { + ABSOLUTE_PATH_TO_PROJECT_ROOT_DIRECTORY, + PROJECT_SETTINGS_PATH, +} from '../../constants/internal'; + +import type {ProjectSettings} from '../../types/internal'; + +const absoluteProjectSettingsPath = join( + ABSOLUTE_PATH_TO_PROJECT_ROOT_DIRECTORY, + PROJECT_SETTINGS_PATH, +); + +/** + * Get static project settings. + */ +export const getProjectSettings = (): ProjectSettings => + // eslint-disable-next-line global-require, import/no-dynamic-require + require(absoluteProjectSettingsPath); diff --git a/src/utils/userland/index.ts b/src/utils/userland/index.ts index 0261288f..7116b513 100644 --- a/src/utils/userland/index.ts +++ b/src/utils/userland/index.ts @@ -1,3 +1,4 @@ +export {getProjectSettings} from './getProjectSettings'; /** @internal */ export {getUserlandHooks, setUserlandHooks} from './userlandHooks'; /** @internal */ From db359ef6dbbbbd058e63be764a141d51127ed758 Mon Sep 17 00:00:00 2001 From: uid11 Date: Tue, 18 Aug 2026 15:32:30 +0300 Subject: [PATCH 2/2] PRO-21866 feat: add `getCodeReport` function feat: add Gherkin step functions refactor: add `requireTypescript` and `requirePlaywright` functions --- autotests/configurator/regroupSteps.ts | 3 +- autotests/configurator/types/testMeta.ts | 4 +- autotests/projectSettings.json | 1 + package.json | 2 +- src/README.md | 85 +++++++++-------- src/constants/index.ts | 7 +- src/constants/internal.ts | 7 +- src/constants/log.ts | 25 +++++ src/index.ts | 2 +- src/step.ts | 54 +++++++++++ src/types/codeReport.ts | 92 +++++++++++++++++++ src/types/fs.ts | 7 ++ src/types/index.ts | 12 +++ src/types/internal.ts | 11 +++ src/types/projectSettings.ts | 1 + src/utils/fs/readEventFromFile.ts | 5 +- src/utils/fs/readEventsFromFiles.ts | 4 +- src/utils/fs/readFilesByGlobs.ts | 6 +- src/utils/index.ts | 1 + src/utils/packCompiler/compilePack.ts | 4 +- src/utils/packCompiler/getCompilerOptions.ts | 4 +- src/utils/parse/codeReport/fillDuplicates.ts | 48 ++++++++++ src/utils/parse/codeReport/fillLinks.ts | 7 ++ src/utils/parse/codeReport/fillReport.ts | 14 +++ src/utils/parse/codeReport/getCodeReport.ts | 47 ++++++++++ src/utils/parse/codeReport/index.ts | 1 + src/utils/parse/codeReport/processFeatures.ts | 68 ++++++++++++++ .../parse/codeReport/processScenarios.ts | 72 +++++++++++++++ src/utils/parse/codeReport/processTests.ts | 73 +++++++++++++++ src/utils/parse/index.ts | 1 + src/utils/require.ts | 15 +++ src/utils/step/runStepBody.ts | 4 +- .../userland/runArrayOfUserlandFunctions.ts | 4 +- src/utils/userland/userlandHooks.ts | 14 ++- .../viewport/isSelectorEntirelyInViewport.ts | 6 +- src/utils/viewport/isSelectorInViewport.ts | 6 +- tsconfig.json | 2 +- 37 files changed, 651 insertions(+), 68 deletions(-) create mode 100644 src/types/codeReport.ts create mode 100644 src/types/fs.ts create mode 100644 src/utils/parse/codeReport/fillDuplicates.ts create mode 100644 src/utils/parse/codeReport/fillLinks.ts create mode 100644 src/utils/parse/codeReport/fillReport.ts create mode 100644 src/utils/parse/codeReport/getCodeReport.ts create mode 100644 src/utils/parse/codeReport/index.ts create mode 100644 src/utils/parse/codeReport/processFeatures.ts create mode 100644 src/utils/parse/codeReport/processScenarios.ts create mode 100644 src/utils/parse/codeReport/processTests.ts create mode 100644 src/utils/require.ts diff --git a/autotests/configurator/regroupSteps.ts b/autotests/configurator/regroupSteps.ts index f06f06b8..6c46e9b7 100644 --- a/autotests/configurator/regroupSteps.ts +++ b/autotests/configurator/regroupSteps.ts @@ -1,4 +1,4 @@ -import {LogEventStatus, LogEventType} from 'e2ed/constants'; +import {LOG_EVENT_STEP_TYPES, LogEventStatus, LogEventType} from 'e2ed/constants'; import {setReadonlyProperty} from 'e2ed/utils'; import type {LogEvent, Mutable} from 'e2ed/types'; @@ -8,6 +8,7 @@ import type {LogEvent, Mutable} from 'e2ed/types'; */ export const regroupSteps = (logEvents: readonly LogEvent[]): readonly LogEvent[] => { const topLevelTypes: readonly LogEventType[] = [ + ...LOG_EVENT_STEP_TYPES, LogEventType.Action, LogEventType.Assert, LogEventType.Entity, diff --git a/autotests/configurator/types/testMeta.ts b/autotests/configurator/types/testMeta.ts index b0ec340a..103cd349 100644 --- a/autotests/configurator/types/testMeta.ts +++ b/autotests/configurator/types/testMeta.ts @@ -1,6 +1,8 @@ +import type {testIdentifierKey} from 'autotests/configurator'; + /** * Test metadata parameters (testId, severity, etc). */ export type TestMeta = Readonly<{ - testId: string; + [testIdentifierKey]: string; }>; diff --git a/autotests/projectSettings.json b/autotests/projectSettings.json index adfa27ff..08c8df1e 100644 --- a/autotests/projectSettings.json +++ b/autotests/projectSettings.json @@ -1,4 +1,5 @@ { + "allFeatureFileGlobs": [], "allTestFileGlobs": ["**/autotests/tests/**/*.ts"], "dockerImage": "e2edhub/e2ed", "pathToTsConfigFromRoot": "./tsconfig.json", diff --git a/package.json b/package.json index c5cdca4b..07c6486c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "bugs": "https://github.com/joomcode/e2ed/issues", "engines": { - "node": ">=22.14.0" + "node": ">=24.14.0" }, "packageManager": "npm@11", "homepage": "https://github.com/joomcode/e2ed#readme", diff --git a/src/README.md b/src/README.md index 90516110..98a30dbc 100644 --- a/src/README.md +++ b/src/README.md @@ -1,6 +1,7 @@ ## Dependency graph -This is a graph of the base modules of the project with dependencies between them. +This is a graph of the base modules of the project with dependencies between them +(for runtime values only, not for types). Modules in the dependency graph should only import the modules above them: @@ -14,46 +15,50 @@ Modules in the dependency graph should only import the modules above them: 7. `configurator` 8. `utils/getHash` 9. `generators` -10. `utils/headers` -11. `utils/screenshot` -12. `utils/viewport` -13. `utils/parse` -14. `utils/distanceBetweenSelectors` -15. `utils/getDurationWithUnits` -16. `utils/valueToString` -17. `utils/error` -18. `utils/asserts` -19. `utils/object` -20. `utils/uiMode` -21. `utils/runLabel` -22. `utils/clone` -23. `utils/notIncludedInPackTests` +10. `utils/require` +11. `utils/headers` +12. `utils/screenshot` +13. `utils/viewport` +14. `utils/parse` +15. `utils/distanceBetweenSelectors` +16. `utils/getDurationWithUnits` +17. `utils/valueToString` +18. `utils/error` +19. `utils/asserts` +20. `utils/object` +21. `utils/uiMode` +22. `utils/runLabel` +23. `utils/clone` 24. `utils/userland` 25. `utils/fn` 26. `utils/environment` 27. `utils/packCompiler` -28. `config` -29. `utils/config` -30. `utils/generalLog` -31. `utils/testFilePaths` -32. `utils/exit` -33. `utils/promise` -34. `utils/resourceUsage` -35. `utils/fs` -36. `utils/completedTestRuns` -37. `utils/getGlobalErrorHandler` -38. `utils/tests` -39. `utils/end` -40. `utils/pack` -41. `useContext` -42. `context` -43. `utils/step` -44. `utils/apiStatistics` -45. `utils/selectors` -46. `selectors` -47. `utils/log` -48. `step` -49. `utils/waitForEvents` -50. `utils/expect` -51. `expect` -52. ... +28. `utils/config` +29. `utils/generalLog` +30. `utils/testFilePaths` +31. `utils/exit` +32. `utils/promise` +33. `utils/resourceUsage` +34. `utils/fs` +35. `utils/completedTestRuns` +36. `utils/getGlobalErrorHandler` +37. `utils/tests` +38. `utils/end` +39. `utils/pack` +40. `useContext` +41. `context` +42. `utils/step` +43. `utils/apiStatistics` +44. `utils/selectors` +45. `selectors` +46. `utils/log` +47. `step` +48. `utils/waitForEvents` +49. `utils/expect` +50. `expect` +51. `config` + +No module imports `config`, so it is at the very bottom of the graph: it is required only lazily, +inside the body of `getFullPackConfig` from `utils/config` (a deliberate exception to the rule +above), and Playwright reads it by the `CONFIG_PATH` file path (as the `--config` CLI argument), +not by import. diff --git a/src/constants/index.ts b/src/constants/index.ts index c1e78f12..69675373 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -9,6 +9,11 @@ export { NOT_FOUND_STATUS_CODE, OK_STATUS_CODE, } from './http'; -export {BACKEND_RESPONSES_LOG_MESSAGE, LogEventStatus, LogEventType} from './log'; +export { + BACKEND_RESPONSES_LOG_MESSAGE, + LOG_EVENT_STEP_TYPES, + LogEventStatus, + LogEventType, +} from './log'; export {FAILED_TEST_RUN_STATUSES, TestRunStatus} from './testRun'; export {ANY_URL_REGEXP, SLASHES_AT_THE_END_REGEXP, SLASHES_AT_THE_START_REGEXP} from './url'; diff --git a/src/constants/internal.ts b/src/constants/internal.ts index 7e14698a..64360084 100644 --- a/src/constants/internal.ts +++ b/src/constants/internal.ts @@ -39,7 +39,12 @@ export { MAX_ELEMENTS_COUNT_IN_PRINTED_ARRAY, MAX_STRING_LENGTH_IN_PRINTED_VALUE, } from './inspect'; -export {BACKEND_RESPONSES_LOG_MESSAGE, LogEventStatus, LogEventType} from './log'; +export { + BACKEND_RESPONSES_LOG_MESSAGE, + LOG_EVENT_STEP_TYPES, + LogEventStatus, + LogEventType, +} from './log'; /** @internal */ export {ADDITIONAL_STEP_TIMEOUT, MESSAGE_BACKGROUND_COLOR_BY_STATUS} from './log'; /** @internal */ diff --git a/src/constants/log.ts b/src/constants/log.ts index e8955e78..7904e29c 100644 --- a/src/constants/log.ts +++ b/src/constants/log.ts @@ -33,8 +33,33 @@ export const enum LogEventType { InternalCore = 7, InternalUtil = 8, Unspecified = 9, + Given = 10, + When = 11, + Then = 12, + And = 13, + But = 14, + Star = 15, } +/** + * `LogEvent` types of steps. + */ +export const LOG_EVENT_STEP_TYPES: [ + LogEventType.Given, + LogEventType.When, + LogEventType.Then, + LogEventType.And, + LogEventType.But, + LogEventType.Star, +] = [ + LogEventType.Given, + LogEventType.When, + LogEventType.Then, + LogEventType.And, + LogEventType.But, + LogEventType.Star, +] as const; + /** * Background color of log message by test run status. * @internal diff --git a/src/index.ts b/src/index.ts index 43c50413..122c49a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,4 +15,4 @@ export {WebSocketRoute} from './WebSocketRoute'; export {createClientFunction} from './createClientFunction'; export {createTestFunction} from './createTestFunction'; export {expect} from './expect'; -export {step} from './step'; +export {And, But, Given, Star, step, Then, When} from './step'; diff --git a/src/step.ts b/src/step.ts index fa91de3d..2df1186f 100644 --- a/src/step.ts +++ b/src/step.ts @@ -98,3 +98,57 @@ export const step = async ( } } }; + +/** + * Given step. + */ +export const Given = ( + name: string, + body?: StepBody, + options: Omit = {}, +): Promise => step(name, body, {...options, type: LogEventType.Given}); + +/** + * When step. + */ +export const When = ( + name: string, + body?: StepBody, + options: Omit = {}, +): Promise => step(name, body, {...options, type: LogEventType.When}); + +/** + * Then step. + */ +export const Then = ( + name: string, + body?: StepBody, + options: Omit = {}, +): Promise => step(name, body, {...options, type: LogEventType.Then}); + +/** + * And step. + */ +export const And = ( + name: string, + body?: StepBody, + options: Omit = {}, +): Promise => step(name, body, {...options, type: LogEventType.And}); + +/** + * But step. + */ +export const But = ( + name: string, + body?: StepBody, + options: Omit = {}, +): Promise => step(name, body, {...options, type: LogEventType.But}); + +/** + * Star step. + */ +export const Star = ( + name: string, + body?: StepBody, + options: Omit = {}, +): Promise => step(name, body, {...options, type: LogEventType.Star}); diff --git a/src/types/codeReport.ts b/src/types/codeReport.ts new file mode 100644 index 00000000..a745cbb2 --- /dev/null +++ b/src/types/codeReport.ts @@ -0,0 +1,92 @@ +import type {Feature, Scenario} from 'parse-gherkin'; + +import type {Brand} from './brand'; +import type {SourceFile} from './fs'; +import type {ParsedTest} from './parseTest'; + +/** + * Code report analyzing test and specification code, as well as the relationships between them. + */ +export type CodeReport< + TestIdentifierKey extends string = string, + TestIdentifierValue extends string = string, +> = Readonly<{ + features: Readonly>; + invalidFeatures: Readonly>; + invalidTests: Readonly>; + scenarios: Readonly>>; + tests: Readonly>>; +}>; + +/** + * Full feature report. + */ +export type FeatureReport = Readonly< + Omit & { + path: SourcePath; + scenariosPaths: readonly SourcePath[]; + } +>; + +/** + * Parsing error with source. + */ +export type ParseError = Readonly<{ + error: Error; + source: string; +}>; + +/** + * Full scenario report. + */ +export type ScenarioReport< + TestIdentifierKey extends string = string, + TestIdentifierValue extends string = string, +> = Readonly< + Scenario & + TestIdentifierField & { + duplicatesByTestIdentifier: readonly SourcePath[]; + errors: readonly string[]; + featurePath: SourcePath; + path: SourcePath; + testIdentifier: string | undefined; + testPath: SourcePath | undefined; + } +>; + +/** + * Iterable stream of source files. + */ +export type SourceIterable = AsyncIterable | Iterable; + +/** + * Path to source file. + */ +export type SourcePath = Brand; + +/** + * Field with test identifier, if any. + */ +export type TestIdentifierField< + TestIdentifierKey extends string, + TestIdentifierValue extends string | undefined, +> = string extends TestIdentifierKey + ? {} + : Readonly>; + +/** + * Full test report. + */ +export type TestReport< + TestIdentifierKey extends string = string, + TestIdentifierValue extends string = string, +> = Readonly< + ParsedTest & + TestIdentifierField & { + duplicatesByTestIdentifier: readonly SourcePath[]; + errors: readonly string[]; + path: SourcePath; + scenarioPath: SourcePath | undefined; + testIdentifier: string | undefined; + } +>; diff --git a/src/types/fs.ts b/src/types/fs.ts new file mode 100644 index 00000000..a2969599 --- /dev/null +++ b/src/types/fs.ts @@ -0,0 +1,7 @@ +/** + * Source file of any type. + */ +export type SourceFile = Readonly<{ + path: string; + source: string; +}>; diff --git a/src/types/index.ts b/src/types/index.ts index 73141619..dd598d53 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines */ + export type {ClearContext, GetContext, GetWithDefaultValueContext, SetContext} from '../useContext'; export type {Trigger} from './actions'; export type { @@ -11,6 +13,15 @@ export type {Brand, IsBrand} from './brand'; export type {Expect, IsArray, IsEqual, IsReadonlyKey, IsUnion} from './checks'; export type {Class} from './class'; export type {ClientFunction} from './clientFunction'; +export type { + CodeReport, + FeatureReport, + ParseError, + ScenarioReport, + SourceIterable, + SourcePath, + TestReport, +} from './codeReport'; export type {BrowserName} from './config'; export type {ConsoleMessage, ConsoleMessageType} from './console'; export type {UtcTimeInMs} from './date'; @@ -18,6 +29,7 @@ export type {DeepMutable, DeepPartial, DeepReadonly, DeepRequired} from './deep' export type {E2edPrintedFields, JsError} from './errors'; export type {LogEvent, Onlog, TestRunEvent} from './events'; export type {Fn, MergeFunctions} from './fn'; +export type {SourceFile} from './fs'; export type { FullMocksConfig, FullMocksResponse, diff --git a/src/types/internal.ts b/src/types/internal.ts index 93f3dcc7..277963b5 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -15,6 +15,16 @@ export type {Brand, IsBrand} from './brand'; export type {Expect, IsArray, IsEqual, IsReadonlyKey, IsUnion} from './checks'; export type {Class} from './class'; export type {ClientFunction} from './clientFunction'; +export type { + CodeReport, + FeatureReport, + ParseError, + ScenarioReport, + SourceIterable, + SourcePath, + TestIdentifierField, + TestReport, +} from './codeReport'; export type { AnyPack, BrowserName, @@ -35,6 +45,7 @@ export type {LogEvent, Onlog, TestRunEvent} from './events'; /** @internal */ export type {EndTestRunEvent, FullEventsData} from './events'; export type {Fn, MergeFunctions} from './fn'; +export type {SourceFile} from './fs'; export type { FullMocksConfig, FullMocksResponse, diff --git a/src/types/projectSettings.ts b/src/types/projectSettings.ts index f5b67823..e64f3859 100644 --- a/src/types/projectSettings.ts +++ b/src/types/projectSettings.ts @@ -2,6 +2,7 @@ * Common static project settings (general for all packs). */ export type ProjectSettings = Readonly<{ + allFeatureFileGlobs: readonly string[]; allTestFileGlobs: readonly string[]; dockerImage: string | null; pathToTsConfigFromRoot: string; diff --git a/src/utils/fs/readEventFromFile.ts b/src/utils/fs/readEventFromFile.ts index 6229e626..6bea35bc 100644 --- a/src/utils/fs/readEventFromFile.ts +++ b/src/utils/fs/readEventFromFile.ts @@ -3,8 +3,6 @@ import {join} from 'node:path'; import {EVENTS_DIRECTORY_PATH, READ_FILE_OPTIONS} from '../../constants/internal'; -import {generalLog} from '../generalLog'; - /** * Reads event object with test run from temporary directory. * @internal @@ -13,6 +11,9 @@ export const readEventFromFile = (fileName: string): Promise const filePath = join(EVENTS_DIRECTORY_PATH, fileName); return readFile(filePath, READ_FILE_OPTIONS).catch((error: unknown) => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const {generalLog} = require('../generalLog'); + generalLog(`Caught an error on reading text of test run event from file "${fileName}"`, { error, filePath, diff --git a/src/utils/fs/readEventsFromFiles.ts b/src/utils/fs/readEventsFromFiles.ts index 59d1c661..100a496a 100644 --- a/src/utils/fs/readEventsFromFiles.ts +++ b/src/utils/fs/readEventsFromFiles.ts @@ -9,7 +9,6 @@ import { } from '../../constants/internal'; import {assertValueIsDefined, assertValueIsTrue} from '../asserts'; -import {generalLog} from '../generalLog'; import {getDurationWithUnits} from '../getDurationWithUnits'; import {readEventFromFile} from './readEventFromFile'; @@ -35,6 +34,9 @@ export const readEventsFromFiles = async ( ); } + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const {generalLog} = require('../generalLog'); + const newEventFiles = allEventFiles.filter((fileName) => !skippedEventFiles.includes(fileName)); const fullTestRuns: FullTestRun[] = []; diff --git a/src/utils/fs/readFilesByGlobs.ts b/src/utils/fs/readFilesByGlobs.ts index 8af69bb3..f1a05cb8 100644 --- a/src/utils/fs/readFilesByGlobs.ts +++ b/src/utils/fs/readFilesByGlobs.ts @@ -1,9 +1,9 @@ import {glob, readFile} from 'node:fs/promises'; import {normalize} from 'node:path'; -const POOL_UPDATED = Symbol('poolUpdated'); +import type {SourceFile} from '../../types/internal'; -type File = Readonly<{path: string; source: string}>; +const POOL_UPDATED = Symbol('poolUpdated'); type ReadResult = Readonly< {key: number; path: string} & ({error: unknown; ok: false} | {ok: true; text: string}) @@ -15,7 +15,7 @@ type ReadResult = Readonly< export async function* readFilesByGlobs( patterns: readonly string[], filterByPath: (path: string) => boolean = () => true, -): AsyncGenerator { +): AsyncGenerator { const readsInFlight = new Map>(); const seenPaths = new Set(); let nextKey = 0; diff --git a/src/utils/index.ts b/src/utils/index.ts index cd23a48f..f730817d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -43,6 +43,7 @@ export {log} from './log'; export {deepMerge, getEntries, getKeys, setReadonlyProperty} from './object'; export {createPageObjectsFromMultiLocator} from './pageObjects'; export { + getCodeReport, getLinesIndexes, parseMaybeEmptyValueAsJson, parseTest, diff --git a/src/utils/packCompiler/compilePack.ts b/src/utils/packCompiler/compilePack.ts index 17c8f529..05a3aff0 100644 --- a/src/utils/packCompiler/compilePack.ts +++ b/src/utils/packCompiler/compilePack.ts @@ -1,5 +1,6 @@ import {getPathToPack} from '../environment'; import {getDurationWithUnits} from '../getDurationWithUnits'; +import {requireTypescript} from '../require'; import {getCompilerOptions} from './getCompilerOptions'; @@ -18,8 +19,7 @@ const unusedTsExceptErrorMessage = "Unused '@ts-expect-error' directive."; * @internal */ export const compilePack = (): Return => { - // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires - const typescript = require('typescript') as typeof import('typescript'); + const typescript = requireTypescript(); const { createProgram, diff --git a/src/utils/packCompiler/getCompilerOptions.ts b/src/utils/packCompiler/getCompilerOptions.ts index ee7ad9a0..bf04a83b 100644 --- a/src/utils/packCompiler/getCompilerOptions.ts +++ b/src/utils/packCompiler/getCompilerOptions.ts @@ -8,6 +8,7 @@ import { import {assertValueIsDefined} from '../asserts'; import {cloneWithoutUndefinedProperties} from '../clone'; +import {requireTypescript} from '../require'; import {getProjectSettings} from '../userland'; import type {CompilerOptions} from 'typescript'; @@ -22,8 +23,7 @@ type Return = Readonly<{ * @internal */ export const getCompilerOptions = (): Return => { - // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires - const typescript = require('typescript') as typeof import('typescript'); + const typescript = requireTypescript(); const {ModuleKind, ScriptTarget} = typescript; diff --git a/src/utils/parse/codeReport/fillDuplicates.ts b/src/utils/parse/codeReport/fillDuplicates.ts new file mode 100644 index 00000000..1b654c9f --- /dev/null +++ b/src/utils/parse/codeReport/fillDuplicates.ts @@ -0,0 +1,48 @@ +import type {Mutable, ScenarioReport, SourcePath} from '../../../types/internal'; + +type Entry = Mutable< + Pick +>; + +/** + * Fill `duplicatesByTestIdentifier` field for features or tests. + * @internal + */ +export const fillDuplicates = (entries: Readonly>): void => { + const entriesPathsByTestId: Record> = Object.create(null) as {}; + + for (const {path, testIdentifier} of Object.values(entries)) { + if (testIdentifier === undefined) { + continue; + } + + let paths = entriesPathsByTestId[testIdentifier]; + + if (paths === undefined) { + paths = new Set(); + entriesPathsByTestId[testIdentifier] = paths; + } + + paths.add(path); + } + + for (const paths of Object.values(entriesPathsByTestId)) { + if (paths.size <= 1) { + continue; + } + + for (const path of paths) { + const entry = entries[path]; + + if (entry === undefined) { + throw Error(`Cannot find entry by path "${path}"`); + } + + const duplicates = new Set(paths); + + duplicates.delete(path); + + entry.duplicatesByTestIdentifier = [...duplicates]; + } + } +}; diff --git a/src/utils/parse/codeReport/fillLinks.ts b/src/utils/parse/codeReport/fillLinks.ts new file mode 100644 index 00000000..efccac9f --- /dev/null +++ b/src/utils/parse/codeReport/fillLinks.ts @@ -0,0 +1,7 @@ +import type {CodeReport} from '../../../types/internal'; + +/** + * Fills links from tests to scenarios and from scenarios to tests. + * @internal + */ +export const fillLinks = (_codeReport: CodeReport): void => {}; diff --git a/src/utils/parse/codeReport/fillReport.ts b/src/utils/parse/codeReport/fillReport.ts new file mode 100644 index 00000000..6b09435b --- /dev/null +++ b/src/utils/parse/codeReport/fillReport.ts @@ -0,0 +1,14 @@ +import {fillDuplicates} from './fillDuplicates'; +import {fillLinks} from './fillLinks'; + +import type {CodeReport} from '../../../types/internal'; + +/** + * Fill code report internal fields. + * @internal + */ +export const fillReport = (codeReport: CodeReport): void => { + fillDuplicates(codeReport.scenarios); + fillDuplicates(codeReport.tests); + fillLinks(codeReport); +}; diff --git a/src/utils/parse/codeReport/getCodeReport.ts b/src/utils/parse/codeReport/getCodeReport.ts new file mode 100644 index 00000000..383f7071 --- /dev/null +++ b/src/utils/parse/codeReport/getCodeReport.ts @@ -0,0 +1,47 @@ +// eslint-disable-next-line import/no-internal-modules +import {readFilesByGlobs} from '../../fs/readFilesByGlobs'; +// eslint-disable-next-line import/no-internal-modules +import {getProjectSettings} from '../../userland/getProjectSettings'; + +import {fillReport} from './fillReport'; +import {processFeatures} from './processFeatures'; +import {processTests} from './processTests'; + +import type {CodeReport, SourceIterable} from '../../../types/internal'; + +type Options = Readonly<{ + features?: SourceIterable; + tests?: SourceIterable; +}>; + +/** + * Get code report, that analyzing test and specification code, as well as the relationships between them. + */ +export const getCodeReport = async < + TestIdentifierKey extends string = string, + TestIdentifierValue extends string = string, +>( + options: Options = {}, +): Promise> => { + const featuresIterable: SourceIterable = + options.features ?? readFilesByGlobs(getProjectSettings().allFeatureFileGlobs); + const testsIterable: SourceIterable = + options.tests ?? readFilesByGlobs(getProjectSettings().allTestFileGlobs); + + const codeReport: CodeReport = { + features: Object.create(null) as {}, + invalidFeatures: Object.create(null) as {}, + invalidTests: Object.create(null) as {}, + scenarios: Object.create(null) as {}, + tests: Object.create(null) as {}, + }; + + await Promise.all([ + processFeatures(codeReport, featuresIterable), + processTests(codeReport, testsIterable), + ]); + + fillReport(codeReport); + + return codeReport; +}; diff --git a/src/utils/parse/codeReport/index.ts b/src/utils/parse/codeReport/index.ts new file mode 100644 index 00000000..fc0e7155 --- /dev/null +++ b/src/utils/parse/codeReport/index.ts @@ -0,0 +1 @@ +export {getCodeReport} from './getCodeReport'; diff --git a/src/utils/parse/codeReport/processFeatures.ts b/src/utils/parse/codeReport/processFeatures.ts new file mode 100644 index 00000000..815f91c8 --- /dev/null +++ b/src/utils/parse/codeReport/processFeatures.ts @@ -0,0 +1,68 @@ +import {parseGherkin} from 'parse-gherkin'; + +import {processScenarios} from './processScenarios'; + +import type { + CodeReport, + FeatureReport, + Mutable, + SourceFile, + SourceIterable, + SourcePath, +} from '../../../types/internal'; + +/** + * Process features files. + * @internal + */ +export const processFeatures = async ( + codeReport: CodeReport, + featuresIterable: SourceIterable, +): Promise => { + const {invalidFeatures, features} = codeReport; + + const process = ({path, source}: SourceFile): void => { + if (path in features || path in invalidFeatures) { + throw new Error(`There is more than one feature with the "${path}" path`); + } + + try { + const parsed = parseGherkin(source); + const scenariosPaths: SourcePath[] = []; + const featureReport: Mutable = { + ...parsed, + path: path as SourcePath, + scenariosPaths, + }; + + (features as Mutable)[path as SourcePath] = featureReport; + + const maybeScenarios = parsed.scenarios?.filter( + (maybeScenario) => 'Scenario' in maybeScenario, + ); + + if (maybeScenarios === undefined || maybeScenarios.length === 0) { + return; + } + + const paths = processScenarios(path as SourcePath, codeReport, maybeScenarios); + + scenariosPaths.push(...paths); + } catch (error) { + (invalidFeatures as Mutable)[path as SourcePath] = { + error: error as Error, + source, + }; + } + }; + + if (Symbol.asyncIterator in featuresIterable) { + for await (const file of featuresIterable) { + process(file); + } + } else { + for (const file of featuresIterable) { + process(file); + } + } +}; diff --git a/src/utils/parse/codeReport/processScenarios.ts b/src/utils/parse/codeReport/processScenarios.ts new file mode 100644 index 00000000..cb03d006 --- /dev/null +++ b/src/utils/parse/codeReport/processScenarios.ts @@ -0,0 +1,72 @@ +// eslint-disable-next-line import/no-internal-modules +import {getTestIdentifierKey} from '../../../configurator/getTestIdentifierKey'; + +// eslint-disable-next-line import/no-internal-modules +import {getProjectSettings} from '../../userland/getProjectSettings'; + +import type {Scenario} from 'parse-gherkin'; + +import type {CodeReport, Mutable, ScenarioReport, SourcePath} from '../../../types/internal'; + +/** + * Process scenarios from features files. + * @internal + */ +export const processScenarios = ( + featurePath: SourcePath, + codeReport: CodeReport, + scenarios: readonly Scenario[], +): readonly SourcePath[] => { + const {scenarios: reportScenarios} = codeReport; + const paths: SourcePath[] = []; + const projectSettings = getProjectSettings(); + const testIdentifierKey = getTestIdentifierKey(projectSettings); + const testIdTagStart = `@${testIdentifierKey}-`; + + for (let index = 0; index < scenarios.length; index += 1) { + const scenario = scenarios[index]; + + if (scenario === undefined) { + throw new Error(`Scenario is undefined in feature with the "${featurePath}" path`); + } + + const path = `${featurePath}/[${index}]` as SourcePath; + + paths.push(path); + + const errors: string[] = []; + const scenarioReport: Mutable = { + ...scenario, + duplicatesByTestIdentifier: [], + errors, + featurePath, + path, + testIdentifier: undefined, + [testIdentifierKey]: undefined, + testPath: undefined, + }; + + (reportScenarios as Mutable)[path] = scenarioReport; + + let testId: string | undefined; + + for (const tag of scenario.tags) { + if (!tag.startsWith(testIdTagStart)) { + continue; + } + + if (testId === undefined) { + testId = tag.slice(testIdTagStart.length); + } else { + errors.push(`Scenario has a duplicate test identifier tag: ${tag}`); + } + } + + if (testId !== undefined) { + scenarioReport.testIdentifier = testId; + scenarioReport[testIdentifierKey as 'testIdentifier'] = testId; + } + } + + return paths; +}; diff --git a/src/utils/parse/codeReport/processTests.ts b/src/utils/parse/codeReport/processTests.ts new file mode 100644 index 00000000..8ac625b2 --- /dev/null +++ b/src/utils/parse/codeReport/processTests.ts @@ -0,0 +1,73 @@ +// eslint-disable-next-line import/no-internal-modules +import {getTestIdentifierKey} from '../../../configurator/getTestIdentifierKey'; + +// eslint-disable-next-line import/no-internal-modules +import {getProjectSettings} from '../../userland/getProjectSettings'; + +import {parseTest} from '../parseTest'; + +import type { + CodeReport, + Mutable, + SourceFile, + SourceIterable, + SourcePath, + TestReport, +} from '../../../types/internal'; + +/** + * Process tests files. + * @internal + */ +export const processTests = async ( + codeReport: CodeReport, + testsIterable: SourceIterable, +): Promise => { + const {invalidTests, tests} = codeReport; + const projectSettings = getProjectSettings(); + const testIdentifierKey = getTestIdentifierKey(projectSettings); + + const process = ({path, source}: SourceFile): void => { + if (path in tests || path in invalidTests) { + throw new Error(`There is more than one test with the "${path}" path`); + } + + try { + const parsed = parseTest(source); + const errors: string[] = []; + const testReport: Mutable = { + ...parsed, + duplicatesByTestIdentifier: [], + errors, + path: path as SourcePath, + scenarioPath: undefined, + testIdentifier: undefined, + [testIdentifierKey]: undefined, + }; + + (tests as Mutable)[path as SourcePath] = testReport; + + const testId: unknown = parsed.options?.['meta']?.[testIdentifierKey as never]; + + if (testId !== undefined) { + testReport.testIdentifier = String(testId); + testReport[testIdentifierKey as 'testIdentifier'] = String(testId); + } + } catch (error) { + (invalidTests as Mutable)[path as SourcePath] = { + error: error as Error, + source, + }; + } + }; + + if (Symbol.asyncIterator in testsIterable) { + for await (const file of testsIterable) { + process(file); + } + } else { + for (const file of testsIterable) { + process(file); + } + } +}; diff --git a/src/utils/parse/index.ts b/src/utils/parse/index.ts index 20ed79b2..c47ea5d5 100644 --- a/src/utils/parse/index.ts +++ b/src/utils/parse/index.ts @@ -1,3 +1,4 @@ +export {getCodeReport} from './codeReport'; export {parseMaybeEmptyValueAsJson} from './parseMaybeEmptyValueAsJson'; export {getLinesIndexes, parseTest, ParseTestError} from './parseTest'; export {parseValueAsJsonIfNeeded} from './parseValueAsJsonIfNeeded'; diff --git a/src/utils/require.ts b/src/utils/require.ts new file mode 100644 index 00000000..73dcf24a --- /dev/null +++ b/src/utils/require.ts @@ -0,0 +1,15 @@ +/* eslint-disable global-require */ + +/** + * Requires `@playwright/test` (for lazy loading). + * @internal + */ +export const requirePlaywright = (): typeof import('@playwright/test') => + require('@playwright/test'); + +/** + * Requires `typescript` (for lazy loading). + * @internal + */ +export const requireTypescript = (): typeof import('typescript') => + require('typescript'); diff --git a/src/utils/step/runStepBody.ts b/src/utils/step/runStepBody.ts index cacef95f..f82cbfaf 100644 --- a/src/utils/step/runStepBody.ts +++ b/src/utils/step/runStepBody.ts @@ -4,6 +4,7 @@ import {getTestIdleTimeout} from '../../context/testIdleTimeout'; import {E2edError} from '../error'; import {getDurationWithUnits} from '../getDurationWithUnits'; import {addTimeoutToPromise} from '../promise'; +import {requirePlaywright} from '../require'; import type { LogEvent, @@ -67,8 +68,7 @@ export const runStepBody = async ({ }); if (stepOptions?.runPlaywrightStep === true) { - // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires - const playwrightTest = (require('@playwright/test') as typeof import('@playwright/test')).test; + const {test: playwrightTest} = requirePlaywright(); await playwrightTest.step(name, () => runBodyWithTimeout()); } else { diff --git a/src/utils/userland/runArrayOfUserlandFunctions.ts b/src/utils/userland/runArrayOfUserlandFunctions.ts index 3af11338..91edee81 100644 --- a/src/utils/userland/runArrayOfUserlandFunctions.ts +++ b/src/utils/userland/runArrayOfUserlandFunctions.ts @@ -1,4 +1,3 @@ -import {E2edError} from '../error'; import {getDurationWithUnits} from '../getDurationWithUnits'; import type {Fn, UtcTimeInMs} from '../../types/internal'; @@ -24,6 +23,9 @@ export const runArrayOfUserlandFunctions = async ('../error'); + throw new E2edError('Caught an error on running userland function', {args, cause, fn}); } } diff --git a/src/utils/userland/userlandHooks.ts b/src/utils/userland/userlandHooks.ts index d5b358cd..21e488af 100644 --- a/src/utils/userland/userlandHooks.ts +++ b/src/utils/userland/userlandHooks.ts @@ -1,5 +1,3 @@ -import {assertValueIsDefined, assertValueIsUndefined} from '../asserts'; - import type {UserlandHooks} from '../../types/internal'; let userlandHooks: UserlandHooks | undefined; @@ -9,6 +7,11 @@ let userlandHooks: UserlandHooks | undefined; * @internal */ export const getUserlandHooks = (): UserlandHooks => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const asserts = require('../asserts'); + + const assertValueIsDefined: typeof asserts.assertValueIsDefined = asserts.assertValueIsDefined; + assertValueIsDefined(userlandHooks, 'userlandHooks is defined'); return userlandHooks; @@ -19,6 +22,13 @@ export const getUserlandHooks = (): UserlandHooks => { * @internal */ export const setUserlandHooks = (hooks: UserlandHooks): void => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const asserts = require('../asserts'); + + const assertValueIsDefined: typeof asserts.assertValueIsDefined = asserts.assertValueIsDefined; + const assertValueIsUndefined: typeof asserts.assertValueIsUndefined = + asserts.assertValueIsUndefined; + assertValueIsUndefined(userlandHooks, 'userlandHooks is not defined', {hooks}); assertValueIsDefined(hooks, 'hooks is defined', {userlandHooks}); diff --git a/src/utils/viewport/isSelectorEntirelyInViewport.ts b/src/utils/viewport/isSelectorEntirelyInViewport.ts index 9560eb3f..0df4f655 100644 --- a/src/utils/viewport/isSelectorEntirelyInViewport.ts +++ b/src/utils/viewport/isSelectorEntirelyInViewport.ts @@ -1,3 +1,5 @@ +import {requirePlaywright} from '../require'; + import type {Selector} from '../../types/internal'; /** @@ -6,9 +8,7 @@ import type {Selector} from '../../types/internal'; */ export const isSelectorEntirelyInViewport = async (selector: Selector): Promise => { try { - // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires - const playwrightExpect = (require('@playwright/test') as typeof import('@playwright/test')) - .expect; + const {expect: playwrightExpect} = requirePlaywright(); await playwrightExpect(selector.getPlaywrightLocator()).toBeInViewport({ ratio: 1, diff --git a/src/utils/viewport/isSelectorInViewport.ts b/src/utils/viewport/isSelectorInViewport.ts index a136c8cf..717e45cc 100644 --- a/src/utils/viewport/isSelectorInViewport.ts +++ b/src/utils/viewport/isSelectorInViewport.ts @@ -1,3 +1,5 @@ +import {requirePlaywright} from '../require'; + import type {Selector} from '../../types/internal'; /** @@ -6,9 +8,7 @@ import type {Selector} from '../../types/internal'; */ export const isSelectorInViewport = async (selector: Selector): Promise => { try { - // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires - const playwrightExpect = (require('@playwright/test') as typeof import('@playwright/test')) - .expect; + const {expect: playwrightExpect} = requirePlaywright(); await playwrightExpect(selector.getPlaywrightLocator()).toBeInViewport({timeout: 1}); diff --git a/tsconfig.json b/tsconfig.json index 56eb16aa..e7b5cff9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,7 +31,7 @@ "skipLibCheck": false, "strict": true, "stripInternal": true, - "target": "ES2024", + "target": "ES2025", "types": ["node"], "useDefineForClassFields": true },