From 4bfee8096060111cf073224e5efd5f55bde88462 Mon Sep 17 00:00:00 2001 From: uid11 Date: Fri, 28 Aug 2026 20:13:38 +0300 Subject: [PATCH] PRO-22458 feat: sort all data in CodeReport chore: update CODE_OF_CONDUCT to 3.0 fix: readFilesByGlobs use liner number of promises fix: add testIdentifier to test and scenario references --- CODE_OF_CONDUCT.md | 159 ++++-------- README.md | 2 +- autotests/tests/codeReport.ts | 241 +++++++++++++++++- src/step.ts | 12 +- src/types/codeReport.ts | 2 + src/utils/clone/cloneWithSortedFields.ts | 23 ++ .../clone/cloneWithoutUndefinedProperties.ts | 10 +- src/utils/clone/index.ts | 1 + src/utils/fs/readFilesByGlobs.ts | 94 ++++--- src/utils/index.ts | 4 +- src/utils/parse/codeReport/fillLinks.ts | 2 +- src/utils/parse/codeReport/fillReport.ts | 4 +- src/utils/parse/codeReport/fillTestErrors.ts | 12 +- src/utils/parse/codeReport/getCodeReport.ts | 1 + .../parse/codeReport/getScenarioReference.ts | 14 +- .../getScenarioStepsWithReference.ts | 3 +- .../parse/codeReport/getTestReference.ts | 9 +- src/utils/parse/codeReport/index.ts | 2 + .../parse/codeReport/processScenarios.ts | 1 + src/utils/parse/codeReport/sortAllFields.ts | 31 +++ src/utils/parse/index.ts | 2 +- src/utils/parse/parseTest/comments.ts | 2 +- src/utils/userland/getProjectSettings.ts | 3 +- 23 files changed, 454 insertions(+), 180 deletions(-) create mode 100644 src/utils/clone/cloneWithSortedFields.ts create mode 100644 src/utils/parse/codeReport/sortAllFields.ts diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index d383e604..8b005d5a 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,132 +1,83 @@ -# Contributor Covenant Code of Conduct +# Contributor Covenant 3.0 Code of Conduct ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. +We pledge to make our community welcoming, safe, and equitable for all. -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant. -## Our Standards +## Encouraged Behaviors -Examples of behavior that contributes to a positive environment for our -community include: +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language. -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the overall - community +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including: -Examples of unacceptable behavior include: +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. -- The use of sexualized language or imagery, and sexual attention or advances of - any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email address, - without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting +## Restricted Behaviors -## Enforcement Responsibilities +We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct. -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people. +3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community. +5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. +### Other Restrictions -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -uid.11222@gmail.com. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors. -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: +## Reporting an Issue -### 1. Correction +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm. -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. +When an incident does occur, it is important to report it promptly. To report a possible violation, send an email to uid.11222@gmail.com. -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. +Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution. -### 2. Warning +## Addressing and Repairing Harm -**Community Impact**: A violation through a single incident or series of -actions. +If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped. -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or permanent -ban. +1. Warning + 1. Event: A violation involving a single incident or series of incidents. + 2. Consequence: A private, written warning from the Community Moderators. + 3. Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations. +2. Temporarily Limited Activities + 1. Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation. + 2. Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members. + 3. Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over. +3. Temporary Suspension + 1. Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation. + 2. Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions. + 3. Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4. Permanent Ban + 1. Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member. + 2. Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior. + 3. Repair: There is no possible repair in cases of this severity. -### 3. Temporary Ban +This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community. -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. +## Scope -**Consequence**: A permanent ban from any sort of public interaction within the -community. +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. - -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][mozilla coc]. +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). -For answers to common questions about this code of conduct, see the FAQ at -[https://www.contributor-covenant.org/faq][faq]. Translations are available at -[https://www.contributor-covenant.org/translations][translations]. +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this license, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html -[mozilla coc]: https://github.com/mozilla/diversity -[faq]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations +For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion). diff --git a/README.md b/README.md index e1393a12..0092ab12 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ If the variable is not set, the default value of `16` is used. [MIT][license-url] -[code-of-conduct-image]: https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg 'Contributor Covenant Code of Conduct' +[code-of-conduct-image]: https://img.shields.io/badge/Contributor%20Covenant-3.0-4baaaa.svg 'Contributor Covenant Code of Conduct' [code-of-conduct-url]: CODE_OF_CONDUCT.md [conventional-commits-image]: https://img.shields.io/badge/Conventional_Commits-1.0.0-yellow.svg 'The Conventional Commits specification' [conventional-commits-url]: https://www.conventionalcommits.org/en/v1.0.0/ diff --git a/autotests/tests/codeReport.ts b/autotests/tests/codeReport.ts index d095d64b..63d07c05 100644 --- a/autotests/tests/codeReport.ts +++ b/autotests/tests/codeReport.ts @@ -2,7 +2,7 @@ import {test} from 'autotests'; import {expect} from 'e2ed'; -import {assertValueIsDefined} from 'e2ed/utils'; +import {assertValueIsDefined, readFilesByGlobs} from 'e2ed/utils'; import {getCodeReport} from 'e2ed/utils/parse'; import type { @@ -65,7 +65,7 @@ async function* toAsyncIterable(files: readonly SourceFile[]): AsyncGenerator { const projectFeaturePath = 'autotests/specs/codeReport.feature' as SourcePath; const selfTestPath = 'autotests/tests/codeReport.ts' as SourcePath; @@ -74,6 +74,11 @@ test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, asyn await expect(projectReport.durationInMs, 'Code report has non-negative duration').gte(0); + await expect( + projectReport.testIdentifierKey, + 'Report contains test identifier key from project settings', + ).eql('testId'); + await expect(Object.keys(projectReport.invalidFeatures), 'Project has no invalid features').eql( [], ); @@ -204,7 +209,7 @@ test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, asyn 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.`, + `The scenario "Scenario without steps linked to test" (testId=36) in ${projectFeaturePath}:29:3 has no steps.`, ]); await expect(projectReport.testsByTestIdentifier['36'], 'This test is present in tests map').eql( @@ -315,13 +320,13 @@ test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, asyn 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.', + 'Step "Then c" in f.feature:7:5 (in scenario "Bad scenario" (testId=101) in f.feature:4:3) is missing from test "Bad" (testId=101) in t.ts:1:1.', + 'The test "Bad" (testId=101) in t.ts:1:1 has an extra step "Then d" in t.ts:4:3 that is absent from scenario "Bad scenario" (testId=101) 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).', + '"Given a" in f.feature:5:5 (in scenario "Bad scenario" (testId=101) in f.feature:4:3),', + '"When b" in f.feature:6:5 (in scenario "Bad scenario" (testId=101) 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.', @@ -366,9 +371,9 @@ test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, asyn [ '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).', + '"Given A" in order.feature:5:5 (in scenario "Order scenario" (testId=106) in order.feature:4:3),', + '"Then C" in order.feature:7:5 (in scenario "Order scenario" (testId=106) in order.feature:4:3),', + '"And D" in order.feature:8:5 (in scenario "Order scenario" (testId=106) 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,', @@ -516,7 +521,7 @@ test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, asyn 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.', + 'The test "T" (testId=107) in triple.ts:1:1 has an extra step "Then c" (occurrence 3) in triple.ts:4:3 that is absent from scenario "S" (testId=107) in triple.feature:4:3.', ]); const emptyStepReport = await getCodeReport({ @@ -559,9 +564,9 @@ test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, asyn 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.', + 'Step in empty.ts:3:3 (in test "T" (testId=104) in empty.ts:1:1) has no definition.', + 'Step in empty.ts:4:3 (in test "T" (testId=104) in empty.ts:1:1) has no definition.', + 'Step "Then b" in empty.feature:6:5 (in scenario "S" (testId=104) in empty.feature:4:3) is missing from test "T" (testId=104) in empty.ts:1:1.', ]); await expect( @@ -781,6 +786,11 @@ test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, asyn const caseScenario = getScenario(caseReport, 'case.feature/[0]'); const caseTest = getTestReport(caseReport, 'case.ts'); + await expect( + caseReport.testIdentifierKey, + 'Custom key: report contains test identifier key from options', + ).eql('caseId'); + await expect( caseTest.testIdentifier, 'Custom key: test identifier is read from caseId meta property', @@ -910,6 +920,23 @@ test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, asyn ).ok(); } + const unreachablePaths: string[] = []; + + try { + for await (const {path} of readFilesByGlobs([null as unknown as string])) { + unreachablePaths.push(path); + } + + throw new Error('Unreachable'); + } catch (error) { + await expect( + error instanceof TypeError && error.message.includes('patterns'), + 'readFilesByGlobs throws glob error for invalid pattern', + ).ok(); + } + + await expect(unreachablePaths, 'Invalid pattern yields no files').eql([]); + const emptyReport = await getCodeReport({features: [], tests: []}); await expect(Object.keys(emptyReport.features), 'Empty report has no features').eql([]); @@ -936,4 +963,190 @@ test('getCodeReport(...) function works correctly', {meta: {testId: '36'}}, asyn ).eql([]); await expect(emptyReport.durationInMs, 'Empty report has non-negative duration').gte(0); + + const orderFeatureFiles: readonly SourceFile[] = [ + { + path: 'orderB.feature', + source: [ + 'Feature: OrderB', + '', + ' @testId-202', + ' Scenario: B first', + ' Given b', + '', + ' @testId-203', + ' Scenario: B second', + ' Given b', + '', + ].join('\n'), + }, + { + path: 'orderA.feature', + source: [ + 'Feature: OrderA', + '', + ' @testId-201', + ' Scenario: A only', + ' Given a', + '', + ].join('\n'), + }, + ]; + const orderTestFiles: readonly SourceFile[] = [ + {path: 'orderB.ts', source: "test('B', {meta: {testId: '202'}}, async () => {});"}, + {path: 'orderA.ts', source: "test('A', {meta: {testId: '201'}}, async () => {});"}, + ]; + + const forwardReport = await getCodeReport({features: orderFeatureFiles, tests: orderTestFiles}); + const reversedReport = await getCodeReport({ + features: [...orderFeatureFiles].reverse(), + tests: [...orderTestFiles].reverse(), + }); + + await expect(Object.keys(forwardReport.features), 'Features are sorted by path').eql([ + 'orderA.feature', + 'orderB.feature', + ]); + + await expect(Object.keys(forwardReport.tests), 'Tests are sorted by path').eql([ + 'orderA.ts', + 'orderB.ts', + ]); + + await expect( + Object.keys(forwardReport.scenarios), + 'Scenarios are sorted by feature path and by index inside feature', + ).eql(['orderA.feature/[0]', 'orderB.feature/[0]', 'orderB.feature/[1]']); + + await expect( + getScenario(forwardReport, 'orderB.feature/[1]').indexInFeature, + 'Scenario has index inside feature', + ).eql(1); + + for (const reportKey of [ + 'features', + 'invalidFeatures', + 'invalidTests', + 'scenarios', + 'scenariosByTestIdentifier', + 'tests', + 'testsByTestIdentifier', + ] as const) { + await expect( + Object.keys(reversedReport[reportKey]), + `Keys order of ${reportKey} does not depend on the order of source files`, + ).eql(Object.keys(forwardReport[reportKey])); + } + + await expect( + reversedReport.scenarios, + 'Scenarios content does not depend on the order of source files', + ).eql(forwardReport.scenarios); + + await expect( + reversedReport.tests, + 'Tests content does not depend on the order of source files', + ).eql(forwardReport.tests); + + const manyScenariosLines = ['Feature: Many']; + const expectedManyScenariosPaths: string[] = []; + + for (let scenarioIndex = 0; scenarioIndex <= 10; scenarioIndex += 1) { + manyScenariosLines.push('', ` Scenario: S${scenarioIndex}`, ' Given g'); + expectedManyScenariosPaths.push(`many.feature/[${scenarioIndex}]`); + } + + manyScenariosLines.push(''); + + const manyScenariosReport = await getCodeReport({ + features: [{path: 'many.feature', source: manyScenariosLines.join('\n')}], + tests: [], + }); + + await expect( + Object.keys(manyScenariosReport.scenarios), + 'Scenarios are sorted numerically by index inside feature, not alphabetically by path', + ).eql(expectedManyScenariosPaths); + + const duplicatesOrderTestFiles: readonly SourceFile[] = [ + {path: 'dupOrderZ.ts', source: "test('Z', {meta: {testId: '44'}}, async () => {});"}, + {path: 'dupOrderX.ts', source: "test('X', {meta: {testId: '44'}}, async () => {});"}, + {path: 'dupOrderY.ts', source: "test('Y', {meta: {testId: '44'}}, async () => {});"}, + ]; + + const duplicatesOrderFeatureFiles: readonly SourceFile[] = [ + { + path: 'dupOrderD.feature', + source: ['Feature: D', '', ' @testId-45', ' Scenario: D only', ' Given g', ''].join( + '\n', + ), + }, + { + path: 'dupOrderC.feature', + source: [ + 'Feature: C', + '', + ' @testId-45', + ' Scenario: C first', + ' Given g', + '', + ' @testId-45', + ' Scenario: C second', + ' Given g', + '', + ].join('\n'), + }, + ]; + + const duplicatesOrderReports: readonly (readonly [label: string, codeReport: CodeReport])[] = [ + [ + 'forward', + await getCodeReport({features: duplicatesOrderFeatureFiles, tests: duplicatesOrderTestFiles}), + ], + [ + 'reversed', + await getCodeReport({ + features: [...duplicatesOrderFeatureFiles].reverse(), + tests: [...duplicatesOrderTestFiles].reverse(), + }), + ], + ]; + + for (const [label, duplicatesOrderReport] of duplicatesOrderReports) { + await expect( + getTestReport(duplicatesOrderReport, 'dupOrderX.ts').duplicatesByTestIdentifier.map(String), + `Duplicates of the first test are sorted by path (${label} order)`, + ).eql(['dupOrderY.ts', 'dupOrderZ.ts']); + + await expect( + getTestReport(duplicatesOrderReport, 'dupOrderY.ts').duplicatesByTestIdentifier.map(String), + `Duplicates of the middle test are sorted by path (${label} order)`, + ).eql(['dupOrderX.ts', 'dupOrderZ.ts']); + + await expect( + getTestReport(duplicatesOrderReport, 'dupOrderZ.ts').duplicatesByTestIdentifier.map(String), + `Duplicates of the last test are sorted by path (${label} order)`, + ).eql(['dupOrderX.ts', 'dupOrderY.ts']); + + await expect( + getScenario(duplicatesOrderReport, 'dupOrderC.feature/[0]').duplicatesByTestIdentifier.map( + String, + ), + `Duplicates of the first scenario are sorted by feature path and index (${label} order)`, + ).eql(['dupOrderC.feature/[1]', 'dupOrderD.feature/[0]']); + + await expect( + getScenario(duplicatesOrderReport, 'dupOrderC.feature/[1]').duplicatesByTestIdentifier.map( + String, + ), + `Duplicates of the second scenario are sorted by feature path and index (${label} order)`, + ).eql(['dupOrderC.feature/[0]', 'dupOrderD.feature/[0]']); + + await expect( + getScenario(duplicatesOrderReport, 'dupOrderD.feature/[0]').duplicatesByTestIdentifier.map( + String, + ), + `Duplicates of the scenario from another feature are sorted (${label} order)`, + ).eql(['dupOrderC.feature/[0]', 'dupOrderC.feature/[1]']); + } }); diff --git a/src/step.ts b/src/step.ts index 2df1186f..26fab97d 100644 --- a/src/step.ts +++ b/src/step.ts @@ -104,7 +104,7 @@ export const step = async ( */ export const Given = ( name: string, - body?: StepBody, + body: StepBody, options: Omit = {}, ): Promise => step(name, body, {...options, type: LogEventType.Given}); @@ -113,7 +113,7 @@ export const Given = ( */ export const When = ( name: string, - body?: StepBody, + body: StepBody, options: Omit = {}, ): Promise => step(name, body, {...options, type: LogEventType.When}); @@ -122,7 +122,7 @@ export const When = ( */ export const Then = ( name: string, - body?: StepBody, + body: StepBody, options: Omit = {}, ): Promise => step(name, body, {...options, type: LogEventType.Then}); @@ -131,7 +131,7 @@ export const Then = ( */ export const And = ( name: string, - body?: StepBody, + body: StepBody, options: Omit = {}, ): Promise => step(name, body, {...options, type: LogEventType.And}); @@ -140,7 +140,7 @@ export const And = ( */ export const But = ( name: string, - body?: StepBody, + body: StepBody, options: Omit = {}, ): Promise => step(name, body, {...options, type: LogEventType.But}); @@ -149,6 +149,6 @@ export const But = ( */ export const Star = ( name: string, - body?: StepBody, + body: StepBody, options: Omit = {}, ): Promise => step(name, body, {...options, type: LogEventType.Star}); diff --git a/src/types/codeReport.ts b/src/types/codeReport.ts index 488ed7f1..bdd7f760 100644 --- a/src/types/codeReport.ts +++ b/src/types/codeReport.ts @@ -17,6 +17,7 @@ export type CodeReport< invalidTests: Readonly>; scenarios: Readonly>>; scenariosByTestIdentifier: Readonly>; + testIdentifierKey: string; tests: Readonly>>; testsByTestIdentifier: Readonly>; }>; @@ -51,6 +52,7 @@ export type ScenarioReport< duplicatesByTestIdentifier: readonly SourcePath[]; errors: readonly string[]; featurePath: SourcePath; + indexInFeature: number; name: string; path: SourcePath; } & ( diff --git a/src/utils/clone/cloneWithSortedFields.ts b/src/utils/clone/cloneWithSortedFields.ts new file mode 100644 index 00000000..7ef117a7 --- /dev/null +++ b/src/utils/clone/cloneWithSortedFields.ts @@ -0,0 +1,23 @@ +type Pair = [key: Key, value: Value]; + +/** + * Clones objects with sorted fields. + */ +export const cloneWithSortedFields = ( + object: Readonly>, + compareFn: (pairA: Pair, pairB: Pair) => number = ([keyA], [keyB]) => + // eslint-disable-next-line no-nested-ternary + keyA > keyB ? 1 : keyA < keyB ? -1 : 0, + result: Record = Object.create(null) as Record, +): Readonly> => { + const pairs = Object.entries(object) as Pair[]; + + pairs.sort(compareFn); + + for (const [key, value] of pairs) { + // eslint-disable-next-line no-param-reassign + result[key] = value; + } + + return result; +}; diff --git a/src/utils/clone/cloneWithoutUndefinedProperties.ts b/src/utils/clone/cloneWithoutUndefinedProperties.ts index e2bdf2c0..1a55b1a6 100644 --- a/src/utils/clone/cloneWithoutUndefinedProperties.ts +++ b/src/utils/clone/cloneWithoutUndefinedProperties.ts @@ -6,14 +6,14 @@ import type {CloneWithoutUndefinedProperties, ObjectEntries} from '../../types/i * Clone object without properties that values is `undefined`. */ export const cloneWithoutUndefinedProperties = ( - x: Type, + object: Type, ): CloneWithoutUndefinedProperties => { - const result = {} as unknown as CloneWithoutUndefinedProperties; + const result = Object.create(null) as CloneWithoutUndefinedProperties; - for (const [key, value] of Object.entries(x) as ObjectEntries) { - const descriptor = Object.getOwnPropertyDescriptor(x, key); + for (const [key, value] of Object.entries(object) as ObjectEntries) { + const descriptor = Object.getOwnPropertyDescriptor(object, key); - assertValueIsDefined(descriptor, 'descriptor is defined', {key, x}); + assertValueIsDefined(descriptor, 'descriptor is defined', {key, object}); if (value !== undefined) { Object.defineProperty(result, key, descriptor); diff --git a/src/utils/clone/index.ts b/src/utils/clone/index.ts index f932071d..4b65d6d9 100644 --- a/src/utils/clone/index.ts +++ b/src/utils/clone/index.ts @@ -1,3 +1,4 @@ /** @internal */ export {cloneWithoutLogEvents} from './cloneWithoutLogEvents'; export {cloneWithoutUndefinedProperties} from './cloneWithoutUndefinedProperties'; +export {cloneWithSortedFields} from './cloneWithSortedFields'; diff --git a/src/utils/fs/readFilesByGlobs.ts b/src/utils/fs/readFilesByGlobs.ts index f1a05cb8..20a67c78 100644 --- a/src/utils/fs/readFilesByGlobs.ts +++ b/src/utils/fs/readFilesByGlobs.ts @@ -3,43 +3,72 @@ import {normalize} from 'node:path'; import type {SourceFile} from '../../types/internal'; -const POOL_UPDATED = Symbol('poolUpdated'); - type ReadResult = Readonly< - {key: number; path: string} & ({error: unknown; ok: false} | {ok: true; text: string}) + {path: string} & ({error: unknown; ok: false} | {ok: true; text: string}) >; /** * Reads files by glob patterns. */ +// eslint-disable-next-line max-statements export async function* readFilesByGlobs( patterns: readonly string[], filterByPath: (path: string) => boolean = () => true, ): AsyncGenerator { - const readsInFlight = new Map>(); + const completedReads = new Set(); const seenPaths = new Set(); - let nextKey = 0; let globsInFlight = patterns.length; + let isFinished = false; + let readsInFlight = 0; let globError: unknown; let signalUpdate!: () => void; - let update!: Promise; + let update!: Promise; const resetUpdate = (): void => { update = new Promise((resolve) => { - signalUpdate = () => resolve(POOL_UPDATED); + signalUpdate = resolve; }); }; resetUpdate(); + const startRead = (path: string): void => { + readsInFlight += 1; + + void readFile(path, 'utf8').then( + (text) => { + readsInFlight -= 1; + + if (!isFinished) { + completedReads.add({ok: true, path, text}); + } + + signalUpdate(); + }, + (error: unknown) => { + readsInFlight -= 1; + + if (!isFinished) { + completedReads.add({error, ok: false, path}); + } + + signalUpdate(); + }, + ); + }; + for (const pattern of patterns) { // eslint-disable-next-line @typescript-eslint/no-loop-func void (async () => { try { for await (const rawPath of glob(pattern)) { - const path = normalize(String(rawPath)); + if (isFinished) { + return; + } + + const path = normalize(rawPath); if (seenPaths.has(path)) { continue; @@ -51,18 +80,7 @@ export async function* readFilesByGlobs( continue; } - nextKey += 1; - - const key = nextKey; - - readsInFlight.set( - key, - readFile(path, 'utf8').then( - (text) => ({key, ok: true as const, path, text}), - (error: unknown) => ({error, key, ok: false as const, path}), - ), - ); - signalUpdate(); + startRead(path); } } catch (error) { globError ??= error; @@ -73,25 +91,35 @@ export async function* readFilesByGlobs( })(); } - while (readsInFlight.size > 0 || globsInFlight > 0) { - const result = await Promise.race([update, ...readsInFlight.values()]); - - if (result === POOL_UPDATED) { - resetUpdate(); - + try { + while (completedReads.size > 0 || readsInFlight > 0 || globsInFlight > 0) { if (globError !== undefined) { throw globError; } - continue; - } + if (completedReads.size === 0) { + await update; + resetUpdate(); - readsInFlight.delete(result.key); + continue; + } + + for (const result of completedReads) { + completedReads.delete(result); + + // eslint-disable-next-line max-depth + if (result.ok) { + yield {path: result.path, source: result.text}; + } else { + throw result.error; + } + } + } - if (result.ok) { - yield {path: result.path, source: result.text}; - } else { - throw result.error; + if (globError !== undefined) { + throw globError; } + } finally { + isFinished = true; } } diff --git a/src/utils/index.ts b/src/utils/index.ts index f730817d..e78e2f41 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -13,7 +13,7 @@ export { assertValueIsTrue, assertValueIsUndefined, } from './asserts'; -export {cloneWithoutUndefinedProperties} from './clone'; +export {cloneWithoutUndefinedProperties, cloneWithSortedFields} from './clone'; export {getFullPackConfig as untypedGetFullPackConfig} from './config'; export { assertStringIsSameSite, @@ -45,6 +45,8 @@ export {createPageObjectsFromMultiLocator} from './pageObjects'; export { getCodeReport, getLinesIndexes, + getScenarioReference, + getTestReference, parseMaybeEmptyValueAsJson, parseTest, ParseTestError, diff --git a/src/utils/parse/codeReport/fillLinks.ts b/src/utils/parse/codeReport/fillLinks.ts index bd543ee5..a9791b25 100644 --- a/src/utils/parse/codeReport/fillLinks.ts +++ b/src/utils/parse/codeReport/fillLinks.ts @@ -39,7 +39,7 @@ export const fillLinks = (codeReport: CodeReport): void => { setReadonlyProperty(test, 'featurePath', scenario.featurePath); setReadonlyProperty(test, 'scenarioPath', scenarioPath); - fillTestErrors(scenario, test); + fillTestErrors(scenario, test, codeReport.testIdentifierKey); } } }; diff --git a/src/utils/parse/codeReport/fillReport.ts b/src/utils/parse/codeReport/fillReport.ts index 6b09435b..2ab3e277 100644 --- a/src/utils/parse/codeReport/fillReport.ts +++ b/src/utils/parse/codeReport/fillReport.ts @@ -1,13 +1,15 @@ import {fillDuplicates} from './fillDuplicates'; import {fillLinks} from './fillLinks'; +import {sortAllFields} from './sortAllFields'; import type {CodeReport} from '../../../types/internal'; /** - * Fill code report internal fields. + * Fills code report internal fields. * @internal */ export const fillReport = (codeReport: CodeReport): void => { + sortAllFields(codeReport); fillDuplicates(codeReport.scenarios); fillDuplicates(codeReport.tests); fillLinks(codeReport); diff --git a/src/utils/parse/codeReport/fillTestErrors.ts b/src/utils/parse/codeReport/fillTestErrors.ts index cf607553..1ded7204 100644 --- a/src/utils/parse/codeReport/fillTestErrors.ts +++ b/src/utils/parse/codeReport/fillTestErrors.ts @@ -10,11 +10,15 @@ 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 => { +export const fillTestErrors = ( + scenario: ScenarioReport, + test: TestReport, + testIdentifierKey: string, +): void => { let scenarioHasError = false; const errors = test.errors as string[]; - const scenarioReference = getScenarioReference(scenario); - const testReference = getTestReference(test); + const scenarioReference = getScenarioReference(scenario, testIdentifierKey); + const testReference = getTestReference(test, testIdentifierKey); if (scenario.steps.length === 0) { scenarioHasError = true; @@ -42,7 +46,7 @@ export const fillTestErrors = (scenario: ScenarioReport, test: TestReport): void return; } - const scenarioSteps = getScenarioStepsWithReference(scenario); + const scenarioSteps = getScenarioStepsWithReference(scenario, testIdentifierKey); const testSteps = getTestStepsWithReference(test); const comparisonErrors = getStepComparisonErrors({ diff --git a/src/utils/parse/codeReport/getCodeReport.ts b/src/utils/parse/codeReport/getCodeReport.ts index 5e40609c..4a9afd90 100644 --- a/src/utils/parse/codeReport/getCodeReport.ts +++ b/src/utils/parse/codeReport/getCodeReport.ts @@ -56,6 +56,7 @@ export const getCodeReport = async < invalidTests: Object.create(null) as {}, scenarios: Object.create(null) as {}, scenariosByTestIdentifier: Object.create(null) as CodeReport['scenariosByTestIdentifier'], + testIdentifierKey, tests: Object.create(null) as {}, testsByTestIdentifier: Object.create(null) as CodeReport['testsByTestIdentifier'], }; diff --git a/src/utils/parse/codeReport/getScenarioReference.ts b/src/utils/parse/codeReport/getScenarioReference.ts index a705ff92..4f013151 100644 --- a/src/utils/parse/codeReport/getScenarioReference.ts +++ b/src/utils/parse/codeReport/getScenarioReference.ts @@ -2,7 +2,15 @@ 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}`; +export const getScenarioReference = ( + scenario: ScenarioReport, + testIdentifierKey: string, +): string => { + const maybeTestIdentifier = + scenario.testIdentifier === undefined + ? '' + : ` (${testIdentifierKey}=${scenario.testIdentifier})`; + + return `scenario "${scenario.name}"${maybeTestIdentifier} 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 index 08a56f37..5e40aaff 100644 --- a/src/utils/parse/codeReport/getScenarioStepsWithReference.ts +++ b/src/utils/parse/codeReport/getScenarioStepsWithReference.ts @@ -8,8 +8,9 @@ import type {ScenarioReport, StepWithReference} from '../../../types/internal'; */ export const getScenarioStepsWithReference = ( scenario: ScenarioReport, + testIdentifierKey: string, ): readonly StepWithReference[] => { - const scenarioReference = getScenarioReference(scenario); + const scenarioReference = getScenarioReference(scenario, testIdentifierKey); const steps: StepWithReference[] = []; const stepsHash: Record = Object.create(null) as {}; diff --git a/src/utils/parse/codeReport/getTestReference.ts b/src/utils/parse/codeReport/getTestReference.ts index 037e6a80..8e5d91b4 100644 --- a/src/utils/parse/codeReport/getTestReference.ts +++ b/src/utils/parse/codeReport/getTestReference.ts @@ -2,7 +2,10 @@ 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`; +export const getTestReference = (test: TestReport, testIdentifierKey: string): string => { + const maybeTestIdentifier = + test.testIdentifier === undefined ? '' : ` (${testIdentifierKey}=${test.testIdentifier})`; + + return `test "${test.name}"${maybeTestIdentifier} in ${test.path}:${test.testLineNumber}:1`; +}; diff --git a/src/utils/parse/codeReport/index.ts b/src/utils/parse/codeReport/index.ts index fc0e7155..bd95bf9a 100644 --- a/src/utils/parse/codeReport/index.ts +++ b/src/utils/parse/codeReport/index.ts @@ -1 +1,3 @@ export {getCodeReport} from './getCodeReport'; +export {getScenarioReference} from './getScenarioReference'; +export {getTestReference} from './getTestReference'; diff --git a/src/utils/parse/codeReport/processScenarios.ts b/src/utils/parse/codeReport/processScenarios.ts index 72a6eb92..a704795b 100644 --- a/src/utils/parse/codeReport/processScenarios.ts +++ b/src/utils/parse/codeReport/processScenarios.ts @@ -47,6 +47,7 @@ export const processScenarios = ({ duplicatesByTestIdentifier: [], errors, featurePath, + indexInFeature: index, name: scenario.Scenario, path, testIdentifier: undefined as string | undefined, diff --git a/src/utils/parse/codeReport/sortAllFields.ts b/src/utils/parse/codeReport/sortAllFields.ts new file mode 100644 index 00000000..0376a94c --- /dev/null +++ b/src/utils/parse/codeReport/sortAllFields.ts @@ -0,0 +1,31 @@ +// eslint-disable-next-line import/no-internal-modules +import {cloneWithSortedFields} from '../../clone/cloneWithSortedFields'; +// eslint-disable-next-line import/no-internal-modules +import {setReadonlyProperty} from '../../object/setReadonlyProperty'; + +import type {CodeReport} from '../../../types/internal'; + +/** + * Sorts all fields in code report (features, scenarios, tests). + * @internal + */ +export const sortAllFields = (codeReport: CodeReport): void => { + const features = cloneWithSortedFields(codeReport.features); + const invalidFeatures = cloneWithSortedFields(codeReport.invalidFeatures); + const invalidTests = cloneWithSortedFields(codeReport.invalidTests); + const scenarios = cloneWithSortedFields(codeReport.scenarios, ([, a], [, b]) => + // eslint-disable-next-line no-nested-ternary + a.featurePath < b.featurePath + ? -1 + : a.featurePath > b.featurePath + ? 1 + : a.indexInFeature - b.indexInFeature, + ); + const tests = cloneWithSortedFields(codeReport.tests); + + setReadonlyProperty(codeReport, 'features', features); + setReadonlyProperty(codeReport, 'invalidFeatures', invalidFeatures); + setReadonlyProperty(codeReport, 'invalidTests', invalidTests); + setReadonlyProperty(codeReport, 'scenarios', scenarios); + setReadonlyProperty(codeReport, 'tests', tests); +}; diff --git a/src/utils/parse/index.ts b/src/utils/parse/index.ts index c47ea5d5..81f0b9da 100644 --- a/src/utils/parse/index.ts +++ b/src/utils/parse/index.ts @@ -1,4 +1,4 @@ -export {getCodeReport} from './codeReport'; +export {getCodeReport, getScenarioReference, getTestReference} from './codeReport'; export {parseMaybeEmptyValueAsJson} from './parseMaybeEmptyValueAsJson'; export {getLinesIndexes, parseTest, ParseTestError} from './parseTest'; export {parseValueAsJsonIfNeeded} from './parseValueAsJsonIfNeeded'; diff --git a/src/utils/parse/parseTest/comments.ts b/src/utils/parse/parseTest/comments.ts index 1792b723..070a5c75 100644 --- a/src/utils/parse/parseTest/comments.ts +++ b/src/utils/parse/parseTest/comments.ts @@ -48,7 +48,7 @@ const onSinglelineCommentError: OnCommentError = (context, _so throwError(context, 'Cannot find end of single line comment', start); /** - * Statements of ECMAScript comments. + * Statements of ECMAScript comments and string literals. * @internal */ export const comments: readonly Comment[] = [ diff --git a/src/utils/userland/getProjectSettings.ts b/src/utils/userland/getProjectSettings.ts index 0cb3d978..bcfd6e1b 100644 --- a/src/utils/userland/getProjectSettings.ts +++ b/src/utils/userland/getProjectSettings.ts @@ -3,7 +3,8 @@ import {join} from 'node:path'; import { ABSOLUTE_PATH_TO_PROJECT_ROOT_DIRECTORY, PROJECT_SETTINGS_PATH, -} from '../../constants/internal'; + // eslint-disable-next-line import/no-internal-modules, import/no-restricted-paths +} from '../../constants/paths'; import type {ProjectSettings} from '../../types/internal';