diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9de0273e..81548f0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,8 +8,8 @@ jobs: build-docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: cache: npm node-version: 24.14.0 @@ -20,8 +20,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: cache: npm node-version: 24.14.0 @@ -32,15 +32,15 @@ jobs: test-local: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: cache: npm node-version: 24.14.0 - run: npm ci --force - run: npm run build - run: npm run test:local - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v7 if: ${{ always() }} with: name: reports diff --git a/README.md b/README.md index 9fa04f42..e1393a12 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ which in this case will be included in the start info object, and will be used f Each function can thus access the results of the previous function. `enableCsp: boolean`: enables Content-Security-Policy checks in browser. +This parameter can be overridden in the test-specific options. `enableHeadlessMode: boolean`: enables headless mode (if browser supports such mode). @@ -366,9 +367,11 @@ You can define the `SkipTests` type and `skipTests` processing rules in the hook `takeFullPageScreenshotOnError: boolean`: if `true`, then takes a screenshot of the full page (not just the viewport) at the time of the test error, for display in the HTML report. +This parameter can be overridden in the test-specific options. `takeViewportScreenshotOnError: boolean`: if `true`, then takes a screenshot of the page viewport at the time of the test error, for display in the HTML report. +This parameter can be overridden in the test-specific options. `testFileGlobs: readonly string[]`: an array of globs with pack test (task) files. `fs.glob` from `nodejs` is used for matching globs. @@ -383,10 +386,13 @@ If the test run takes longer than this timeout, the test fails and rerun on the This parameter can be overridden in the test-specific options. `userAgent: string`: `userAgent` string of browser (device) in tests. +This parameter can be overridden in the test-specific options. `viewportHeight: number`: height of viewport of page in pixels. +This parameter can be overridden in the test-specific options. `viewportWidth: number`: width of viewport of page in pixels. +This parameter can be overridden in the test-specific options. `waitBeforeRetry: (options: Options) => number`: returns how many milliseconds `e2ed` should wait before running test (for retries). @@ -412,17 +418,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/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/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/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/entities/worker.ts b/autotests/entities/worker.ts index a317a231..2f73451b 100644 --- a/autotests/entities/worker.ts +++ b/autotests/entities/worker.ts @@ -47,7 +47,7 @@ export const getUsers = ({delay = 0, retries = 0}: GetUsersOptions = {}): Promis fetch(`https://dummyjson.com/users?delay=${clientDelay}`, {method: 'GET'}).then( (res) => res.json() as unknown, ), - {name: 'getUsers', retries, timeout: 6_000}, + {name: 'getUsers', retries, timeout: 30_000}, ); } diff --git a/autotests/packs/allTests.ts b/autotests/packs/allTests.ts index 225b2675..156b2993 100644 --- a/autotests/packs/allTests.ts +++ b/autotests/packs/allTests.ts @@ -33,7 +33,7 @@ const browserFlags = [ const filterTestsIntoPack: FilterTestsIntoPack = ({options}) => options.meta.testId !== '13'; const userAgent = - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'; + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36'; const msInMinute = 60_000; const packTimeoutInMinutes = 5; @@ -82,7 +82,7 @@ export const pack: Pack = { skipTests, takeFullPageScreenshotOnError: false, takeViewportScreenshotOnError: true, - testFileGlobs: ['**/autotests/tests/**/*.ts'], + testFileGlobs: ['autotests/tests/**/*.ts'], testIdleTimeout: 8_000, testTimeout: 15_000, userAgent, diff --git a/autotests/projectSettings.json b/autotests/projectSettings.json new file mode 100644 index 00000000..22cd9a1f --- /dev/null +++ b/autotests/projectSettings.json @@ -0,0 +1,7 @@ +{ + "allFeatureFileGlobs": ["autotests/specs/**/*.feature"], + "allTestFileGlobs": ["autotests/tests/**/*.ts"], + "dockerImage": "e2edhub/e2ed", + "pathToTsConfigFromRoot": "./tsconfig.json", + "testIdentifierKey": {"testId": "key of the test identifier in test meta"} +} diff --git a/autotests/specs/codeReport.feature b/autotests/specs/codeReport.feature new file mode 100644 index 00000000..66bd80b0 --- /dev/null +++ b/autotests/specs/codeReport.feature @@ -0,0 +1,29 @@ +Feature: Code report + + Scenario: Scenario without test identifier + Given base state + When action + Then result + + @testId-901 + Scenario: Scenario with test identifier and without test + Given base state + When action + Then result + And more + But not that + * anything + + @testId-902 @testId-903 + Scenario: Scenario with two test identifier tags + Given base state + + @testId-904 + Scenario: Scenario without steps + + @testId-905 + Scenario: Scenario with empty step + Given + + @testId-36 + Scenario: Scenario without steps linked to test diff --git a/autotests/tests/codeReport.ts b/autotests/tests/codeReport.ts new file mode 100644 index 00000000..d095d64b --- /dev/null +++ b/autotests/tests/codeReport.ts @@ -0,0 +1,939 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, max-lines */ + +import {test} from 'autotests'; +import {expect} from 'e2ed'; +import {assertValueIsDefined} from 'e2ed/utils'; +import {getCodeReport} from 'e2ed/utils/parse'; + +import type { + CodeReport, + FeatureReport, + ParseError, + ScenarioReport, + SourceFile, + SourcePath, + TestReport, +} from 'e2ed/types'; + +const getFeature = (codeReport: CodeReport, path: string): FeatureReport => { + const feature = Object.values(codeReport.features).find( + (featureReport) => featureReport.path === path, + ); + + assertValueIsDefined(feature, 'feature is defined', {path}); + + return feature; +}; + +const getParseError = (parseErrors: CodeReport['invalidFeatures'], path: string): ParseError => { + const parseError = Object.entries(parseErrors).find(([somePath]) => somePath === path)?.[1]; + + assertValueIsDefined(parseError, 'parseError is defined', {path}); + + return parseError; +}; + +const getScenario = ( + codeReport: CodeReport, + path: string, +): ScenarioReport => { + const scenario = Object.values(codeReport.scenarios).find( + (scenarioReport) => scenarioReport.path === path, + ); + + assertValueIsDefined(scenario, 'scenario is defined', {path}); + + return scenario; +}; + +const getTestReport = ( + codeReport: CodeReport, + path: string, +): TestReport => { + const testReport = Object.values(codeReport.tests).find( + (someTestReport) => someTestReport.path === path, + ); + + assertValueIsDefined(testReport, 'testReport is defined', {path}); + + return testReport; +}; + +async function* toAsyncIterable(files: readonly SourceFile[]): AsyncGenerator { + for (const file of files) { + yield await Promise.resolve(file); + } +} + +// eslint-disable-next-line max-lines-per-function, max-statements +test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, async () => { + const projectFeaturePath = 'autotests/specs/codeReport.feature' as SourcePath; + const selfTestPath = 'autotests/tests/codeReport.ts' as SourcePath; + + const projectReport = await getCodeReport<'testId'>(); + + await expect(projectReport.durationInMs, 'Code report has non-negative duration').gte(0); + + await expect(Object.keys(projectReport.invalidFeatures), 'Project has no invalid features').eql( + [], + ); + + await expect(Object.keys(projectReport.invalidTests), 'Project has no invalid tests').eql([]); + + const projectFeature = getFeature(projectReport, projectFeaturePath); + + await expect(projectFeature.name, 'Feature name is parsed correctly').eql('Code report'); + + await expect( + projectFeature.scenariosPaths.map(String), + 'Feature report contains paths of all scenarios', + ).eql([0, 1, 2, 3, 4, 5].map((index) => `${projectFeaturePath}/[${index}]`)); + + await expect( + !('scenarios' in projectFeature), + 'Feature report has no scenarios field at runtime', + ).ok(); + + const scenarioWithoutId = getScenario(projectReport, `${projectFeaturePath}/[0]`); + + await expect(scenarioWithoutId.name, 'Scenario name is parsed correctly').eql( + 'Scenario without test identifier', + ); + + await expect(scenarioWithoutId.testIdentifier, 'Scenario without tag has no test identifier').eql( + undefined, + ); + + await expect(scenarioWithoutId.testId, 'Scenario without tag has no testId field').eql(undefined); + + await expect(scenarioWithoutId.testPath, 'Scenario without tag is not linked to test').eql( + undefined, + ); + + await expect(scenarioWithoutId.errors, 'Scenario without tag has no errors').eql([]); + + await expect( + scenarioWithoutId.steps.map(({kind, definition}) => `${kind}:${definition}`), + 'Scenario steps are parsed with kinds and definitions', + ).eql(['Given:base state', 'When:action', 'Then:result']); + + const scenarioWithoutTest = getScenario(projectReport, `${projectFeaturePath}/[1]`); + + await expect( + scenarioWithoutTest.testIdentifier, + 'Scenario test identifier is read from the tag', + ).eql('901'); + + await expect( + scenarioWithoutTest.testId, + 'Scenario test identifier is duplicated in testId field', + ).eql('901'); + + await expect(scenarioWithoutTest.featurePath, 'Scenario has reference to feature path').eql( + projectFeaturePath, + ); + + await expect( + scenarioWithoutTest.steps.map(({kind}) => kind), + 'All scenario step kinds are parsed', + ).eql(['Given', 'When', 'Then', 'And', 'But', '*']); + + await expect(scenarioWithoutTest.testPath, 'Scenario without matching test is not linked').eql( + undefined, + ); + + await expect( + projectReport.scenariosByTestIdentifier['901'], + 'Scenario with test identifier is present in scenarios map', + ).eql(`${projectFeaturePath}/[1]` as SourcePath); + + const scenarioWithTwoTags = getScenario(projectReport, `${projectFeaturePath}/[2]`); + + await expect( + scenarioWithTwoTags.testIdentifier, + 'First test identifier tag wins for scenario with two tags', + ).eql('902'); + + await expect( + scenarioWithTwoTags.errors, + 'Second test identifier tag produces scenario error', + ).eql(['Scenario has a duplicate test identifier tag: "@testId-903".']); + + const scenarioWithoutSteps = getScenario(projectReport, `${projectFeaturePath}/[3]`); + + await expect(scenarioWithoutSteps.steps, 'Scenario without steps has empty steps').eql([]); + + await expect(scenarioWithoutSteps.errors, 'Unlinked scenario without steps has no errors').eql( + [], + ); + + const scenarioWithEmptyStep = getScenario(projectReport, `${projectFeaturePath}/[4]`); + + await expect( + scenarioWithEmptyStep.steps.map(({kind, definition}) => `${kind}:${definition}`), + 'Empty step definition is parsed as empty string', + ).eql(['Given:']); + + await expect(scenarioWithEmptyStep.errors, 'Unlinked scenario with empty step has no errors').eql( + [], + ); + + const linkedScenario = getScenario(projectReport, `${projectFeaturePath}/[5]`); + const selfTest = getTestReport(projectReport, selfTestPath); + + await expect(linkedScenario.name, 'Linked scenario has correct name').eql( + 'Scenario without steps linked to test', + ); + + await expect(linkedScenario.testIdentifier, 'Linked scenario has test identifier').eql('36'); + + await expect(linkedScenario.testPath, 'Linked scenario points to this test').eql(selfTestPath); + + await expect(selfTest.scenarioPath, 'This test points to linked scenario').eql( + `${projectFeaturePath}/[5]` as SourcePath, + ); + + await expect(selfTest.name, 'Test name is parsed correctly').eql( + 'getCodeReport(...) function works correctly', + ); + + await expect(selfTest.testIdentifier, 'Test identifier is read from test options').eql('36'); + + await expect(selfTest.testId, 'Test identifier is duplicated in testId field').eql('36'); + + await expect(selfTest.featurePath, 'Linked test points to feature file').eql(projectFeaturePath); + + await expect(selfTest.errors, 'Linked scenario without steps produces error on test').eql([ + `The scenario "Scenario without steps linked to test" in ${projectFeaturePath}:29:3 has no steps.`, + ]); + + await expect(projectReport.testsByTestIdentifier['36'], 'This test is present in tests map').eql( + selfTestPath, + ); + + await expect( + projectReport.testsByTestIdentifier['25'], + 'Other tests are present in tests map', + ).eql('autotests/tests/parseTest.ts' as SourcePath); + + await expect( + getTestReport(projectReport, 'autotests/tests/parseTest.ts').scenarioPath, + 'Test without matching scenario is not linked', + ).eql(undefined); + + await expect( + getTestReport(projectReport, 'autotests/tests/parseTest.ts').featurePath, + 'Test without matching scenario has no feature path', + ).eql(undefined); + + const allProjectTests = Object.values(projectReport.tests); + + await expect(allProjectTests.length, 'Project has tests').gt(0); + + await expect( + allProjectTests + .filter(({testIdentifier}) => testIdentifier === undefined) + .map(({path}) => path), + 'All project tests have test identifier', + ).eql([]); + + await expect( + allProjectTests + .filter(({duplicatesByTestIdentifier}) => duplicatesByTestIdentifier.length > 0) + .map(({path}) => path), + 'Project has no tests with duplicate test identifiers', + ).eql([]); + + await expect( + Object.keys(projectReport.testsByTestIdentifier).length, + 'All project test identifiers are unique', + ).eql(allProjectTests.length); + + await expect( + allProjectTests.filter(({errors}) => errors.length > 0).map(({path}) => String(path)), + 'Only this test has errors from linked scenario', + ).eql([selfTestPath]); + + const comparisonReport = await getCodeReport({ + features: [ + { + path: 'f.feature', + source: [ + 'Feature: F', + '', + ' @testId-101', + ' Scenario: Bad scenario', + ' Given a', + ' When b', + ' Then c', + '', + ].join('\n'), + }, + ], + tests: [ + { + path: 't.ts', + source: [ + "test('Bad', {meta: {testId: '101'}}, async () => {", + " await When('b');", + " await Given('a');", + " await Then('d');", + '});', + ].join('\n'), + }, + ], + }); + + const badScenario = getScenario(comparisonReport, 'f.feature/[0]'); + const badTest = getTestReport(comparisonReport, 't.ts'); + + await expect(getFeature(comparisonReport, 'f.feature').name, 'Feature name is parsed').eql('F'); + + await expect(badScenario.name, 'Scenario name is parsed').eql('Bad scenario'); + + await expect(badScenario.testPath, 'Scenario is linked to test by test identifier').eql( + 't.ts' as SourcePath, + ); + + await expect(badTest.scenarioPath, 'Test is linked to scenario by test identifier').eql( + 'f.feature/[0]' as SourcePath, + ); + + await expect(badTest.featurePath, 'Linked test points to feature file of its scenario').eql( + 'f.feature' as SourcePath, + ); + + await expect( + comparisonReport.scenariosByTestIdentifier['101'], + 'Scenario is present in scenarios map', + ).eql('f.feature/[0]' as SourcePath); + + await expect(comparisonReport.testsByTestIdentifier['101'], 'Test is present in tests map').eql( + 't.ts' as SourcePath, + ); + + await expect(badScenario.errors, 'Scenario itself has no comparison errors').eql([]); + + await expect(badTest.errors, 'Missing, extra and reordered steps produce exact errors').eql([ + 'Step "Then c" in f.feature:7:5 (in scenario "Bad scenario" in f.feature:4:3) is missing from test "Bad" in t.ts:1:1.', + 'The test "Bad" in t.ts:1:1 has an extra step "Then d" in t.ts:4:3 that is absent from scenario "Bad scenario" in f.feature:4:3.', + [ + 'The following steps appear in a different order in the scenario and the test.', + 'In the scenario the order is:', + '"Given a" in f.feature:5:5 (in scenario "Bad scenario" in f.feature:4:3),', + '"When b" in f.feature:6:5 (in scenario "Bad scenario" in f.feature:4:3).', + 'In the test the order is:', + '"When b" in t.ts:2:3,', + '"Given a" in t.ts:3:3.', + ].join('\n'), + ]); + + const orderReport = await getCodeReport({ + features: [ + { + path: 'order.feature', + source: [ + 'Feature: F', + '', + ' @testId-106', + ' Scenario: Order scenario', + ' Given A', + ' When B', + ' Then C', + ' And D', + '', + ].join('\n'), + }, + ], + tests: [ + { + path: 'order.ts', + source: [ + "test('Order', {meta: {testId: '106'}}, async () => {", + " await Then('C');", + " await When('B');", + " await And('D');", + " await Given('A');", + '});', + ].join('\n'), + }, + ], + }); + + const orderTest = getTestReport(orderReport, 'order.ts'); + + await expect(orderTest.errors, 'Steps at matching positions are excluded from order error').eql([ + [ + 'The following steps appear in a different order in the scenario and the test.', + 'In the scenario the order is:', + '"Given A" in order.feature:5:5 (in scenario "Order scenario" in order.feature:4:3),', + '"Then C" in order.feature:7:5 (in scenario "Order scenario" in order.feature:4:3),', + '"And D" in order.feature:8:5 (in scenario "Order scenario" in order.feature:4:3).', + 'In the test the order is:', + '"Then C" in order.ts:2:3,', + '"And D" in order.ts:4:3,', + '"Given A" in order.ts:5:3.', + ].join('\n'), + ]); + + await expect( + orderTest.errors[0] ?? '', + 'Step at the same position in scenario and test is not mentioned in order error', + ).notContains('When B'); + + const fullFeatureFiles: readonly SourceFile[] = [ + { + path: 'full.feature', + source: [ + 'Feature: Full', + '', + ' @testId-102', + ' Scenario: Full scenario', + ' Given a', + ' When b', + ' Then c', + ' And d', + ' But e', + ' * f', + ' Then c', + '', + ].join('\n'), + }, + ]; + const fullTestFiles: readonly SourceFile[] = [ + { + path: 'full.ts', + source: [ + "test('Full', {meta: {testId: '102'}}, async () => {", + " await Given('a');", + " await When('b');", + " await Then('c');", + " await And('d');", + " await But('e');", + " await Star('f');", + " await Then('c');", + '});', + ].join('\n'), + }, + ]; + + const fullReport = await getCodeReport({features: fullFeatureFiles, tests: fullTestFiles}); + + const fullTest = getTestReport(fullReport, 'full.ts'); + + await expect(fullTest.errors, 'Fully matching test has no errors').eql([]); + + await expect( + fullTest.steps.map(({kind, definition}) => `${kind}:${definition}`), + 'All test step kinds are parsed, including Star and duplicated steps', + ).eql(['Given:a', 'When:b', 'Then:c', 'And:d', 'But:e', '*:f', 'Then:c']); + + await expect( + getScenario(fullReport, 'full.feature/[0]').testPath, + 'Fully matching scenario is linked to test', + ).eql('full.ts' as SourcePath); + + const asyncReport = await getCodeReport({ + features: toAsyncIterable(fullFeatureFiles), + tests: toAsyncIterable(fullTestFiles), + }); + + await expect( + getTestReport(asyncReport, 'full.ts').errors, + 'Async iterables of features and tests are supported', + ).eql([]); + + await expect( + getTestReport(asyncReport, 'full.ts').scenarioPath, + 'Links are filled for async iterables', + ).eql('full.feature/[0]' as SourcePath); + + const countReport = await getCodeReport({ + features: [ + { + path: 'count.feature', + source: ['Feature: F', '', ' @testId-103', ' Scenario: S', ' Then c', ''].join('\n'), + }, + ], + tests: [ + { + path: 'count.ts', + source: [ + "test('T', {meta: {testId: '103'}}, async () => {", + " await Then('c');", + " await Then('c');", + '});', + ].join('\n'), + }, + ], + }); + + const countTest = getTestReport(countReport, 'count.ts'); + + await expect(countTest.errors.length, 'Duplicated test step produces exactly one error').eql(1); + + await expect( + countTest.errors[0] ?? '', + 'Error on duplicated step mentions the duplicate count', + ).contains('"Then c" (occurrence 2)'); + + await expect(countTest.errors[0] ?? '', 'Duplicated test step is reported as extra').contains( + 'has an extra step', + ); + + const tripleReport = await getCodeReport({ + features: [ + { + path: 'triple.feature', + source: [ + 'Feature: F', + '', + ' @testId-107', + ' Scenario: S', + ' Then c', + ' Then c', + '', + ].join('\n'), + }, + ], + tests: [ + { + path: 'triple.ts', + source: [ + "test('T', {meta: {testId: '107'}}, async () => {", + " await Then('c');", + " await Then('c');", + " await Then('c');", + '});', + ].join('\n'), + }, + ], + }); + + const tripleTest = getTestReport(tripleReport, 'triple.ts'); + + await expect( + tripleTest.errors, + 'Third duplicate of the step is reported as extra with (occurrence 3) count', + ).eql([ + 'The test "T" in triple.ts:1:1 has an extra step "Then c" (occurrence 3) in triple.ts:4:3 that is absent from scenario "S" in triple.feature:4:3.', + ]); + + const emptyStepReport = await getCodeReport({ + features: [ + { + path: 'empty.feature', + source: [ + 'Feature: F', + '', + ' @testId-104', + ' Scenario: S', + ' Given a', + ' Then b', + '', + ].join('\n'), + }, + ], + tests: [ + { + path: 'empty.ts', + source: [ + "test('T', {meta: {testId: '104'}}, async () => {", + " await Given('a');", + " await Then('');", + ' await When(callback);', + '});', + ].join('\n'), + }, + ], + }); + + const emptyStepTest = getTestReport(emptyStepReport, 'empty.ts'); + + await expect( + emptyStepTest.steps.map(({kind, definition}) => `${kind}:${String(definition)}`), + 'Empty and non-string test step definitions are parsed', + ).eql(['Given:a', 'Then:', 'When:undefined']); + + await expect( + emptyStepTest.errors, + 'Test steps without definition produce own errors and are excluded from comparison', + ).eql([ + 'Step in empty.ts:3:3 (in test "T" in empty.ts:1:1) has no definition.', + 'Step in empty.ts:4:3 (in test "T" in empty.ts:1:1) has no definition.', + 'Step "Then b" in empty.feature:6:5 (in scenario "S" in empty.feature:4:3) is missing from test "T" in empty.ts:1:1.', + ]); + + await expect( + emptyStepTest.errors.some((error) => error.includes('extra step')), + 'Test steps without definition are not reported as extra steps', + ).notOk(); + + const duplicateTestsReport = await getCodeReport({ + features: [ + { + path: 'dup42.feature', + source: ['Feature: F', '', ' @testId-42', ' Scenario: S', ' Given g', ''].join('\n'), + }, + ], + tests: [ + { + path: 'dupA.ts', + source: [ + "test('A', {meta: {testId: '42'}}, async () => {", + " await Given('g');", + '});', + ].join('\n'), + }, + {path: 'dupB.ts', source: "test('B', {meta: {testId: '42'}}, async () => {});"}, + ], + }); + + const duplicateTestA = getTestReport(duplicateTestsReport, 'dupA.ts'); + const duplicateTestB = getTestReport(duplicateTestsReport, 'dupB.ts'); + + await expect( + duplicateTestA.duplicatesByTestIdentifier.map(String), + 'First duplicate test points to the second one', + ).eql(['dupB.ts']); + + await expect( + duplicateTestB.duplicatesByTestIdentifier.map(String), + 'Second duplicate test points to the first one', + ).eql(['dupA.ts']); + + await expect( + duplicateTestsReport.testsByTestIdentifier['42'], + 'Duplicate tests are excluded from tests map', + ).eql(undefined); + + await expect( + getScenario(duplicateTestsReport, 'dup42.feature/[0]').testPath, + 'Scenario is not linked to duplicate tests', + ).eql(undefined); + + await expect( + duplicateTestsReport.scenariosByTestIdentifier['42'], + 'Scenario itself is present in scenarios map', + ).eql('dup42.feature/[0]' as SourcePath); + + await expect(duplicateTestA.scenarioPath, 'Duplicate test is not linked').eql(undefined); + + await expect(duplicateTestA.featurePath, 'Duplicate test has no feature path').eql(undefined); + + await expect(duplicateTestA.errors, 'Duplicate tests are not compared with scenario').eql([]); + + await expect(duplicateTestB.errors, 'Second duplicate test also has no errors').eql([]); + + const duplicateScenariosReport = await getCodeReport({ + features: [ + { + path: 'dup43.feature', + source: [ + 'Feature: F', + '', + ' @testId-43', + ' Scenario: First', + ' Given g', + '', + ' @testId-43', + ' Scenario: Second', + ' Given g', + '', + ].join('\n'), + }, + ], + tests: [ + { + path: 'dup43.ts', + source: [ + "test('T', {meta: {testId: '43'}}, async () => {", + " await Given('g');", + '});', + ].join('\n'), + }, + ], + }); + + await expect( + getFeature(duplicateScenariosReport, 'dup43.feature').scenariosPaths.map(String), + 'Feature contains paths of both duplicate scenarios', + ).eql(['dup43.feature/[0]', 'dup43.feature/[1]']); + + await expect( + getScenario(duplicateScenariosReport, 'dup43.feature/[0]').duplicatesByTestIdentifier.map( + String, + ), + 'First duplicate scenario points to the second one', + ).eql(['dup43.feature/[1]']); + + await expect( + getScenario(duplicateScenariosReport, 'dup43.feature/[1]').duplicatesByTestIdentifier.map( + String, + ), + 'Second duplicate scenario points to the first one', + ).eql(['dup43.feature/[0]']); + + await expect( + duplicateScenariosReport.scenariosByTestIdentifier['43'], + 'Duplicate scenarios are excluded from scenarios map', + ).eql(undefined); + + const testWithDuplicateScenarios = getTestReport(duplicateScenariosReport, 'dup43.ts'); + + await expect( + testWithDuplicateScenarios.scenarioPath, + 'Test is not linked to duplicate scenarios', + ).eql(undefined); + + await expect( + testWithDuplicateScenarios.errors, + 'Test is not compared with duplicate scenarios', + ).eql([]); + + await expect( + duplicateScenariosReport.testsByTestIdentifier['43'], + 'Test itself is present in tests map', + ).eql('dup43.ts' as SourcePath); + + const invalidFeatureSource = 'Not a gherkin file at all'; + const invalidTestSource = ['const someConstant = 1;', ''].join('\n'); + + const invalidReport = await getCodeReport({ + features: [ + {path: 'bad.feature', source: invalidFeatureSource}, + { + path: 'good.feature', + source: ['Feature: Good', '', ' Scenario: S', ' Given g', ''].join('\n'), + }, + ], + tests: [ + {path: 'bad.ts', source: invalidTestSource}, + {path: 'good.ts', source: "test('Good', async () => {});"}, + ], + }); + + const featureParseError = getParseError(invalidReport.invalidFeatures, 'bad.feature'); + const testParseError = getParseError(invalidReport.invalidTests, 'bad.ts'); + + await expect(featureParseError.error instanceof Error, 'Invalid feature error is Error').ok(); + + await expect( + featureParseError.error.message, + 'Invalid feature error message mentions the problem', + ).contains('Feature'); + + await expect(featureParseError.source, 'Source is preserved for invalid feature').eql( + invalidFeatureSource, + ); + + await expect( + testParseError.error.message, + 'Invalid test error message mentions the problem', + ).contains('contains no tests'); + + await expect(testParseError.source, 'Source is preserved for invalid test').eql( + invalidTestSource, + ); + + await expect( + Object.keys(invalidReport.features), + 'Valid feature is parsed alongside invalid one', + ).eql(['good.feature']); + + const goodTest = getTestReport(invalidReport, 'good.ts'); + + await expect(goodTest.options, 'Test without options has undefined options').eql(undefined); + + await expect(goodTest.testIdentifier, 'Test without options has no test identifier').eql( + undefined, + ); + + await expect(goodTest.errors, 'Test without options has no errors').eql([]); + + const caseReport = await getCodeReport<'caseId'>({ + features: [ + { + path: 'case.feature', + source: [ + 'Feature: F', + '', + ' @testId-7 @caseId-9', + ' Scenario: S', + ' Given g', + '', + ].join('\n'), + }, + ], + testIdentifierKey: 'caseId', + tests: [ + { + path: 'case.ts', + source: [ + "test('T', {meta: {caseId: '9', testId: '7'}}, async () => {", + " await Given('g');", + '});', + ].join('\n'), + }, + ], + }); + + const caseScenario = getScenario(caseReport, 'case.feature/[0]'); + const caseTest = getTestReport(caseReport, 'case.ts'); + + await expect( + caseTest.testIdentifier, + 'Custom key: test identifier is read from caseId meta property', + ).eql('9'); + + await expect(caseTest.caseId, 'Custom key: identifier is duplicated in caseId field').eql('9'); + + await expect(caseScenario.testIdentifier, 'Custom key: @caseId-* tag is used').eql('9'); + + await expect(caseScenario.testPath, 'Custom key: scenario is linked to test').eql( + 'case.ts' as SourcePath, + ); + + await expect(caseTest.errors, 'Custom key: matching steps produce no errors').eql([]); + + await expect( + caseReport.testsByTestIdentifier['7'], + 'Custom key: testId meta property is ignored', + ).eql(undefined); + + await expect( + caseReport.testsByTestIdentifier['9'], + 'Custom key: test is present in tests map by caseId', + ).eql('case.ts' as SourcePath); + + const customStepsReport = await getCodeReport({ + features: [ + { + path: 'steps.feature', + source: [ + 'Feature: F', + '', + ' @testId-105', + ' Scenario: S', + ' Given a', + ' When b', + ' Then c', + '', + ].join('\n'), + }, + ], + steps: {Given: '^[ \t]*await Given\\(', When: '^[ \t]*await When\\('}, + tests: [ + { + path: 'steps.ts', + source: [ + "test('T', {meta: {testId: '105'}}, async () => {", + " await Given('a');", + " await When('b');", + " await Then('c');", + '});', + ].join('\n'), + }, + ], + }); + + const customStepsTest = getTestReport(customStepsReport, 'steps.ts'); + + await expect( + customStepsTest.steps.map(({kind}) => kind), + 'Only steps from custom step tokens are parsed', + ).eql(['Given', 'When']); + + await expect( + customStepsTest.errors.length, + 'Step not covered by custom tokens is missing from test', + ).eql(1); + + await expect(customStepsTest.errors[0] ?? '', 'Missing step error mentions the step').contains( + 'Step "Then c"', + ); + + await expect(customStepsTest.errors[0] ?? '', 'Missing step error has correct kind').contains( + 'is missing from', + ); + + const unknownReport = await getCodeReport({ + features: [], + tests: [ + {path: 'unknown.ts', source: "test('T', {meta: {testId: SomeExternalId}}, async () => {});"}, + ], + }); + + const unknownTest = getTestReport(unknownReport, 'unknown.ts'); + + await expect( + unknownTest.testIdentifier, + 'Unknown identifier in options becomes `` string', + ).eql(''); + + await expect( + unknownReport.testsByTestIdentifier[''], + 'Test with unknown identifier is present in tests map', + ).eql('unknown.ts' as SourcePath); + + const duplicatePathTest: SourceFile = { + path: 'same.ts', + source: "test('T', {meta: {testId: '1'}}, async () => {});", + }; + + try { + await getCodeReport({features: [], tests: [duplicatePathTest, duplicatePathTest]}); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof Error && + error.message.includes('more than one test with the "same.ts" path'), + 'Correctly throw on duplicate test path', + ).ok(); + } + + const duplicatePathFeature: SourceFile = { + path: 'same.feature', + source: ['Feature: F', '', ' Scenario: S', ' Given g', ''].join('\n'), + }; + + try { + await getCodeReport({features: [duplicatePathFeature, duplicatePathFeature], tests: []}); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof Error && + error.message.includes('more than one feature with the "same.feature" path'), + 'Correctly throw on duplicate feature path', + ).ok(); + } + + const emptyReport = await getCodeReport({features: [], tests: []}); + + await expect(Object.keys(emptyReport.features), 'Empty report has no features').eql([]); + + await expect( + Object.keys(emptyReport.invalidFeatures), + 'Empty report has no invalid features', + ).eql([]); + + await expect(Object.keys(emptyReport.invalidTests), 'Empty report has no invalid tests').eql([]); + + await expect(Object.keys(emptyReport.scenarios), 'Empty report has no scenarios').eql([]); + + await expect( + Object.keys(emptyReport.scenariosByTestIdentifier), + 'Empty report has empty scenarios map', + ).eql([]); + + await expect(Object.keys(emptyReport.tests), 'Empty report has no tests').eql([]); + + await expect( + Object.keys(emptyReport.testsByTestIdentifier), + 'Empty report has empty tests map', + ).eql([]); + + await expect(emptyReport.durationInMs, 'Empty report has non-negative duration').gte(0); +}); diff --git a/autotests/tests/parseTest.ts b/autotests/tests/parseTest.ts index 46311280..5bc7dcca 100644 --- a/autotests/tests/parseTest.ts +++ b/autotests/tests/parseTest.ts @@ -9,7 +9,7 @@ const Given: (definition: string) => Promise = async () => {}; const When: (definition?: string) => Promise = async () => {}; -const testsPattern = '**/autotests/tests/**/*.ts'; +const testsPattern = 'autotests/tests/**/*.ts'; // eslint-disable-next-line complexity, max-lines-per-function, max-statements test('parseTest(...) function works correctly', {meta: {testId: '25'}}, async () => { @@ -59,7 +59,7 @@ test('parseTest(...) function works correctly', {meta: {testId: '25'}}, async () await expect( parsedTest.steps[0]?.kind === 'Given' && parsedTest.steps[0]?.definition === 'First Given' && - parsedTest.steps[0]?.column === 1 && + parsedTest.steps[0]?.column === 3 && parsedTest.steps[0]?.line === 16, 'First step is correct', ).ok(); @@ -67,7 +67,7 @@ test('parseTest(...) function works correctly', {meta: {testId: '25'}}, async () await expect( parsedTest.steps[1]?.kind === 'When' && parsedTest.steps[1]?.definition === 'First When' && - parsedTest.steps[1]?.column === 1 && + parsedTest.steps[1]?.column === 3 && parsedTest.steps[1]?.line === 17, 'Second step is correct', ).ok(); @@ -75,7 +75,7 @@ test('parseTest(...) function works correctly', {meta: {testId: '25'}}, async () await expect( parsedTest.steps[2]?.kind === 'When' && parsedTest.steps[2]?.definition === undefined && - parsedTest.steps[2]?.column === 1 && + parsedTest.steps[2]?.column === 5 && parsedTest.steps[2]?.line === 40, 'Third step is correct', ).ok(); @@ -184,6 +184,28 @@ test('parseTest(...) function works correctly', {meta: {testId: '25'}}, async () ).ok(); } + await expect( + parseTest( + "test('Foo', {meta: {testId: '2'}, testIdleTimeout: 3_000, userAgent}, async () => {});", + ).options, + 'Shorthand property with unknown identifier in options is supported', + ).eql({meta: {testId: '2'}, testIdleTimeout: 3_000, userAgent: ''}); + + await expect( + parseTest("test('Foo', {foo, bar: baz, qux}, async () => {});").options, + 'Several shorthand properties in options are supported', + ).eql({bar: '', foo: '', qux: ''}); + + await expect( + parseTest("test('Foo', {desc: 'my Language here', lang: Language}, async () => {});").options, + 'Identifier words inside string values of options are not corrupted', + ).eql({desc: 'my Language here', lang: ''}); + + await expect( + parseTest("test('Foo', {meta: {testId: $testIdVar}}, async () => {});").options, + 'Unknown identifiers with `$` in options are supported', + ).eql({meta: {testId: ''}}); + const crlfParsedTest = parseTest( [ "test('Crlf', {meta: {testId: '25'}}, async () => {", @@ -202,7 +224,7 @@ test('parseTest(...) function works correctly', {meta: {testId: '25'}}, async () crlfParsedTest.steps[0]?.kind === 'Given' && crlfParsedTest.steps[0]?.definition === 'a' && crlfParsedTest.steps[0]?.line === 2 && - crlfParsedTest.steps[0]?.column === 1, + crlfParsedTest.steps[0]?.column === 3, 'CRLF: first step has exact position', ).ok(); @@ -210,7 +232,7 @@ test('parseTest(...) function works correctly', {meta: {testId: '25'}}, async () crlfParsedTest.steps[1]?.kind === 'When' && crlfParsedTest.steps[1]?.definition === 'b' && crlfParsedTest.steps[1]?.line === 3 && - crlfParsedTest.steps[1]?.column === 1, + crlfParsedTest.steps[1]?.column === 3, 'CRLF: second step has exact position', ).ok(); diff --git a/autotests/tests/waitForRequest.ts b/autotests/tests/waitForRequest.ts index 364098cf..3dbc2916 100644 --- a/autotests/tests/waitForRequest.ts +++ b/autotests/tests/waitForRequest.ts @@ -12,9 +12,12 @@ import type {ApiAddUserRequest, UserWorker} from 'autotests/types'; const worker: UserWorker = {firstName: 'John', lastName: 'Doe'}; +const userAgent = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'; + test( 'waitForRequest/waitForRequestToRoute gets correct request body and rejects on timeout', - {meta: {testId: '2'}, testIdleTimeout: 3_000}, + {meta: {testId: '2'}, testIdleTimeout: 3_000, userAgent}, // eslint-disable-next-line max-lines-per-function async () => { const request = await waitForRequest( @@ -32,6 +35,10 @@ test( await expect(request.requestBody, 'request has correct body').eql(worker); + await expect(request.requestHeaders['user-agent'], 'request has correct user agent').eql( + userAgent, + ); + await assertFunctionThrows(async () => { await waitForRequest(() => false, {timeout: 100}); }, 'waitForRequest throws an error on timeout'); 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/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/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/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 12bbdef5..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 */ @@ -54,7 +59,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 +67,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/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/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/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/test.ts b/src/test.ts index 2d49b1c3..9bddf51d 100644 --- a/src/test.ts +++ b/src/test.ts @@ -41,10 +41,12 @@ export const test: TestFunction = (name, options, testFn) => { playwrightTest.use({bypassCSP: !options.enableCsp}); } + if (options.userAgent !== undefined) { + playwrightTest.use({userAgent: options.userAgent}); + } + if (options.viewportHeight !== undefined && options.viewportWidth !== undefined) { - playwrightTest.use({ - viewport: {height: options.viewportHeight, width: options.viewportWidth}, - }); + playwrightTest.use({viewport: {height: options.viewportHeight, width: options.viewportWidth}}); } playwrightTest(playwrightTestName, runTest); 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/codeReport.ts b/src/types/codeReport.ts new file mode 100644 index 00000000..488ed7f1 --- /dev/null +++ b/src/types/codeReport.ts @@ -0,0 +1,122 @@ +import type {Feature, Scenario, StepKind} 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<{ + durationInMs: number; + features: Readonly>; + invalidFeatures: Readonly>; + invalidTests: Readonly>; + scenarios: Readonly>>; + scenariosByTestIdentifier: Readonly>; + tests: Readonly>>; + testsByTestIdentifier: Readonly>; +}>; + +/** + * Full feature report. + */ +export type FeatureReport = Readonly< + Omit & { + name: string; + 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 & { + duplicatesByTestIdentifier: readonly SourcePath[]; + errors: readonly string[]; + featurePath: SourcePath; + name: string; + path: SourcePath; + } & ( + | (TestIdentifierField & { + testIdentifier: string; + testPath: SourcePath | undefined; + }) + | (TestIdentifierField & { + testIdentifier: undefined; + testPath: undefined; + }) + ) +>; + +/** + * Iterable stream of source files. + */ +export type SourceIterable = AsyncIterable | Iterable; + +/** + * Path to source file. + */ +export type SourcePath = Brand; + +/** + * Tokens for locating steps in tests. + */ +export type StepTokens = Readonly>>; + +/** + * Step representation for the step comparison algorithm. + * @internal + */ +export type StepWithReference = Readonly<{key: string; reference: string}>; + +/** + * 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 & { + duplicatesByTestIdentifier: readonly SourcePath[]; + errors: readonly string[]; + path: SourcePath; + } & ( + | (TestIdentifierField & { + featurePath: SourcePath | undefined; + scenarioPath: SourcePath | undefined; + testIdentifier: string; + }) + | (TestIdentifierField & { + featurePath: undefined; + scenarioPath: undefined; + testIdentifier: undefined; + }) + ) +>; diff --git a/src/types/config/ownE2edConfig.ts b/src/types/config/ownE2edConfig.ts index 3221e93d..717f9df5 100644 --- a/src/types/config/ownE2edConfig.ts +++ b/src/types/config/ownE2edConfig.ts @@ -67,6 +67,7 @@ export type OwnE2edConfig< /** * Enables Content-Security-Policy checks in browser. + * This parameter can be overridden in the test-specific options. */ enableCsp: boolean; @@ -232,12 +233,14 @@ export type OwnE2edConfig< /** * If `true`, then takes a screenshot of the full page (not just the viewport) * at the time of the test error, for display in the HTML report. + * This parameter can be overridden in the test-specific options. */ takeFullPageScreenshotOnError: boolean; /** * If `true`, then takes a screenshot of the page viewport * at the time of the test error, for display in the HTML report. + * This parameter can be overridden in the test-specific options. */ takeViewportScreenshotOnError: boolean; @@ -264,16 +267,19 @@ export type OwnE2edConfig< /** * `userAgent` string of browser (device) in tests. + * This parameter can be overridden in the test-specific options. */ userAgent: string; /** * Height of viewport of page in pixels. + * This parameter can be overridden in the test-specific options. */ viewportHeight: number; /** * Width of viewport of page in pixels. + * This parameter can be overridden in the test-specific options. */ viewportWidth: number; 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/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 b4e9ae06..326dc4db 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 { @@ -8,9 +10,19 @@ 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 { + CodeReport, + FeatureReport, + ParseError, + ScenarioReport, + SourceIterable, + SourcePath, + StepTokens, + TestReport, +} from './codeReport'; export type {BrowserName} from './config'; export type {ConsoleMessage, ConsoleMessageType} from './console'; export type {UtcTimeInMs} from './date'; @@ -18,6 +30,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, @@ -69,6 +82,7 @@ export type { FilePathFromRoot, TestFilePath, } from './paths'; +export type {ProjectSettings} from './projectSettings'; export type {AsyncVoid, MaybePromise, Thenable} from './promise'; export type { AnyObject, @@ -97,13 +111,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..6f82c23d 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -12,9 +12,22 @@ 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 { + CodeReport, + FeatureReport, + ParseError, + ScenarioReport, + SourceIterable, + SourcePath, + StepTokens, + TestIdentifierField, + TestReport, +} from './codeReport'; +/** @internal */ +export type {StepWithReference} from './codeReport'; export type { AnyPack, BrowserName, @@ -35,6 +48,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, @@ -111,6 +125,7 @@ export type { FilePathFromRoot, TestFilePath, } from './paths'; +export type {ProjectSettings} from './projectSettings'; export type {AsyncVoid, MaybePromise, Thenable} from './promise'; export type { AnyObject, @@ -180,6 +195,7 @@ export type { CreatePackSpecificTypes, CustomPackPropertiesPlaceholder, CustomReportPropertiesPlaceholder, + GetTestIdentifierKey, SkipTestsPlaceholder, TestMetaPlaceholder, UserlandHooks, @@ -187,9 +203,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..e64f3859 --- /dev/null +++ b/src/types/projectSettings.ts @@ -0,0 +1,10 @@ +/** + * Common static project settings (general for all packs). + */ +export type ProjectSettings = Readonly<{ + allFeatureFileGlobs: readonly string[]; + allTestFileGlobs: readonly string[]; + dockerImage: string | null; + pathToTsConfigFromRoot: string; + testIdentifierKey: Readonly>; +}>; diff --git a/src/types/testRun.ts b/src/types/testRun.ts index d4c3f16b..af2fd8ee 100644 --- a/src/types/testRun.ts +++ b/src/types/testRun.ts @@ -86,6 +86,7 @@ export type TestOptions = DeepReadonly< takeViewportScreenshotOnError?: boolean; testIdleTimeout?: number; testTimeout?: number; + userAgent?: string; } & ( | {viewportHeight: number; viewportWidth: number} | {viewportHeight?: undefined; viewportWidth?: undefined} 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/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 f88e200f..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, @@ -61,5 +62,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/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 104b88f9..bf04a83b 100644 --- a/src/utils/packCompiler/getCompilerOptions.ts +++ b/src/utils/packCompiler/getCompilerOptions.ts @@ -4,11 +4,12 @@ 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 {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; @@ -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/parse/codeReport/assertValueIsDefined.ts b/src/utils/parse/codeReport/assertValueIsDefined.ts new file mode 100644 index 00000000..ba5b8ea8 --- /dev/null +++ b/src/utils/parse/codeReport/assertValueIsDefined.ts @@ -0,0 +1,12 @@ +/** + * Asserts that the value is defined (is not `undefined`). + * @internal + */ +export function assertValueIsDefined( + value: Type, + check: string, +): asserts value is Exclude { + if (value === undefined) { + throw new Error(check); + } +} diff --git a/src/utils/parse/codeReport/fillDuplicates.ts b/src/utils/parse/codeReport/fillDuplicates.ts new file mode 100644 index 00000000..bd0ce0d3 --- /dev/null +++ b/src/utils/parse/codeReport/fillDuplicates.ts @@ -0,0 +1,49 @@ +// eslint-disable-next-line import/no-internal-modules +import {setReadonlyProperty} from '../../object/setReadonlyProperty'; + +import type {ScenarioReport, SourcePath} from '../../../types/internal'; + +type Entry = Pick; + +/** + * Fills `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 = []; + entriesPathsByTestId[testIdentifier] = paths; + } + + paths.push(path); + } + + for (const paths of Object.values(entriesPathsByTestId)) { + if (paths.length <= 1) { + continue; + } + + for (const path of paths) { + const entry = entries[path]; + + if (entry === undefined) { + throw new Error(`Cannot find entry by path "${path}"`); + } + + const duplicates = new Set(paths); + + duplicates.delete(path); + + setReadonlyProperty(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..bd543ee5 --- /dev/null +++ b/src/utils/parse/codeReport/fillLinks.ts @@ -0,0 +1,45 @@ +// eslint-disable-next-line import/no-internal-modules +import {setReadonlyProperty} from '../../object/setReadonlyProperty'; + +import {assertValueIsDefined} from './assertValueIsDefined'; +import {fillTestErrors} from './fillTestErrors'; + +import type {CodeReport} from '../../../types/internal'; + +/** + * Fills links from tests to scenarios and from scenarios to tests. + * @internal + */ +export const fillLinks = (codeReport: CodeReport): void => { + const {scenarios, scenariosByTestIdentifier, tests, testsByTestIdentifier} = codeReport; + + for (const {duplicatesByTestIdentifier, path, testIdentifier} of Object.values(scenarios)) { + if (testIdentifier !== undefined && duplicatesByTestIdentifier.length === 0) { + setReadonlyProperty(scenariosByTestIdentifier, testIdentifier, path); + } + } + + for (const test of Object.values(tests)) { + const {duplicatesByTestIdentifier, path: testPath, testIdentifier} = test; + + if (testIdentifier !== undefined && duplicatesByTestIdentifier.length === 0) { + setReadonlyProperty(testsByTestIdentifier, testIdentifier, testPath); + + const scenarioPath = scenariosByTestIdentifier[testIdentifier]; + + if (scenarioPath === undefined) { + continue; + } + + const scenario = scenarios[scenarioPath]; + + assertValueIsDefined(scenario, `Cannot find scenario with the "${scenarioPath}" path`); + + setReadonlyProperty(scenario, 'testPath', testPath); + setReadonlyProperty(test, 'featurePath', scenario.featurePath); + setReadonlyProperty(test, 'scenarioPath', scenarioPath); + + fillTestErrors(scenario, test); + } + } +}; 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/fillTestErrors.ts b/src/utils/parse/codeReport/fillTestErrors.ts new file mode 100644 index 00000000..cf607553 --- /dev/null +++ b/src/utils/parse/codeReport/fillTestErrors.ts @@ -0,0 +1,56 @@ +import {getScenarioReference} from './getScenarioReference'; +import {getScenarioStepsWithReference} from './getScenarioStepsWithReference'; +import {getStepComparisonErrors} from './getStepComparisonErrors'; +import {getTestReference} from './getTestReference'; +import {getTestStepsWithReference} from './getTestStepsWithReference'; + +import type {ScenarioReport, TestReport} from '../../../types/internal'; + +/** + * Fills test errors (compares tests steps with scenario steps). + * @internal + */ +export const fillTestErrors = (scenario: ScenarioReport, test: TestReport): void => { + let scenarioHasError = false; + const errors = test.errors as string[]; + const scenarioReference = getScenarioReference(scenario); + const testReference = getTestReference(test); + + if (scenario.steps.length === 0) { + scenarioHasError = true; + errors.push(`The ${scenarioReference} has no steps.`); + } + + for (const step of scenario.steps) { + if (step.definition === '') { + scenarioHasError = true; + errors.push( + `Step in ${scenario.featurePath}:${step.lineNumber + 1}:${step.column + 1} (in ${scenarioReference}) has no definition.`, + ); + } + } + + for (const step of test.steps) { + if (step.definition === undefined || step.definition === '') { + errors.push( + `Step in ${test.path}:${step.line}:${step.column} (in ${testReference}) has no definition.`, + ); + } + } + + if (scenarioHasError) { + return; + } + + const scenarioSteps = getScenarioStepsWithReference(scenario); + const testSteps = getTestStepsWithReference(test); + + const comparisonErrors = getStepComparisonErrors({ + scenarioReference, + scenarioSteps, + testReference, + testSteps, + }); + + errors.push(...comparisonErrors); +}; diff --git a/src/utils/parse/codeReport/getCodeReport.ts b/src/utils/parse/codeReport/getCodeReport.ts new file mode 100644 index 00000000..5e40609c --- /dev/null +++ b/src/utils/parse/codeReport/getCodeReport.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 {readFilesByGlobs} from '../../fs/readFilesByGlobs'; +// eslint-disable-next-line import/no-internal-modules +import {setReadonlyProperty} from '../../object/setReadonlyProperty'; +// 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, StepTokens} from '../../../types/internal'; + +type Options = Readonly<{ + features?: SourceIterable; + steps?: StepTokens; + testIdentifierKey?: string; + tests?: SourceIterable; +}>; + +const defaultSteps: Required = { + '*': '^[ \t]*await Star\\(', + And: '^[ \t]*await And\\(', + But: '^[ \t]*await But\\(', + Given: '^[ \t]*await Given\\(', + Then: '^[ \t]*await Then\\(', + When: '^[ \t]*await When\\(', +}; + +/** + * Get code report that analyzes test and specification code, as well as the relationships between them. + */ +export const getCodeReport = async < + TestIdentifierKey extends string = string, + TestIdentifierValue extends string = string, +>({ + features, + steps = defaultSteps, + testIdentifierKey = getTestIdentifierKey(getProjectSettings()), + tests, +}: Options = {}): Promise> => { + const startTimeInMs = Date.now(); + + const featuresIterable: SourceIterable = + features ?? readFilesByGlobs(getProjectSettings().allFeatureFileGlobs); + const testsIterable: SourceIterable = + tests ?? readFilesByGlobs(getProjectSettings().allTestFileGlobs); + + const codeReport: CodeReport = { + durationInMs: 0, + features: Object.create(null) as {}, + invalidFeatures: Object.create(null) as {}, + invalidTests: Object.create(null) as {}, + scenarios: Object.create(null) as {}, + scenariosByTestIdentifier: Object.create(null) as CodeReport['scenariosByTestIdentifier'], + tests: Object.create(null) as {}, + testsByTestIdentifier: Object.create(null) as CodeReport['testsByTestIdentifier'], + }; + + await Promise.all([ + processFeatures({codeReport, featuresIterable, testIdentifierKey}), + processTests({codeReport, stepTokens: steps, testIdentifierKey, testsIterable}), + ]); + + fillReport(codeReport); + + setReadonlyProperty(codeReport, 'durationInMs', Date.now() - startTimeInMs); + + return codeReport; +}; diff --git a/src/utils/parse/codeReport/getScenarioReference.ts b/src/utils/parse/codeReport/getScenarioReference.ts new file mode 100644 index 00000000..a705ff92 --- /dev/null +++ b/src/utils/parse/codeReport/getScenarioReference.ts @@ -0,0 +1,8 @@ +import type {ScenarioReport} from '../../../types/internal'; + +/** + * Get reference to scenario for errors. + * @internal + */ +export const getScenarioReference = (scenario: ScenarioReport): string => + `scenario "${scenario.Scenario}" in ${scenario.featurePath}:${scenario.lineNumber + 1}:${scenario.column + 1}`; diff --git a/src/utils/parse/codeReport/getScenarioStepsWithReference.ts b/src/utils/parse/codeReport/getScenarioStepsWithReference.ts new file mode 100644 index 00000000..08a56f37 --- /dev/null +++ b/src/utils/parse/codeReport/getScenarioStepsWithReference.ts @@ -0,0 +1,32 @@ +import {getScenarioReference} from './getScenarioReference'; + +import type {ScenarioReport, StepWithReference} from '../../../types/internal'; + +/** + * Get steps with reference for scenario. + * @internal + */ +export const getScenarioStepsWithReference = ( + scenario: ScenarioReport, +): readonly StepWithReference[] => { + const scenarioReference = getScenarioReference(scenario); + const steps: StepWithReference[] = []; + const stepsHash: Record = Object.create(null) as {}; + + for (const step of scenario.steps) { + const fullDefinition = `${step.kind} ${step.definition}`; + + stepsHash[fullDefinition] = + stepsHash[fullDefinition] === undefined ? 1 : stepsHash[fullDefinition] + 1; + + const count = stepsHash[fullDefinition]; + const reference = `in ${scenario.featurePath}:${step.lineNumber + 1}:${step.column + 1} (in ${scenarioReference})`; + + steps.push({ + key: count === 1 ? `"${fullDefinition}"` : `"${fullDefinition}" (occurrence ${count})`, + reference, + }); + } + + return steps; +}; diff --git a/src/utils/parse/codeReport/getStepComparisonErrors.ts b/src/utils/parse/codeReport/getStepComparisonErrors.ts new file mode 100644 index 00000000..2d7cd232 --- /dev/null +++ b/src/utils/parse/codeReport/getStepComparisonErrors.ts @@ -0,0 +1,69 @@ +import {assertValueIsDefined} from './assertValueIsDefined'; +import {getStepOrderError} from './getStepOrderError'; + +import type {StepWithReference} from '../../../types/internal'; + +type Options = Readonly<{ + scenarioReference: string; + scenarioSteps: readonly StepWithReference[]; + testReference: string; + testSteps: readonly StepWithReference[]; +}>; + +/** + * Get step comparison errors (between steps of scenario and steps of test). + * @internal + */ +export const getStepComparisonErrors = ({ + scenarioReference, + scenarioSteps, + testReference, + testSteps, +}: Options): readonly string[] => { + const errors: string[] = []; + const scenarioStepsHash: Record = Object.create(null) as {}; + const testStepsHash: Record = Object.create(null) as {}; + + for (let index = 0; index < testSteps.length; index += 1) { + const step = testSteps[index]; + + assertValueIsDefined(step, `Undefined step in ${JSON.stringify(testSteps)}`); + + testStepsHash[step.key] = index; + } + + const scenarioBothSteps: StepWithReference[] = []; + const testBothSteps: StepWithReference[] = []; + + for (let index = 0; index < scenarioSteps.length; index += 1) { + const step = scenarioSteps[index]; + + assertValueIsDefined(step, `Undefined step in ${JSON.stringify(scenarioSteps)}`); + + if (step.key in testStepsHash) { + scenarioBothSteps.push(step); + } else { + errors.push(`Step ${step.key} ${step.reference} is missing from ${testReference}.`); + } + + scenarioStepsHash[step.key] = index; + } + + for (const step of testSteps) { + if (step.key in scenarioStepsHash) { + testBothSteps.push(step); + } else { + errors.push( + `The ${testReference} has an extra step ${step.key} ${step.reference} that is absent from ${scenarioReference}.`, + ); + } + } + + const orderError = getStepOrderError(scenarioBothSteps, testBothSteps); + + if (orderError !== undefined) { + errors.push(orderError); + } + + return errors; +}; diff --git a/src/utils/parse/codeReport/getStepOrderError.ts b/src/utils/parse/codeReport/getStepOrderError.ts new file mode 100644 index 00000000..29751598 --- /dev/null +++ b/src/utils/parse/codeReport/getStepOrderError.ts @@ -0,0 +1,42 @@ +import type {StepWithReference} from '../../../types/internal'; + +/** + * Compares the order of two arrays of steps and return an error if they differ. + * @internal + */ +export const getStepOrderError = ( + scenarioSteps: readonly StepWithReference[], + testSteps: readonly StepWithReference[], +): string | undefined => { + const unorderedScenarioSteps: StepWithReference[] = []; + const unorderedTestSteps: StepWithReference[] = []; + + for (let index = 0; index < scenarioSteps.length; index += 1) { + const scenarioStep = scenarioSteps[index]; + const testStep = testSteps[index]; + + if (scenarioStep !== undefined && testStep !== undefined && scenarioStep.key !== testStep.key) { + unorderedScenarioSteps.push(scenarioStep); + unorderedTestSteps.push(testStep); + } + } + + if (unorderedScenarioSteps.length === 0) { + return; + } + + const scenarioStepsMessage = unorderedScenarioSteps + .map(({key, reference}) => `${key} ${reference}`) + .join(',\n'); + const testStepsMessage = unorderedTestSteps + .map(({key, reference}) => `${key} ${reference}`) + .join(',\n'); + + return [ + 'The following steps appear in a different order in the scenario and the test.', + 'In the scenario the order is:', + `${scenarioStepsMessage}.`, + 'In the test the order is:', + `${testStepsMessage}.`, + ].join('\n'); +}; diff --git a/src/utils/parse/codeReport/getTestReference.ts b/src/utils/parse/codeReport/getTestReference.ts new file mode 100644 index 00000000..037e6a80 --- /dev/null +++ b/src/utils/parse/codeReport/getTestReference.ts @@ -0,0 +1,8 @@ +import type {TestReport} from '../../../types/internal'; + +/** + * Get reference to test for errors. + * @internal + */ +export const getTestReference = (test: TestReport): string => + `test "${test.name}" in ${test.path}:${test.testLineNumber}:1`; diff --git a/src/utils/parse/codeReport/getTestStepsWithReference.ts b/src/utils/parse/codeReport/getTestStepsWithReference.ts new file mode 100644 index 00000000..6efaa00b --- /dev/null +++ b/src/utils/parse/codeReport/getTestStepsWithReference.ts @@ -0,0 +1,31 @@ +import type {StepWithReference, TestReport} from '../../../types/internal'; + +/** + * Get steps with reference for test. + * @internal + */ +export const getTestStepsWithReference = (test: TestReport): readonly StepWithReference[] => { + const steps: StepWithReference[] = []; + const stepsHash: Record = Object.create(null) as {}; + + for (const step of test.steps) { + if (step.definition === undefined || step.definition === '') { + continue; + } + + const fullDefinition = `${step.kind} ${step.definition}`; + + stepsHash[fullDefinition] = + stepsHash[fullDefinition] === undefined ? 1 : stepsHash[fullDefinition] + 1; + + const count = stepsHash[fullDefinition]; + const reference = `in ${test.path}:${step.line}:${step.column}`; + + steps.push({ + key: count === 1 ? `"${fullDefinition}"` : `"${fullDefinition}" (occurrence ${count})`, + reference, + }); + } + + return steps; +}; 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..ce0cdd9f --- /dev/null +++ b/src/utils/parse/codeReport/processFeatures.ts @@ -0,0 +1,84 @@ +import {parseGherkin} from 'parse-gherkin'; + +// eslint-disable-next-line import/no-internal-modules +import {setReadonlyProperty} from '../../object/setReadonlyProperty'; + +import {processScenarios} from './processScenarios'; + +import type { + CodeReport, + FeatureReport, + SourceFile, + SourceIterable, + SourcePath, +} from '../../../types/internal'; + +type Options = Readonly<{ + codeReport: CodeReport; + featuresIterable: SourceIterable; + testIdentifierKey: string; +}>; + +/** + * Process features files. + * @internal + */ +export const processFeatures = async ({ + codeReport, + featuresIterable, + testIdentifierKey, +}: Options): 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 {scenarios: _scenarios, ...parsedWithoutScenarios} = parsed; + const featureReport: FeatureReport = { + ...parsedWithoutScenarios, + name: parsedWithoutScenarios.Feature, + path: path as SourcePath, + scenariosPaths, + }; + + setReadonlyProperty(features, path as SourcePath, featureReport); + + const maybeScenarios = parsed.scenarios?.filter( + (maybeScenario) => 'Scenario' in maybeScenario, + ); + + if (maybeScenarios === undefined || maybeScenarios.length === 0) { + return; + } + + const paths = processScenarios({ + codeReport, + featurePath: path as SourcePath, + scenarios: maybeScenarios, + testIdentifierKey, + }); + + scenariosPaths.push(...paths); + } catch (error) { + setReadonlyProperty(invalidFeatures, 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..72a6eb92 --- /dev/null +++ b/src/utils/parse/codeReport/processScenarios.ts @@ -0,0 +1,79 @@ +// eslint-disable-next-line import/no-internal-modules +import {setReadonlyProperty} from '../../object/setReadonlyProperty'; + +import {assertValueIsDefined} from './assertValueIsDefined'; + +import type {Scenario} from 'parse-gherkin'; + +import type {CodeReport, ScenarioReport, SourcePath} from '../../../types/internal'; + +type Options = Readonly<{ + codeReport: CodeReport; + featurePath: SourcePath; + scenarios: readonly Scenario[]; + testIdentifierKey: string; +}>; + +/** + * Process scenarios from features files. + * @internal + */ +export const processScenarios = ({ + codeReport, + featurePath, + scenarios, + testIdentifierKey, +}: Options): readonly SourcePath[] => { + const {scenarios: reportScenarios} = codeReport; + const paths: SourcePath[] = []; + const testIdTagStart = `@${testIdentifierKey}-`; + + for (let index = 0; index < scenarios.length; index += 1) { + const scenario = scenarios[index]; + + assertValueIsDefined( + scenario, + `Scenario is undefined in feature with the "${featurePath}" path`, + ); + + const path = `${featurePath}/[${index}]` as SourcePath; + + paths.push(path); + + const errors: string[] = []; + const scenarioReport: ScenarioReport = { + [testIdentifierKey]: undefined, + ...scenario, + duplicatesByTestIdentifier: [], + errors, + featurePath, + name: scenario.Scenario, + path, + testIdentifier: undefined as string | undefined, + testPath: undefined, + }; + + setReadonlyProperty(reportScenarios, 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) { + setReadonlyProperty(scenarioReport, 'testIdentifier', testId); + setReadonlyProperty(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..40d4d132 --- /dev/null +++ b/src/utils/parse/codeReport/processTests.ts @@ -0,0 +1,79 @@ +// eslint-disable-next-line import/no-internal-modules +import {setReadonlyProperty} from '../../object/setReadonlyProperty'; + +import {parseTest} from '../parseTest'; + +import type {StepKind} from 'parse-gherkin'; + +import type { + CodeReport, + SourceFile, + SourceIterable, + SourcePath, + StepTokens, + TestReport, +} from '../../../types/internal'; + +type Options = Readonly<{ + codeReport: CodeReport; + stepTokens: StepTokens; + testIdentifierKey: string; + testsIterable: SourceIterable; +}>; + +/** + * Process tests files. + * @internal + */ +export const processTests = async ({ + codeReport, + stepTokens, + testIdentifierKey, + testsIterable, +}: Options): Promise => { + const {invalidTests, tests} = codeReport; + + 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, stepTokens as Required); + const testReport: TestReport = { + [testIdentifierKey]: undefined, + ...parsed, + duplicatesByTestIdentifier: [], + errors: [], + featurePath: undefined, + path: path as SourcePath, + scenarioPath: undefined, + testIdentifier: undefined as string | undefined, + }; + + setReadonlyProperty(tests, path as SourcePath, testReport); + + const testId: unknown = parsed.options?.['meta']?.[testIdentifierKey as never]; + + if (testId !== undefined) { + setReadonlyProperty(testReport, 'testIdentifier', String(testId)); + setReadonlyProperty(testReport, testIdentifierKey as 'testIdentifier', String(testId)); + } + } catch (error) { + setReadonlyProperty(invalidTests, 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/parse/parseTest/parseOptions.ts b/src/utils/parse/parseTest/parseOptions.ts index 93a5b543..f787c9c8 100644 --- a/src/utils/parse/parseTest/parseOptions.ts +++ b/src/utils/parse/parseTest/parseOptions.ts @@ -8,12 +8,17 @@ const notDefinedMessage = ' is not defined'; * @internal */ export const parseOptions = (optionsSource: string): ParsedTest['options'] => { - let literal = optionsSource; + const variables: string[] = []; for (let attempt = 0; attempt < attemptsNumber; attempt += 1) { try { + const variablesDeclaration = + variables.length === 0 ? '' : `var ${variables.join("='',")}=''`; + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func - return new Function(`'use strict';return (${literal})`)() as ParsedTest['options']; + return new Function( + `'use strict';${variablesDeclaration};return (${optionsSource})`, + )() as ParsedTest['options']; } catch (error) { if ( !(error instanceof ReferenceError) || @@ -24,9 +29,8 @@ export const parseOptions = (optionsSource: string): ParsedTest['options'] => { } const variable = error.message.slice(0, -notDefinedMessage.length).trim(); - const regexp = new RegExp(`\\b${variable}\\b`, 'g'); - literal = literal.replace(regexp, '``'); + variables.push(variable); } } diff --git a/src/utils/parse/parseTest/stepHandlers.ts b/src/utils/parse/parseTest/stepHandlers.ts index 6b351d98..0cebfae6 100644 --- a/src/utils/parse/parseTest/stepHandlers.ts +++ b/src/utils/parse/parseTest/stepHandlers.ts @@ -1,4 +1,5 @@ import {getLineColumnByIndex} from './getLineColumnByIndex'; +import {parseSpace} from './parseSpace'; import {throwError} from './throwError'; import type {OnParse} from 'parse-statements'; @@ -16,7 +17,8 @@ export const getOnStepParse = throwError(context, `Step "${kind}" precedes the test function`, start, end); } - const lineColumn = getLineColumnByIndex(context, start); + const nonSpaceStart = parseSpace(start, source); + const lineColumn = getLineColumnByIndex(context, nonSpaceStart === -1 ? start : nonSpaceStart); const step: Mutable = { definition: undefined, 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/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 */ 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 },