diff --git a/.eslintrc.yaml b/.eslintrc.yaml index d0a995c6..d070629d 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -88,8 +88,6 @@ rules: - src/*/internal.ts - src/bin/*.ts - src/config.ts - - src/createLocator.ts - - src/getModulesGraph.ts - src/index.ts missingExports: true unusedExports: true diff --git a/autotests/tests/externalLibraries.ts b/autotests/tests/externalLibraries.ts new file mode 100644 index 00000000..3e430f43 --- /dev/null +++ b/autotests/tests/externalLibraries.ts @@ -0,0 +1,47 @@ +import {test} from 'autotests'; +import {expect} from 'e2ed'; +import { + type Attributes, + createSimpleLocator, + createTestLocator, + getCssSelectorFromAttributesChain, +} from 'e2ed/createLocator'; +import { + getModulesGraph, + type Options, + resolveImports, + resolveReexports, +} from 'e2ed/getModulesGraph'; +import {type Feature, parseGherkin, ParseGherkinError} from 'e2ed/parseGherkin'; + +test('External libraries are reexported correctly', {meta: {testId: '35'}}, async () => { + await expect(typeof createSimpleLocator, '`createSimpleLocator` is a function').eql('function'); + + await expect(typeof createTestLocator, '`createTestLocator` is a function').eql('function'); + + await expect( + typeof getCssSelectorFromAttributesChain, + '`getCssSelectorFromAttributesChain` is a function', + ).eql('function'); + + await expect(typeof getModulesGraph, '`getModulesGraph` is a function').eql('function'); + + await expect(typeof resolveImports, '`resolveImports` is a function').eql('function'); + + await expect(typeof resolveReexports, '`resolveReexports` is a function').eql('function'); + + await expect(typeof parseGherkin, '`parseGherkin` is a function').eql('function'); + + await expect(typeof ParseGherkinError, '`ParseGherkinError` is a class').eql('function'); + + const locatorAttributes: Attributes = {}; + const modulesGraphOptions: Partial = {}; + const gherkinFeatures: readonly Feature[] = []; + + await expect( + typeof locatorAttributes === 'object' && + typeof modulesGraphOptions === 'object' && + gherkinFeatures.length === 0, + 'Types from external libraries are imported correctly', + ).ok(); +}); diff --git a/autotests/tests/internalTypeTests/createPageObjectsFromMultiSelector.skip.ts b/autotests/tests/internalTypeTests/createPageObjectsFromMultiSelector.skip.ts index 895849c6..ff792041 100644 --- a/autotests/tests/internalTypeTests/createPageObjectsFromMultiSelector.skip.ts +++ b/autotests/tests/internalTypeTests/createPageObjectsFromMultiSelector.skip.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ +import {test} from 'autotests'; import {type CreateLocator, createRootLocator, type Locator, type Node} from 'e2ed/createLocator'; import {createPageObjectsFromMultiLocator} from 'e2ed/utils'; @@ -19,6 +20,8 @@ type BarMappedLocator = CreateLocator, Selector>; true satisfies IsEqual>; +test('Skipped test', {meta: {testId: '26'}}, async () => {}); + class Foo { readonly bar: string; diff --git a/autotests/tests/internalTypeTests/expect.skip.ts b/autotests/tests/internalTypeTests/expect.skip.ts index e3a95125..16493d6b 100644 --- a/autotests/tests/internalTypeTests/expect.skip.ts +++ b/autotests/tests/internalTypeTests/expect.skip.ts @@ -1,8 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ +import {test} from 'autotests'; import {htmlElementSelector} from 'autotests/selectors'; import {expect} from 'e2ed'; +test('Skipped test', {meta: {testId: '27'}}, async () => {}); + const someNumber = 3; // ok diff --git a/autotests/tests/internalTypeTests/mockApiRoute.skip.ts b/autotests/tests/internalTypeTests/mockApiRoute.skip.ts index 6c88eaa0..af439345 100644 --- a/autotests/tests/internalTypeTests/mockApiRoute.skip.ts +++ b/autotests/tests/internalTypeTests/mockApiRoute.skip.ts @@ -1,3 +1,4 @@ +import {test} from 'autotests'; import {CreateDevice, CreateProduct} from 'autotests/routes/apiRoutes'; import {Main} from 'autotests/routes/pageRoutes'; import {mockApiRoute, unmockApiRoute} from 'e2ed/actions'; @@ -29,6 +30,8 @@ const apiMockFunction = ( return {responseBody}; }; +test('Skipped test', {meta: {testId: '28'}}, async () => {}); + // @ts-expect-error: mockApiRoute require API route as first argument void mockApiRoute(Main, apiMockFunction); diff --git a/autotests/tests/internalTypeTests/mockWebSocketRoute.skip.ts b/autotests/tests/internalTypeTests/mockWebSocketRoute.skip.ts index 8ba1f06c..3d47e908 100644 --- a/autotests/tests/internalTypeTests/mockWebSocketRoute.skip.ts +++ b/autotests/tests/internalTypeTests/mockWebSocketRoute.skip.ts @@ -1,3 +1,4 @@ +import {test} from 'autotests'; import {Main} from 'autotests/routes/pageRoutes'; import {Base, Score} from 'autotests/routes/webSocketRoutes'; import {mockWebSocketRoute, unmockWebSocketRoute} from 'e2ed/actions'; @@ -18,6 +19,8 @@ const webSocketMockFunction = ( return {score: size > 2 ? size : 2}; }; +test('Skipped test', {meta: {testId: '29'}}, async () => {}); + // @ts-expect-error: mockWebSocketRoute require WebSocket route as first argument void mockWebSocketRoute(Main, anyMockFunction); diff --git a/autotests/tests/internalTypeTests/pages.skip.ts b/autotests/tests/internalTypeTests/pages.skip.ts index f042f1b4..98f8cd54 100644 --- a/autotests/tests/internalTypeTests/pages.skip.ts +++ b/autotests/tests/internalTypeTests/pages.skip.ts @@ -2,9 +2,12 @@ * @file Tests of TypeScript types for pages. */ +import {test} from 'autotests'; import {Main, Search, Services} from 'autotests/pageObjects/pages'; import {navigateToPage} from 'e2ed/actions'; +test('Skipped test', {meta: {testId: '30'}}, async () => {}); + /** * PageParams = Readonly<{mobileDevice?: MobileDeviceModel, query?: string}> */ diff --git a/autotests/tests/internalTypeTests/request.skip.ts b/autotests/tests/internalTypeTests/request.skip.ts index 1a5598c6..ca7eebc9 100644 --- a/autotests/tests/internalTypeTests/request.skip.ts +++ b/autotests/tests/internalTypeTests/request.skip.ts @@ -1,3 +1,4 @@ +import {test} from 'autotests'; import {CreateDevice, UserSignUp} from 'autotests/routes/apiRoutes'; import {Main} from 'autotests/routes/pageRoutes'; import {getRandomId} from 'e2ed/generators'; @@ -9,6 +10,8 @@ declare const apiUserParams: ApiUserParams; declare const model: MobileDeviceModel; declare const apiDeviceParams: ApiDeviceParams; +test('Skipped test', {meta: {testId: '31'}}, async () => {}); + // @ts-expect-error: request require API route as first argument void request(Main, {requestBody: apiUserParams}); diff --git a/autotests/tests/internalTypeTests/routes.skip.ts b/autotests/tests/internalTypeTests/routes.skip.ts index c7d84d5e..bcb1f160 100644 --- a/autotests/tests/internalTypeTests/routes.skip.ts +++ b/autotests/tests/internalTypeTests/routes.skip.ts @@ -4,9 +4,12 @@ * @file Tests of TypeScript types for routes. */ +import {test} from 'autotests'; import {CreateDevice, UserSignUp} from 'autotests/routes/apiRoutes'; import {Search} from 'autotests/routes/pageRoutes'; +test('Skipped test', {meta: {testId: '32'}}, async () => {}); + /** * RouteParams = Readonly<{model: MobileDevice}> */ diff --git a/autotests/tests/internalTypeTests/selectors.skip.ts b/autotests/tests/internalTypeTests/selectors.skip.ts index 40c7a195..e837c411 100644 --- a/autotests/tests/internalTypeTests/selectors.skip.ts +++ b/autotests/tests/internalTypeTests/selectors.skip.ts @@ -1,7 +1,10 @@ +import {test} from 'autotests'; import {createSelector, htmlElementSelector, locator} from 'autotests/selectors'; import type {Selector} from 'e2ed/types'; +test('Skipped test', {meta: {testId: '33'}}, async () => {}); + // @ts-expect-error: wrong number of arguments htmlElementSelector.findByTestId(); // ok diff --git a/autotests/tests/internalTypeTests/waitForEvents.skip.ts b/autotests/tests/internalTypeTests/waitForEvents.skip.ts index ed0437f7..656fef97 100644 --- a/autotests/tests/internalTypeTests/waitForEvents.skip.ts +++ b/autotests/tests/internalTypeTests/waitForEvents.skip.ts @@ -1,3 +1,4 @@ +import {test} from 'autotests'; import {AddUser, GetUser} from 'autotests/routes/apiRoutes'; import { waitForNewTab, @@ -7,6 +8,8 @@ import { waitForResponseToRoute, } from 'e2ed/actions'; +test('Skipped test', {meta: {testId: '34'}}, async () => {}); + // ok void waitForRequest(() => false); diff --git a/autotests/tests/notInAllTestsPack.ts b/autotests/tests/notInAllTestsPack.ts index 70697dc5..87af128d 100644 --- a/autotests/tests/notInAllTestsPack.ts +++ b/autotests/tests/notInAllTestsPack.ts @@ -5,6 +5,8 @@ import {test} from 'autotests'; import {E2edError} from 'e2ed/utils'; -test('not in allTests pack', {meta: {testId: '13'}}, () => { +test('not in allTests pack', {meta: {testId: '13'}}, async () => { + await Promise.resolve(); + throw new E2edError('Test filtered from the pack "allTests" was running'); }); diff --git a/autotests/tests/parseTest.ts b/autotests/tests/parseTest.ts new file mode 100644 index 00000000..a0cdfbc6 --- /dev/null +++ b/autotests/tests/parseTest.ts @@ -0,0 +1,501 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, max-lines */ + +import {glob, readFile} from 'node:fs/promises'; + +import {test} from 'autotests'; +import {expect} from 'e2ed'; +import {READ_FILE_OPTIONS} from 'e2ed/constants'; +import {parseTest, ParseTestError} from 'e2ed/parseTest'; +import {E2edError} from 'e2ed/utils'; + +const Given: (definition: string) => Promise = async () => {}; + +const When: (definition?: string) => Promise = async () => {}; + +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 () => { + await Given('First Given'); + await When('First When'); + + const stepTokens = {Given: '^[ \t]*await Given\\(', When: '^[ \t]*await When\\('}; + + /* + + await Given('Given inside comment'); + + */ + + // await When('When inside comment'); + + for await (const path of glob(testsPattern)) { + const source = await readFile(path, READ_FILE_OPTIONS); + + const parsedTest = parseTest(source, stepTokens); + + const meta = parsedTest.options?.['meta']; + + if (meta == null || typeof meta !== 'object') { + throw new E2edError('meta is not an object', {parsedTest, path}); + } + + await When(); + + const testId = 'testId' in meta ? meta.testId : undefined; + + if (typeof testId !== 'string' || !Number.isInteger(Number(testId))) { + throw new E2edError('testId is not an integer', {parsedTest, path}); + } + + if (testId !== '25') { + await expect(parsedTest.steps.length, 'Other tests have no steps').eql(0); + + await expect(parsedTest.testLineNumber, 'Parsed test has correct test line number').gt(1); + } else { + await expect(parsedTest.steps.length, 'This test has steps').eql(3); + + await expect(parsedTest.testLineNumber, 'Parsed test has correct test line number').gt(1); + + await expect( + parsedTest.steps[0]?.kind === 'Given' && + parsedTest.steps[0]?.definition === 'First Given' && + parsedTest.steps[0]?.column === 1 && + parsedTest.steps[0]?.line === 19, + 'First step is correctly', + ).ok(); + + await expect( + parsedTest.steps[1]?.kind === 'When' && + parsedTest.steps[1]?.definition === 'First When' && + parsedTest.steps[1]?.column === 1 && + parsedTest.steps[1]?.line === 20, + 'Second step is correctly', + ).ok(); + + await expect( + parsedTest.steps[2]?.kind === 'When' && + parsedTest.steps[2]?.definition === undefined && + parsedTest.steps[2]?.column === 1 && + parsedTest.steps[2]?.line === 43, + 'Third step is correctly', + ).ok(); + } + } + + await expect( + parseTest('test(`Foo`, async () => {});', stepTokens).options, + 'Support tests without options', + ).eql(undefined); + + await expect( + parseTest('test(`Foo`, async () => {});', stepTokens).name === 'Foo', + 'Backtick strings are supported', + ).ok(); + + await expect( + parseTest("test('Foo', {meta: {url: 'https://x.com'}}, async () => {});").options, + 'Correctly parse urls in options', + ).eql({meta: {url: 'https://x.com'}}); + + try { + parseTest(["test('Foo', async () => {});", "test('Bar', async () => {});"].join('\n')); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof ParseTestError && error.message.includes('second test'), + 'Correctly throw when file contains two tests', + ).ok(); + } + + try { + parseTest(["await When('Foo');", "test('Bar', async () => {});"].join('\n'), stepTokens); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof ParseTestError && error.message.includes('Step "When" precedes'), + 'Correctly throw when step precedes the test', + ).ok(); + } + + const globalObject = globalThis as unknown as {parseOptionsEvaluations?: number}; + + globalObject.parseOptionsEvaluations = 0; + + await expect( + parseTest("test('Foo', {count: (globalThis.parseOptionsEvaluations += 1)}, async () => {});") + .options, + 'Options object is correctly parsed', + ).eql({count: 1}); + + await expect( + globalObject.parseOptionsEvaluations, + 'Options literal is evaluated exactly once', + ).eql(1); + + await expect( + parseTest("test('Foo', {meta: {testId: '25', lang: Language}}, async () => {});").options, + 'Unknown identifiers in options are replaced with `` string', + ).eql({meta: {lang: '', testId: '25'}}); + + await expect( + parseTest("test('Foo', {lang: Language.En}, async () => {});").options?.['lang'], + 'Property access on unknown identifier gives undefined', + ).eql(undefined); + + await expect( + parseTest("test('Foo', {a: A1, b: B2, c: C3, d: D4, e: E5, f: F6, g: G7}, async () => {});") + .options, + 'Up to seven distinct unknown identifiers in options are supported', + ).eql({ + a: '', + b: '', + c: '', + d: '', + e: '', + f: '', + g: '', + }); + + try { + parseTest( + "test('Foo', {a: A1, b: B2, c: C3, d: D4, e: E5, f: F6, g: G7, h: H8}, async () => {});", + ); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof ParseTestError && error.message.includes('Cannot parse options object'), + 'Correctly throw when options contain more than seven unknown identifiers', + ).ok(); + } + + try { + parseTest("test('Foo', {meta: getMeta()}, async () => {});"); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof ParseTestError && error.message.includes('Cannot parse options object'), + 'Correctly throw when options call an unknown function', + ).ok(); + } + + const crlfParsedTest = parseTest( + [ + "test('Crlf', {meta: {testId: '25'}}, async () => {", + " await Given('a');", + " await When('b');", + '});', + ].join('\r\n'), + stepTokens, + ); + + await expect(crlfParsedTest.testLineNumber, 'CRLF: parsed test has exact test line number').eql( + 1, + ); + + await expect( + crlfParsedTest.steps[0]?.kind === 'Given' && + crlfParsedTest.steps[0]?.definition === 'a' && + crlfParsedTest.steps[0]?.line === 2 && + crlfParsedTest.steps[0]?.column === 1, + 'CRLF: first step has exact position', + ).ok(); + + await expect( + crlfParsedTest.steps[1]?.kind === 'When' && + crlfParsedTest.steps[1]?.definition === 'b' && + crlfParsedTest.steps[1]?.line === 3 && + crlfParsedTest.steps[1]?.column === 1, + 'CRLF: second step has exact position', + ).ok(); + + try { + parseTest("test('Foo', {meta: {}});"); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof ParseTestError && + error.message.includes('Cannot find end of test definition'), + 'Correctly throw when test has no `async () => {` part', + ).ok(); + } + + try { + parseTest("test('Foo', someOptions, async () => {});"); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof ParseTestError && error.message.includes('is not an options object'), + 'Correctly throw when second argument of test is not an object literal', + ).ok(); + } + + try { + parseTest( + ["test('Foo', async () => {", " await When('unterminated", '});'].join('\n'), + stepTokens, + ); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof ParseTestError && + error.message.includes('Cannot find end of step definition string'), + 'Correctly throw when step definition string is unterminated', + ).ok(); + } + + try { + parseTest('const foo = 1;\n'); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof ParseTestError && error.message.includes('contains no tests'), + 'Correctly throw when file contains no tests', + ).ok(); + } + + await expect( + parseTest("test('It\\'s \"quoted\"', {meta: {}}, async () => {});").name, + 'Escaped quotes in test name are unescaped', + ).eql('It\'s "quoted"'); + + await expect( + parseTest( + ["test('Foo', async () => {", " await When('It\\'s');", '});'].join('\n'), + stepTokens, + ).steps[0]?.definition, + 'Escaped quotes in step definition are unescaped', + ).eql("It's"); + + await expect( + parseTest(['// Comment', '', "test('Foo', {meta: {}}, async () => {});"].join('\n')) + .testLineNumber, + 'Parsed test has exact test line number', + ).eql(3); + + const withMultilineComments = parseTest( + "test( /* one */ 'Foo' /* two */ , /* three */ {meta: {testId: '25' /* four */}} /* five */ , /* six */ async () => {});", + ); + + await expect( + withMultilineComments.name, + 'Multiline comments in all positions of test header do not break the test name', + ).eql('Foo'); + + await expect( + withMultilineComments.options, + 'Multiline comments in all positions of test header are stripped from options', + ).eql({meta: {testId: '25'}}); + + const withSinglelineComments = parseTest( + [ + 'test( // one', + " 'Foo', // two", + ' // three', + ' {', + ' // four', + " meta: {testId: '25'}, // five", + ' }, // six', + ' async () => {});', + ].join('\n'), + ); + + await expect( + withSinglelineComments.name, + 'Singleline comments in all positions of test header do not break the test name', + ).eql('Foo'); + + await expect( + withSinglelineComments.options, + 'Singleline comments in all positions of test header are stripped from options', + ).eql({meta: {testId: '25'}}); + + await expect( + withSinglelineComments.testLineNumber, + 'Comments in test header do not affect test line number', + ).eql(1); + + await expect( + parseTest( + ["test('Foo', {meta: {testId: '25'}},", ' /* async () => { */', ' async () => {});'].join( + '\n', + ), + ).options, + 'Comment containing `async () => {` does not end the test header', + ).eql({meta: {testId: '25'}}); + + await expect( + parseTest( + [ + "test('Foo', {", + ' /* multi', + " line 'with quote and braces {}',", + ' comment */', + " meta: {testId: '25'},", + '}, async () => {});', + ].join('\n'), + ).options, + 'Multiline comment spanning several lines inside options is stripped', + ).eql({meta: {testId: '25'}}); + + const namesWithBackslashes: readonly [nameSource: string, expectedName: string][] = [ + [String.raw`'a\'b'`, "a'b"], + [String.raw`'a\\'`, 'a\\'], + [String.raw`'a\\\'b'`, "a\\'b"], + [String.raw`'a\\\\'`, 'a\\\\'], + [String.raw`'a\\\\\'b'`, "a\\\\'b"], + [String.raw`'a\\\\\\'`, 'a\\\\\\'], + [String.raw`'a\\\\\\\'b'`, "a\\\\\\'b"], + [String.raw`'a\\\\\\\\'`, 'a\\\\\\\\'], + ]; + + for (const [nameSource, expectedName] of namesWithBackslashes) { + const parsedTestWithBackslashes = parseTest( + `test(${nameSource}, {meta: {testId: '25'}}, async () => {});`, + ); + + await expect( + parsedTestWithBackslashes.name, + `Backslashes in test name ${nameSource} are correctly unescaped`, + ).eql(expectedName); + + await expect( + parsedTestWithBackslashes.options, + `End of test name string ${nameSource} is correctly found`, + ).eql({meta: {testId: '25'}}); + } + + await expect( + parseTest( + ["test('Foo', async () => {", String.raw` await When('a\\');`, '});'].join('\n'), + stepTokens, + ).steps[0]?.definition, + 'Escaped backslash at the end of step definition is correctly parsed', + ).eql('a\\'); + + await expect( + parseTest( + [ + "test('T', {meta: {testId: '1'}}, async () => {", + " const p = '**/autotests/**/*.ts';", + " await Given('a');", + '});', + ].join('\n'), + stepTokens, + ).steps.map(({kind, definition, line}) => `${kind}:${definition}:${line}`), + 'String literal with `/*` (glob pattern) does not swallow the following steps', + ).eql(['Given:a:3']); + + await expect( + parseTest( + [ + "test('T', {meta: {testId: '1'}}, async () => {", + " const routePart = 'foo/*bar';", + " await Given('a');", + ' /* real comment */', + " await When('b');", + '});', + ].join('\n'), + stepTokens, + ).steps.map(({kind}) => kind), + 'Unclosed `/*` inside string literal does not open a comment', + ).eql(['Given', 'When']); + + const withTemplateLiteral = parseTest( + [ + "test('T', {meta: {testId: '1'}}, async () => {", + ' const code = `', + "test('Fake', {meta: {testId: '2'}}, async () => {", + " await Given('fake step');", + '});', + ' `;', + " await When('real');", + '});', + ].join('\n'), + stepTokens, + ); + + await expect( + withTemplateLiteral.name, + 'Fake `test(` inside template literal does not become a second test', + ).eql('T'); + + await expect( + withTemplateLiteral.steps.map(({kind, definition}) => `${kind}:${definition}`), + 'Fake step inside template literal is not parsed as a step', + ).eql(['When:real']); + + await expect( + parseTest( + [ + "test('T', {meta: {testId: '1'}}, async () => {", + " // don't break parsing", + " await Given('a');", + '});', + ].join('\n'), + stepTokens, + ).steps.map(({kind}) => kind), + 'Apostrophe inside comment does not open a string literal', + ).eql(['Given']); + + await expect( + parseTest( + [ + "test('T', {meta: {testId: '1'}}, async () => {", + " await Given('see https://x.com/docs');", + " await When('b');", + '});', + ].join('\n'), + stepTokens, + ).steps.map(({kind, definition, line}) => `${kind}:${definition}:${line}`), + 'Step definition with `//` (URL) is parsed correctly', + ).eql(['Given:see https://x.com/docs:2', 'When:b:3']); + + await expect( + parseTest( + String.raw`test('T', {meta: {testId: '1', a: 'don\'t } {', b: "x /* y", c: 'z // w'}}, async () => {});`, + ).options, + 'Braces, quotes and comment-like text inside options strings are parsed correctly', + ).eql({meta: {a: "don't } {", b: 'x /* y', c: 'z // w', testId: '1'}}); + + await expect( + parseTest( + [ + "test('T', {meta: {testId: '1'}}, async () => {", + ' const q = "foo/*bar";', + " await Given('a');", + ' /* real */', + " await When('b');", + '});', + ].join('\n'), + stepTokens, + ).steps.map(({kind}) => kind), + 'Double quoted string with comment-like content is skipped correctly', + ).eql(['Given', 'When']); + + try { + parseTest( + [ + "test('T', {meta: {testId: '1'}}, async () => {", + ' const code = `unterminated', + '});', + ].join('\n'), + stepTokens, + ); + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof ParseTestError && error.message.includes('started with backtick'), + 'Correctly throw when backtick string literal is unterminated', + ).ok(); + } +}); diff --git a/autotests/tests/skipped.ts b/autotests/tests/skipped.ts index 84f18030..1ae9bdd4 100644 --- a/autotests/tests/skipped.ts +++ b/autotests/tests/skipped.ts @@ -4,6 +4,8 @@ import {test} from 'autotests'; -test('skipped', {meta: {testId: '4'}}, () => { +test('skipped', {meta: {testId: '4'}}, async () => { + await Promise.resolve(); + throw new Error('Skipped test was running'); }); diff --git a/package-lock.json b/package-lock.json index 16a47126..422aea53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@playwright/test": "1.62.1", "create-locator": "0.0.27", "get-modules-graph": "0.0.11", + "parse-gherkin": "0.0.2", + "parse-statements": "1.0.14", "sort-json-keys": "1.0.3" }, "bin": { @@ -2989,6 +2991,12 @@ "node": ">=6" } }, + "node_modules/parse-gherkin": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/parse-gherkin/-/parse-gherkin-0.0.2.tgz", + "integrity": "sha512-81nLrW5Jpy9Suw39onM1HqFNFpvlmFAkP6Ix8rtW2Ajcvw0X/kFKqh4A8LTwWe8+Rrtj0rzUBqpk8lmsbR0tWQ==", + "license": "MIT" + }, "node_modules/parse-imports-exports": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.1.3.tgz", @@ -2998,12 +3006,18 @@ "parse-statements": "1.0.10" } }, - "node_modules/parse-statements": { + "node_modules/parse-imports-exports/node_modules/parse-statements": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.10.tgz", "integrity": "sha512-mnZogCUQHm9GNbsNa++tDVQcaEg/v72wSe02p97IINjmkbShct6g5dzpARqxO4uI7hLzYawwfdTkTAsaLqQ36Q==", "license": "MIT" }, + "node_modules/parse-statements": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.14.tgz", + "integrity": "sha512-AbGUCqPuEHQft9M4qu6WCDX5bVK3TiQuLB3hmt20Mnh7ufsq9bUmgxl8BkvYQ1qHrlYGOkuROSXk19Xt2ZOxXA==", + "license": "MIT" + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", diff --git a/package.json b/package.json index 14bddee3..81d87f1b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "engines": { "node": ">=22.14.0" }, - "packageManager": "npm@10", + "packageManager": "npm@11", "homepage": "https://github.com/joomcode/e2ed#readme", "repository": { "type": "git", @@ -28,6 +28,8 @@ "@playwright/test": "1.62.1", "create-locator": "0.0.27", "get-modules-graph": "0.0.11", + "parse-gherkin": "0.0.2", + "parse-statements": "1.0.14", "sort-json-keys": "1.0.3" }, "devDependencies": { @@ -57,9 +59,12 @@ "./configurator": "./configurator/index.js", "./constants": "./constants/index.js", "./context": "./context/index.js", - "./createLocator": "./createLocator.js", + "./createLocator": "./createLocator/index.js", "./generators": "./generators/index.js", - "./getModulesGraph": "./getModulesGraph.js", + "./getModulesGraph": "./getModulesGraph/index.js", + "./package.json": "./package.json", + "./parseGherkin": "./parseGherkin/index.js", + "./parseTest": "./parseTest/index.js", "./selectors": "./selectors/index.js", "./types": "./types/index.js", "./utils": "./utils/index.js" diff --git a/src/createLocator.ts b/src/createLocator/index.ts similarity index 100% rename from src/createLocator.ts rename to src/createLocator/index.ts diff --git a/src/getModulesGraph.ts b/src/getModulesGraph/index.ts similarity index 100% rename from src/getModulesGraph.ts rename to src/getModulesGraph/index.ts diff --git a/src/parseGherkin/index.ts b/src/parseGherkin/index.ts new file mode 100644 index 00000000..3ebde416 --- /dev/null +++ b/src/parseGherkin/index.ts @@ -0,0 +1,4 @@ +export {parseGherkin, ParseGherkinError} from 'parse-gherkin'; + +// eslint-disable-next-line no-restricted-syntax +export type * from 'parse-gherkin'; diff --git a/src/parseTest/ParseTestError.ts b/src/parseTest/ParseTestError.ts new file mode 100644 index 00000000..abff8b29 --- /dev/null +++ b/src/parseTest/ParseTestError.ts @@ -0,0 +1,23 @@ +import type {LineColumn} from '../types/internal'; + +/** + * Parse test error. + */ +export class ParseTestError extends SyntaxError implements LineColumn { + column = 1; + + line = 1; + + override name = 'ParseTestError'; + + source: string | undefined = undefined; + + // eslint-disable-next-line @typescript-eslint/naming-convention + toJSON(): object { + return {...this, message: this.message, stack: this.stack}; + } + + override toString(): string { + return JSON.stringify(this.toJSON()); + } +} diff --git a/src/parseTest/comments.ts b/src/parseTest/comments.ts new file mode 100644 index 00000000..af7cdffa --- /dev/null +++ b/src/parseTest/comments.ts @@ -0,0 +1,62 @@ +import {throwError} from './throwError'; + +import type {Comment, OnCommentError} from 'parse-statements'; + +import type {ParseTestContext} from '../types/internal'; + +/** + * Throws error of parsing single quote string. + */ +const onSingleQuoteError: OnCommentError = (context, _source, {start}) => + throwError(context, 'Cannot find end of string literal started with single quote', start); + +/** + * Throws error of parsing double quote string. + */ +const onDoubleQuoteError: OnCommentError = (context, _source, {start}) => + throwError(context, 'Cannot find end of string literal started with double quote', start); + +/** + * Throws error of parsing backtick string. + */ +const onBacktickError: OnCommentError = (context, _source, {start}) => + throwError(context, 'Cannot find end of string literal started with backtick', start); + +/** + * Throws error of parsing multiline comment. + */ +const onMultilineCommentError: OnCommentError = (context, _source, {start}) => + throwError(context, 'Cannot find end of multiline comment', start); + +/** + * Throws error of parsing single line comment. + */ +const onSinglelineCommentError: OnCommentError = (context, _source, {start}) => + throwError(context, 'Cannot find end of single line comment', start); + +/** + * Statements of ECMAScript comments. + * @internal + */ +export const comments: readonly Comment[] = [ + { + onError: onSingleQuoteError, + tokens: ["'", "((?<=(?:^|[^\\\\])(?:\\\\\\\\)*)')|($)"], + }, + { + onError: onDoubleQuoteError, + tokens: ['"', '((?<=(?:^|[^\\\\])(?:\\\\\\\\)*)")|($)'], + }, + { + onError: onBacktickError, + tokens: ['`', '(?<=(?:^|[^\\\\])(?:\\\\\\\\)*)`'], + }, + { + onError: onSinglelineCommentError, + tokens: ['\\/\\/', '$'], + }, + { + onError: onMultilineCommentError, + tokens: ['\\/\\*', '\\*\\/'], + }, +]; diff --git a/src/parseTest/getLineColumnByIndex.ts b/src/parseTest/getLineColumnByIndex.ts new file mode 100644 index 00000000..3be5f909 --- /dev/null +++ b/src/parseTest/getLineColumnByIndex.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion, no-var, vars-on-top */ + +import type {LineColumn, ParseTestContext} from '../types/internal'; + +/** + * Get number of line where in which the character with the specified index is located. + */ +const getNumberOfLine = (index: number, linesIndexes: readonly number[]): number => { + const {length} = linesIndexes; + + if (index >= linesIndexes[length - 1]!) { + return length - 1; + } + + var min = 0; + var max = length - 2; + + while (min < max) { + // eslint-disable-next-line no-bitwise + var middle = min + ((max - min) >> 1); + + if (index < linesIndexes[middle]!) { + max = middle - 1; + } else if (index >= linesIndexes[middle + 1]!) { + min = middle + 1; + } else { + min = middle; + break; + } + } + + return min; +}; + +/** + * Get `LineColumn` string by index in source. + * @internal + */ +export const getLineColumnByIndex = ( + {lineColumnCache, linesIndexes}: ParseTestContext, + index: number, +): LineColumn => { + let lineColumn = lineColumnCache[index]; + + if (lineColumn !== undefined) { + return lineColumn; + } + + const numberOfLine = getNumberOfLine(index, linesIndexes); + const line = numberOfLine + 1; + const column = index - linesIndexes[numberOfLine]! + 1; + + lineColumn = {column, line}; + // eslint-disable-next-line no-param-reassign + lineColumnCache[index] = lineColumn; + + return lineColumn; +}; diff --git a/src/parseTest/getLinesIndexes.ts b/src/parseTest/getLinesIndexes.ts new file mode 100644 index 00000000..8b536d12 --- /dev/null +++ b/src/parseTest/getLinesIndexes.ts @@ -0,0 +1,18 @@ +/** + * Get array of indexes of lines first symbols in source. + */ +export const getLinesIndexes = (source: string): readonly number[] => { + let index = 0; + const lines = source.split('\n'); + const indexes = new Array(lines.length); + + let lineNumber = 0; + + for (; lineNumber < lines.length; lineNumber += 1) { + indexes[lineNumber] = index; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + index += lines[lineNumber]!.length + 1; + } + + return indexes; +}; diff --git a/src/parseTest/getStatements.ts b/src/parseTest/getStatements.ts new file mode 100644 index 00000000..61549397 --- /dev/null +++ b/src/parseTest/getStatements.ts @@ -0,0 +1,35 @@ +import {getOnStepParse} from './stepHandlers'; +import {onTestError, onTestParse} from './testHandlers'; + +import type {OnParse, Statement} from 'parse-statements'; + +import type {ObjectEntries, ParseTestContext} from '../types/internal'; + +/** + * Get statements for parsing by step tokens. + * @internal + */ +export const getStatements = ( + stepTokens: Readonly>, +): readonly Statement>[] => { + const statements: Statement>[] = [ + { + canIncludeComments: true, + onError: onTestError as OnParse, + onParse: onTestParse as OnParse, + shouldSearchBeforeComments: true, + tokens: ['^test\\(', '\\basync \\(\\) => \\{'], + }, + ]; + + for (const [kind, token] of Object.entries(stepTokens) as ObjectEntries) { + statements.push({ + canIncludeComments: false, + onParse: getOnStepParse(kind) as OnParse, + shouldSearchBeforeComments: true, + tokens: [token], + }); + } + + return statements; +}; diff --git a/src/parseTest/index.ts b/src/parseTest/index.ts new file mode 100644 index 00000000..c135288b --- /dev/null +++ b/src/parseTest/index.ts @@ -0,0 +1,3 @@ +export {getLinesIndexes} from './getLinesIndexes'; +export {parseTest} from './parseTest'; +export {ParseTestError} from './ParseTestError'; diff --git a/src/parseTest/onGlobalError.ts b/src/parseTest/onGlobalError.ts new file mode 100644 index 00000000..6f26f9c8 --- /dev/null +++ b/src/parseTest/onGlobalError.ts @@ -0,0 +1,13 @@ +import {throwError} from './throwError'; + +import type {OnGlobalError} from 'parse-statements'; + +import type {ParseTestContext} from '../types/internal'; + +/** + * Adds global error of parsing source. + * @internal + */ +// eslint-disable-next-line @typescript-eslint/max-params +export const onGlobalError: OnGlobalError = (context, _source, message, index) => + throwError(context, message, index); diff --git a/src/parseTest/parseOptions.ts b/src/parseTest/parseOptions.ts new file mode 100644 index 00000000..1a7de5ae --- /dev/null +++ b/src/parseTest/parseOptions.ts @@ -0,0 +1,34 @@ +import type {ParsedTest} from '../types/internal'; + +const attemptsNumber = 8; +const notDefinedMessage = ' is not defined'; + +/** + * Parses test options object. + * @internal + */ +export const parseOptions = (optionsSource: string): ParsedTest['options'] => { + let literal = optionsSource; + + for (let attempt = 0; attempt < attemptsNumber; attempt += 1) { + try { + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + return new Function(`'use strict';return (${literal})`)() as ParsedTest['options']; + } catch (error) { + if ( + !(error instanceof ReferenceError) || + !error.message.endsWith(notDefinedMessage) || + attempt === attemptsNumber - 1 + ) { + throw error; + } + + const variable = error.message.slice(0, -notDefinedMessage.length).trim(); + const regexp = new RegExp(`\\b${variable}\\b`, 'g'); + + literal = literal.replace(regexp, '``'); + } + } + + throw new Error(`Cannot parse options object: ${optionsSource}`); +}; diff --git a/src/parseTest/parseStringLiteral.ts b/src/parseTest/parseStringLiteral.ts new file mode 100644 index 00000000..fc9379d1 --- /dev/null +++ b/src/parseTest/parseStringLiteral.ts @@ -0,0 +1,38 @@ +/** + * Parses string literal, from opening quote character. + * @internal + */ +export const parseStringLiteral = ( + quoteCharacter: '"' | '`' | "'", + sourceWithString: string, +): Readonly<{index: number; text: string}> => { + let hasBackslash = false; + let index = 1; + + for (; index < sourceWithString.length; index += 1) { + const char = sourceWithString[index]; + + if (char === '\\') { + index += 1; + hasBackslash = true; + + continue; + } + + if (char === quoteCharacter) { + break; + } + } + + if (index >= sourceWithString.length) { + return {index: -1, text: ''}; + } + + let text = sourceWithString.slice(1, index); + + if (hasBackslash) { + text = text.replace(/\\(.)/gs, '$1'); + } + + return {index, text}; +}; diff --git a/src/parseTest/parseTest.ts b/src/parseTest/parseTest.ts new file mode 100644 index 00000000..ae311ecf --- /dev/null +++ b/src/parseTest/parseTest.ts @@ -0,0 +1,57 @@ +import {createParseFunction, type Parse} from 'parse-statements'; + +import {comments} from './comments'; +import {getLinesIndexes} from './getLinesIndexes'; +import {getStatements} from './getStatements'; +import {onGlobalError} from './onGlobalError'; +import {throwError} from './throwError'; + +import type {ParsedTest, ParseTestContext} from '../types/internal'; + +/** + * Cache of parse functions with different options. + */ +const parseCache = Object.create(null) as Record>>; + +/** + * Parses source of test file. + */ +export const parseTest = ( + source: string, + stepTokens: Readonly> = {} as unknown as Record, +): ParsedTest => { + const cacheKey = JSON.stringify(stepTokens); + const context: ParseTestContext = { + lineColumnCache: Object.create(null) as {}, + linesIndexes: getLinesIndexes(source), + name: undefined, + options: undefined, + source, + steps: [], + testLineNumber: 1, + }; + + let parse: Parse> | undefined = parseCache[cacheKey]; + + if (parse === undefined) { + const statements = getStatements(stepTokens); + + parse = createParseFunction>({ + comments, + onError: onGlobalError, + statements, + }); + + parseCache[cacheKey] = parse as Parse>; + } + + parse(context, source); + + if (context.name === undefined) { + throwError(context, 'Test file contains no tests', 0); + } + + const {name, options, steps, testLineNumber} = context; + + return {name, options, steps, testLineNumber}; +}; diff --git a/src/parseTest/stepHandlers.ts b/src/parseTest/stepHandlers.ts new file mode 100644 index 00000000..32dace9e --- /dev/null +++ b/src/parseTest/stepHandlers.ts @@ -0,0 +1,41 @@ +import {getLineColumnByIndex} from './getLineColumnByIndex'; +import {parseStringLiteral} from './parseStringLiteral'; +import {throwError} from './throwError'; + +import type {OnParse} from 'parse-statements'; + +import type {Mutable, ParsedStep, ParseTestContext} from '../types/internal'; + +/** + * Get handler for parsing step by kind. + * @internal + */ +export const getOnStepParse = + (kind: string): OnParse => + (context, source, {start, end}) => { + if (context.name === undefined) { + throwError(context, `Step "${kind}" precedes the test function`, start, end); + } + + const lineColumn = getLineColumnByIndex(context, start); + + const step: Mutable = {definition: undefined, kind, ...lineColumn}; + + context.steps.push(step); + + const unparsed = source.slice(end).trimStart(); + + const char = unparsed[0]; + + if (char !== "'" && char !== '"' && char !== '`') { + return; + } + + const {index, text} = parseStringLiteral(char, unparsed); + + if (index === -1) { + throwError(context, 'Cannot find end of step definition string', start, end); + } + + step.definition = text; + }; diff --git a/src/parseTest/stripComments.ts b/src/parseTest/stripComments.ts new file mode 100644 index 00000000..bf1020ff --- /dev/null +++ b/src/parseTest/stripComments.ts @@ -0,0 +1,34 @@ +import type {CommentPair} from 'parse-statements'; + +/** + * Strips comments from string interval from source. + * @internal + */ +export const stripComments = ( + source: string, + intervalStart: number, + intervalEnd: number, + comments: readonly CommentPair[] | undefined, + // eslint-disable-next-line @typescript-eslint/max-params +): string => { + if (comments === undefined) { + return source.slice(intervalStart, intervalEnd); + } + + let currentStart = intervalStart; + const parts: string[] = []; + + for (const [{start, token}, {end}] of comments) { + if (token === "'" || token === '"' || token === '`') { + continue; + } + + parts.push(source.slice(currentStart, start)); + + currentStart = end; + } + + parts.push(source.slice(currentStart, intervalEnd)); + + return parts.join(''); +}; diff --git a/src/parseTest/testHandlers.ts b/src/parseTest/testHandlers.ts new file mode 100644 index 00000000..7e933b29 --- /dev/null +++ b/src/parseTest/testHandlers.ts @@ -0,0 +1,78 @@ +import {getLineColumnByIndex} from './getLineColumnByIndex'; +import {parseOptions} from './parseOptions'; +import {parseStringLiteral} from './parseStringLiteral'; +import {stripComments} from './stripComments'; +import {throwError} from './throwError'; + +import type {OnParse} from 'parse-statements'; + +import type {ParseTestContext} from '../types/internal'; + +/** + * Error handler for parsing test header. + * @internal + */ +export const onTestError: OnParse = (context, _source, {start}) => { + throwError(context, 'Cannot find end of test definition', start); +}; + +/** + * Handler for parsing test header. + * @internal + */ +// eslint-disable-next-line complexity +export const onTestParse: OnParse = ( + context, + source, + {start, end: unparsedStart, comments}, + {start: unparsedEnd, end}, + // eslint-disable-next-line @typescript-eslint/max-params +) => { + if (context.name !== undefined) { + throwError( + context, + `Test file contains second test.\nFirst: "${context.name}",\n${JSON.stringify(context.options ?? {})}`, + start, + end, + ); + } + + let unparsed = stripComments(source, unparsedStart, unparsedEnd, comments).trim(); + + const quoteCharacter = unparsed[0]; + + if (quoteCharacter !== "'" && quoteCharacter !== '"' && quoteCharacter !== '`') { + throwError(context, 'Cannot find start of test name string', start, end); + } + + const {index, text: name} = parseStringLiteral(quoteCharacter, unparsed); + + if (index === -1) { + throwError(context, 'Cannot find end of test name string', start, end); + } + + context.name = name; + context.testLineNumber = getLineColumnByIndex(context, start).line; + + unparsed = unparsed.slice(index + 1).trimStart(); + + if (unparsed[0] !== ',' || unparsed.at(-1) !== ',') { + throwError(context, 'Incorrect list of arguments of test function', start, end); + } + + unparsed = unparsed.slice(1, -1).trim(); + + if (unparsed === '') { + return; + } + + if (unparsed[0] !== '{' || unparsed.at(-1) !== '}') { + throwError(context, 'Second argument of test function is not an options object', start, end); + } + + try { + context.options = parseOptions(unparsed); + } catch (error) { + throwError(context, `Cannot parse options object: ${String(error)}`, start, end); + } +}; diff --git a/src/parseTest/throwError.ts b/src/parseTest/throwError.ts new file mode 100644 index 00000000..23e05206 --- /dev/null +++ b/src/parseTest/throwError.ts @@ -0,0 +1,28 @@ +import {getLineColumnByIndex} from './getLineColumnByIndex'; +import {ParseTestError} from './ParseTestError'; + +import type {LineColumn, ParseTestContext} from '../types/internal'; + +const defaultStatementLength = 400; + +/** + * Throw `ParseTestError`. + * @internal + */ +export const throwError: ( + context: ParseTestContext, + message: string, + start: number, + end?: number, + // eslint-disable-next-line @typescript-eslint/max-params +) => never = (context, message, start, end = start + defaultStatementLength) => { + const lineColumn = getLineColumnByIndex(context, start); + + const error = new ParseTestError(message); + + Object.assign(error, lineColumn); + + error.source = context.source.slice(start, end); + + throw error; +}; diff --git a/src/types/index.ts b/src/types/index.ts index b243ed72..b4e9ae06 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -62,6 +62,7 @@ export type { PageClassType, PageClassTypeArgs, } from './pages'; +export type {LineColumn, ParsedStep, ParsedTest} from './parseTest'; export type { AbsolutePathToDirectory, DirectoryPathFromRoot, diff --git a/src/types/internal.ts b/src/types/internal.ts index 5f7145c0..4dcdb012 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -102,6 +102,9 @@ export type { PageClassType, PageClassTypeArgs, } from './pages'; +export type {LineColumn, ParsedStep, ParsedTest} from './parseTest'; +/** @internal */ +export type {ParseTestContext} from './parseTest'; export type { AbsolutePathToDirectory, DirectoryPathFromRoot, diff --git a/src/types/parseTest.ts b/src/types/parseTest.ts new file mode 100644 index 00000000..38e68e89 --- /dev/null +++ b/src/types/parseTest.ts @@ -0,0 +1,42 @@ +import type {Mutable} from './utils'; + +/** + * Parse test context. + * @internal + */ +export type ParseTestContext = { + lineColumnCache: Record; + linesIndexes: readonly number[]; + name: string | undefined; + options: ParsedTest['options'] | undefined; + source: string; + steps: Mutable>[]; + testLineNumber: number; +}; + +/** + * Line and column as position in source text. + */ +export type LineColumn = Readonly<{ + column: number; + line: number; +}>; + +/** + * Parsed step object. + */ +export type ParsedStep = Readonly<{ + definition: string | undefined; + kind: StepKind; +}> & + LineColumn; + +/** + * Parsed test object. + */ +export type ParsedTest = Readonly<{ + name: string; + options: Readonly> | undefined; + steps: readonly ParsedStep[]; + testLineNumber: number; +}>; diff --git a/src/utils/testFilePaths/collectTestFilePaths.ts b/src/utils/testFilePaths/collectTestFilePaths.ts index fe9c0281..f3dcb999 100644 --- a/src/utils/testFilePaths/collectTestFilePaths.ts +++ b/src/utils/testFilePaths/collectTestFilePaths.ts @@ -16,8 +16,8 @@ export const collectTestFilePaths = async (): Promise = const {testFileGlobs} = getFullPackConfig(); const rawTestFilesPaths: string[] = []; - for await (const directory of glob(testFileGlobs as string[])) { - rawTestFilesPaths.push(directory); + for await (const path of glob(testFileGlobs)) { + rawTestFilesPaths.push(path); } const testFilesPaths = rawTestFilesPaths