diff --git a/docs/docs/config/01-grouping/01-by-suite.mdx b/docs/docs/config/01-grouping/01-by-suite.mdx index d6aeb4e..dd326a8 100644 --- a/docs/docs/config/01-grouping/01-by-suite.mdx +++ b/docs/docs/config/01-grouping/01-by-suite.mdx @@ -18,9 +18,9 @@ This is perhaps the most common way to group test results, and it makes the most ![Grouping by suite](../../../img/screenshots/config-01-grouping-01.jpg) -The suite hierarchy consists of up to four levels: **parent suite**, **suite**, **subsuite**, and **test case**. +The suite hierarchy consists of up to four levels: **parent suite**, **suite**, **sub-suite** and **test case**. -:::info +:::info Glossary
Parent Suite
@@ -42,10 +42,10 @@ Below we'll explore a few examples of how to configure the grouping by suite. ## Default preset -By default, `jest-allure2-reporter` provides 3 levels of grouping: **suite**, **subsuite**, and **test case**: +By default, `jest-allure2-reporter` provides 3 levels of grouping: **suite**, **sub-suite**, and **test case**: 1. The **suite** level is based on the _test file path_. -1. The **subsuite** level is based on the _top-level describe block_. +1. The **sub-suite** level is based on the _top-level describe block_. 1. The **test case** level is based on the _test name_ (including the inner describe block names). @@ -136,13 +136,19 @@ module.exports = { testEnvironment: 'jest-allure2-reporter/environment-node', reporters: [ 'default', - ['jest-allure2-reporter', /** @type {import('jest-allure2-reporter').Options}*/ + ['jest-allure2-reporter', + /** @type {import('jest-allure2-reporter').ReporterOptions}*/ { - labels: { - parentSuite: ({ file }) => file.pathSegments[0], - suite: ({ file }) => file.pathSegments.slice(1, -1).join(' '), - subsuite: ({ file }) => file.pathSegments.slice(-1)[0], - test: ({ test }) => test.fullName, + testCase: { + name: ({ testCase }) => [ + ...testCase.ancestorTitles, + testCase.title + ].join(' » '), + labels: { + parentSuite: ({ filePath }) => file.pathSegments[0], + suite: ({ filePath }) => file.pathSegments[1], + subSuite: ({ filePath }) => file.pathSegments.slice(2).join('/'), + }, }, }, ], @@ -200,7 +206,7 @@ module.exports = { labels: { parentSuite: ({ file }) => file.path, suite: ({ test }) => test.ancestorTitles[0], - subsuite: ({ test }) => test.ancestorTitles.slice(1).join(' ') || undefined, + subSuite: ({ test }) => test.ancestorTitles.slice(1).join(' ') || undefined, test: ({ test }) => test.title, }, }], diff --git a/docs/docs/config/01-grouping/02-by-story.mdx b/docs/docs/config/01-grouping/02-by-story.mdx index 0d908c5..5507519 100644 --- a/docs/docs/config/01-grouping/02-by-story.mdx +++ b/docs/docs/config/01-grouping/02-by-story.mdx @@ -14,22 +14,46 @@ anywhere. Please use GitHub docs for the latest stable version. ::: + +This grouping option is a concept that comes from the [Behavior-Driven Development](https://en.wikipedia.org/wiki/Behavior-driven_development) (BDD) methodology. +Unlike the [suite-based grouping](01-by-suite.mdx), which is based on the technical structure of your test suite, the story-based grouping +helps you to focus on the business value of your tests and view them from the end-user perspective. + ![Grouping by story](../../../img/screenshots/config-01-grouping-05.jpg) -This grouping option comes from the [Behavior-Driven Development](https://en.wikipedia.org/wiki/Behavior-driven_development) (BDD) methodology and -allows users to group test results based on the **epic**, **feature** and **story** to which each test case belongs, where: +The story-oriented hierarchy has 4 mandatory levels: **epic**, **feature**, **story** and **test case**. + +:::info Glossary -* **epic** is a high-level business goal. -* **feature** is a functionality that delivers business value. -* **story** is a user story that describes a feature from the end-user perspective. +
+
Epic
+
High-level business goal
-This grouping is not enabled by default. Moreover, you need to decide how exactly you want to enable it: via [configuration](#configuration-api) or [annotations](#annotations-api). +
Feature
+
Functionality that delivers business value
+ +
Story
+
User story that describes a feature from the end-user perspective
+ +
Test Case
+
Atomic, lowest-level unit. In Jest, it is a single it or test function.
+
+ +::: + +Before you start using this grouping option, you need to decide how exactly you want to implement it: + +* via [annotations](#annotations-api) – a more granular approach, which allows you to control the grouping on a per-test basis; +* via [configuration](#configuration-api) – a quick option to enable it all at once, based on general rules; +* via _mixing these approaches_ – a compromise between the two, where the configuration serves as a fallback for missing annotations. ## Annotations API -The [annotation-based approach](../../api/08-labels.mdx) gives you a fine-grained control over the names of your Epic, Feature and Story labels, but it requires you to add annotations to your test cases. +The [annotation-based approach](../../api/08-labels.mdx) gives you a fine-grained control over the names of your epic, feature and story labels, but it requires you to add annotations to _every and each test case_ (sic!) which can be tedious. -In the previous example, it would make sense to group both client and server tests under the same features like **Login screen** and **Forgot password screen**, whereas the epic would be **Authentication**. +Let's take the same project as in the previous article, where there are two parts: client and server. +Both them deal with the same functionality – authentication and restoring forgotten passwords. +Hence, it would make sense to group both client and server tests under the same epic named **Authentication**, and continue grouping them by features and stories regardless of the application layer. @@ -78,18 +102,47 @@ describe('Login controller', () => { +As mentioned before, the annotation-based approach requires you to annotate literally **every test case** with all +the three labels (epic, feature and story), otherwise the report will be stubbornly displaying a flat structure in **Behaviors** section. + +:::tip + +To relax the requirement to annotate all your test cases, you can add a fallback via configuration, e.g.: + +```js title="jest.config.js" +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + testEnvironment: 'jest-allure2-reporter/environment-node', + reporters: [ + 'default', + ['jest-allure2-reporter', /** @type {import('jest-allure2-reporter').Options}*/ { + labels: { + epic: ({ value }) => value ?? 'Uncategorized', + feature: ({ value }) => value ?? 'Untitled feature', + story: ({ value }) => value ?? 'Untitled story', + }, + }], + ], +}; +``` + +::: ## Configuration API -The **configuration-based approach** allows you to group test cases based on the available attributes like file path, ancestor describe blocks, test name and so on. +The **configuration-based approach** allows you to group test cases based on the available attributes like the test file path, the ancestor describe blocks and any +other contextually available information. -It's a good option if you don't want to add annotations to your test cases by hand, but it's less flexible than the annotation-based approach. Still, it might be useful if your grouping by suite focuses mostly [on the file structure](#file-oriented-example), and you want to add "a fresh perspective" by grouping tests by describe blocks and test names, for example. +It is much faster to implement than if you were to annotate every test case by hand, but it is also less flexible. +Still, there are many cases where it can be useful, especially if you have a large test suite and you want to add some structure to it. +For example, if your grouping by suite focuses mostly [on the file structure](01-by-suite.mdx#file-oriented-example), +the story-based grouping may add "a fresh perspective" by grouping tests by describe blocks and test names, for example. -Here's a simple example where we map: +Let's explore a simple example, where we'll map: * **epic** to the top-level describe block -* **feature** to the second-level describe block -* **story** to the remaining describe blocks and test name itself +* **feature** to the middle-level describe blocks +* **story** to the lowest-level describe block @@ -130,9 +183,9 @@ module.exports = { 'default', ['jest-allure2-reporter', /** @type {import('jest-allure2-reporter').Options}*/ { labels: { - epic: ({ test }) => test.ancestorTitles[0], - feature: ({ test }) => test.ancestorTitles.slice(1).join(' ') || undefined, - story: ({ test }) => test.title, + epic: ({ testCase }) => testCase.ancestorTitles.at(0) ?? '(uncategorized)', + feature: ({ testCase }) => testCase.ancestorTitles.slice(1, -1).join(' > ') ?? '(uncategorized)', + story: ({ testCase }) => testCase.ancestorTitles.at(-1) ?? '(uncategorized)', }, }], ], @@ -142,3 +195,43 @@ module.exports = { +## Many-to-many mapping + +It is worth mentioning that Allure allows you to map a test case to multiple epics, features and stories, but +you should use this feature with caution, as it may lead to a very complex report structure. + + + + +```js title="login.test.js" +it('should validate e-mail', () => { + /** + * @epic Authentication + * @feature Login screen + * @story Validation + * + * @epic Security + * @feature XSS prevention + * @story Login form + */ + + // ... +}); +``` + + + +```js title="login.test.js" +$Epic('Authentication'); +$Feature('Login screen'); +$Story('Validation'); +$Epic('Security'); +$Feature('XSS prevention'); +$Story('Login form'); +it('should validate e-mail', () => { + // ... +}); +``` + + + diff --git a/docs/docs/config/01-grouping/03-by-package.mdx b/docs/docs/config/01-grouping/03-by-package.mdx index cc6dd17..637b693 100644 --- a/docs/docs/config/01-grouping/03-by-package.mdx +++ b/docs/docs/config/01-grouping/03-by-package.mdx @@ -24,7 +24,8 @@ It strictly follows `com.example.package.ClassName` naming convention, where: * `com.example.package.ClassName` is a **test class**, * `shouldAssertAndDoSomething` is a **test method**. -It doesn't work well with JavaScript, and, that's why you can use only two grouping levels: **package** and **test method**. +It doesn't map well to JavaScript, hence for the most time you'll be able to utilize +only two grouping levels: **package** and **test method**. [^1] A couple of feasible options are: @@ -64,7 +65,7 @@ module.exports = { 'default', ['jest-allure2-reporter', /** @type {import('jest-allure2-reporter').Options}*/ { labels: { - package: ({ package }) => package.name, + package: ({ manifest }) => manifest.name, // NOTE: `testClass` won't work due to the aforementioned issue testClass: ({ file }) => file.path, testMethod: ({ test }) => test.fullName, @@ -77,3 +78,37 @@ module.exports = {
+### Achieving three levels + +:::info Disclaimer + +The example below is simplified and does not handle edge cases like folder names with spaces, and other non-alphanumeric characters. + +::: + +So, especially curious souls may try this hacky configuration to get all three levels, +but it's an open question whether it's worth the effort: + +```js title="jest.config.js" +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + testEnvironment: 'jest-allure2-reporter/environment-node', + reporters: [ + 'default', + ['jest-allure2-reporter', + /** @type {import('jest-allure2-reporter').Options}*/ + { + labels: { + package: ({ filePath }) => filePath.slice(0, -1).join('.'), + testClass: ({ filePath }) => filePath.join('.').replace(/\.test\.[jt]s$/, ''), + testMethod: ({ testCase }) => testCase.fullName, + }, + }], + ], +}; +``` + +This example is a proof of concept to help you understand better how this grouping strategy was supposed to work in the first place. +It demonstrates that if you map file paths like `src/components/MyComponent.test.js` to pseudo-classes like `src.components.MyComponent`, +the generated report will recognize these labels and group tests accordingly. + diff --git a/packages/e2e/configs/default.js b/packages/e2e/configs/default.js index 9c74434..1d18133 100644 --- a/packages/e2e/configs/default.js +++ b/packages/e2e/configs/default.js @@ -1,18 +1,32 @@ +// eslint-disable-next-line node/no-extraneous-require,@typescript-eslint/no-var-requires,import/no-extraneous-dependencies +const _ = require('lodash'); const PRESET = process.env.ALLURE_PRESET ?? 'default'; +/** @type {import('jest-allure2-reporter').ReporterOptions} */ +const jestAllure2ReporterOptions = { + resultsDir: `allure-results/${PRESET}`, + testCase: { + name: ({ testCase }) => + [...testCase.ancestorTitles, testCase.title].join(' » '), + labels: { + parentSuite: ({ filePath }) => filePath[0], + suite: ({ filePath }) => filePath.slice(1, 2).join('/'), + subSuite: ({ filePath }) => filePath.slice(2).join('/'), + epic: ({ value }) => value ?? 'Uncategorized', + story: ({ value }) => value ?? 'Untitled story', + feature: ({ value }) => value ?? 'Untitled feature', + package: ({ filePath }) => filePath.slice(0, -1).join('.'), + testClass: ({ filePath }) => filePath.join('.').replace(/\.test\.[jt]s$/, ''), + testMethod: ({ testCase }) => testCase.fullName, + }, + }, +}; + /** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { // eslint-disable-next-line node/no-unpublished-require,import/no-extraneous-dependencies ...require('@wix/jest-config-jest-allure2-reporter'), - + rootDir: './src/programmatic/grouping', testEnvironment: 'jest-allure2-reporter/dist/environment/node', - reporters: [ - 'default', - [ - 'jest-allure2-reporter', - /** @type {Partial} */ { - resultsDir: `allure-results/${PRESET}`, - }, - ], - ], + reporters: ['default', ['jest-allure2-reporter', jestAllure2ReporterOptions]], }; diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 207f779..6f8862b 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -19,6 +19,9 @@ }, "prettier": "@wix/prettier-config-jest-allure2-reporter", "eslintConfig": { - "extends": "@wix/jest-allure2-reporter" + "extends": "@wix/jest-allure2-reporter", + "env": { + "jest": true + } } } diff --git a/packages/e2e/src/programmatic/grouping/client/auth/ForgotPasswordScreen.test.ts b/packages/e2e/src/programmatic/grouping/client/auth/ForgotPasswordScreen.test.ts new file mode 100644 index 0000000..527d7e9 --- /dev/null +++ b/packages/e2e/src/programmatic/grouping/client/auth/ForgotPasswordScreen.test.ts @@ -0,0 +1,25 @@ +import { $Epic, $Feature, $Story, $Tag } from 'jest-allure2-reporter'; + +$Tag('client'); +$Epic('Authentication'); +$Feature('Restore account'); +describe('Forgot password screen', () => { + $Story('Happy path'); + describe('Rendering', () => { + it('should render the forgot password form', () => { + // ... + }); + }); + + describe('Form Submission', () => { + $Story('Validation'); + it('should show error on invalid e-mail format', () => { + // ... + }); + + $Story('Happy path'); + it('should send forgot password request on valid e-mail', () => { + // ... + }); + }); +}); diff --git a/packages/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts b/packages/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts new file mode 100644 index 0000000..e18dbed --- /dev/null +++ b/packages/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts @@ -0,0 +1,32 @@ +import { $Epic, $Feature, $Story, $Tag } from 'jest-allure2-reporter'; + +$Tag('client'); +$Epic('Authentication'); +$Feature('Login'); +describe('Login screen', () => { + $Story('Happy path'); + describe('Rendering', () => { + it('should render the login form by default', () => { + // ... + }); + }); + + $Story('Validation'); + describe('Form Submission', () => { + it('should show error on invalid e-mail format', () => { + // ... + }); + + it('should show error on short or invalid password format', () => { + // ... + }); + }); + + describe('Navigation', () => { + $Feature('Restore account'); + $Story('Happy path'); + it('should navigate to forgot password screen on "forgot password" click', () => { + // ... + }); + }); +}); diff --git a/packages/e2e/src/programmatic/grouping/client/utils/validators.test.ts b/packages/e2e/src/programmatic/grouping/client/utils/validators.test.ts new file mode 100644 index 0000000..2f10604 --- /dev/null +++ b/packages/e2e/src/programmatic/grouping/client/utils/validators.test.ts @@ -0,0 +1,16 @@ +import { $Tag } from 'jest-allure2-reporter'; + +$Tag('client'); +describe('Validators', () => { + describe('emailValidator', () => { + it('should correctly validate email formats', () => { + // ... + }); + }); + + describe('passwordValidator', () => { + it('should correctly validate password formats', () => { + // ... + }); + }); +}); diff --git a/packages/e2e/src/programmatic/grouping/server/controllers/forgotPassword.test.ts b/packages/e2e/src/programmatic/grouping/server/controllers/forgotPassword.test.ts new file mode 100644 index 0000000..0bc9c11 --- /dev/null +++ b/packages/e2e/src/programmatic/grouping/server/controllers/forgotPassword.test.ts @@ -0,0 +1,21 @@ +import { $Epic, $Feature, $Story, $Tag } from 'jest-allure2-reporter'; + +$Tag('server'); +$Epic('Authentication'); +$Feature('Restore account'); +describe('POST /forgot-password', () => { + $Story('Validation'); + it('should return 401 if user is not found', () => { + // ... + }); + + $Story('Happy path'); + it('should return 200 if reset link is sent successfully', () => { + // ... + }); + + $Story('Error handling'); + it('should handle server errors gracefully', () => { + // ... + }); +}); diff --git a/packages/e2e/src/programmatic/grouping/server/controllers/login.test.ts b/packages/e2e/src/programmatic/grouping/server/controllers/login.test.ts new file mode 100644 index 0000000..d46d844 --- /dev/null +++ b/packages/e2e/src/programmatic/grouping/server/controllers/login.test.ts @@ -0,0 +1,21 @@ +import { $Epic, $Feature, $Story, $Tag } from 'jest-allure2-reporter'; + +$Tag('server'); +$Epic('Authentication'); +$Feature('Login'); +describe('POST /login', () => { + $Story('Validation'); + it('should return 401 if user is not found', () => { + // ... + }); + + $Story('Validation'); + it('should return 401 if password is incorrect', () => { + // ... + }); + + $Story('Happy path'); + it('should return 200 and user details if login is successful', () => { + // ... + }); +}); diff --git a/packages/e2e/src/programmatic/grouping/server/controllers/resetPassword.test.ts b/packages/e2e/src/programmatic/grouping/server/controllers/resetPassword.test.ts new file mode 100644 index 0000000..165786e --- /dev/null +++ b/packages/e2e/src/programmatic/grouping/server/controllers/resetPassword.test.ts @@ -0,0 +1,21 @@ +import { $Epic, $Feature, $Story, $Tag } from 'jest-allure2-reporter'; + +$Tag('server'); +$Epic('Authentication'); +$Feature('Restore account'); +describe('POST /reset-password', () => { + $Story('Validation'); + it('should return 401 for invalid or expired token', () => { + // ... + }); + + $Story('Happy path'); + it('should return 200 if password is reset successfully', () => { + // ... + }); + + $Story('Error handling'); + it('should handle server errors gracefully', () => { + // ... + }); +}); diff --git a/packages/e2e/src/simple.test.ts b/packages/e2e/src/simple.not-test.ts similarity index 100% rename from packages/e2e/src/simple.test.ts rename to packages/e2e/src/simple.not-test.ts diff --git a/packages/library/package.json b/packages/library/package.json index a02ffbd..a21f547 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -56,7 +56,7 @@ "dependencies": { "@noomorph/allure-js-commons": "^2.3.0", "ci-info": "^3.8.0", - "jest-metadata": "^1.0.0-beta.20", + "jest-metadata": "^1.0.0-beta.21", "pkg-up": "^3.1.0", "rimraf": "^4.3.1", "strip-ansi": "^6.0.0" diff --git a/packages/library/src/options/ReporterOptions.ts b/packages/library/src/options/ReporterOptions.ts index d93f810..5d26e77 100644 --- a/packages/library/src/options/ReporterOptions.ts +++ b/packages/library/src/options/ReporterOptions.ts @@ -62,36 +62,39 @@ export type ReporterOptions = { */ resultsDir?: string; /** - * Customize how test cases are reported: names, descriptions, labels, status, etc. - */ - testCase?: Partial; - /** - * Customize how individual test steps are reported. + * Configures the defect categories for the report. + * + * By default, the report will have the following categories: + * `Product defects`, `Test defects` based on the test case status: + * `failed` and `broken` respectively. */ - testStep?: Partial; + categories?: Category[] | CategoriesCustomizer; /** * Configures the environment information that will be reported. */ - environment?: EnvironmentCustomizer; + environment?: Record | EnvironmentCustomizer; /** * Configures the executor information that will be reported. * By default, the executor information is inferred from `ci-info` package. * Local runs won't have any executor information unless you customize this. */ - executor?: ExecutorCustomizer; + executor?: ExecutorInfo | ExecutorCustomizer; /** - * Configures the defect categories for the report. - * - * By default, the report will have the following categories: - * `Product defects`, `Test defects` based on the test case status: - * `failed` and `broken` respectively. + * Customize how test cases are reported: names, descriptions, labels, status, etc. */ - categories?: CategoriesCustomizer; + testCase?: Partial; + /** + * Customize how individual test steps are reported. + */ + testStep?: Partial; }; export type ReporterConfig = Required & { testCase: ResolvedTestCaseCustomizer; testStep: ResolvedTestStepCustomizer; + categories: CategoriesCustomizer; + environment: EnvironmentCustomizer; + executor: ExecutorCustomizer; }; /** @@ -240,24 +243,28 @@ export type LinksCustomizer = export type LabelsCustomizer = | TestCaseExtractor | Partial<{ - readonly package: LabelExtractor; // N/A - readonly testClass: LabelExtractor; // N/A - readonly testMethod: LabelExtractor; // N/A - readonly parentSuite: LabelExtractor; // N/A - readonly suite: LabelExtractor; // N/A - readonly subSuite: LabelExtractor; // N/A - readonly epic: LabelExtractor; // uniq | AggregatedResultMetadata → ... → TestEntryMetadata → (invocations) - readonly feature: LabelExtractor; // uniq | AggregatedResultMetadata → ... → TestEntryMetadata → (invocations) - readonly story: LabelExtractor; // uniq | AggregatedResultMetadata → ... → TestEntryMetadata → (invocations) - readonly thread: LabelExtractor; // N/A - readonly severity: LabelExtractor; - readonly tag: LabelExtractor; - readonly owner: LabelExtractor; + readonly package: LabelConfig; + readonly testClass: LabelConfig; + readonly testMethod: LabelConfig; + readonly parentSuite: LabelConfig; + readonly suite: LabelConfig; + readonly subSuite: LabelConfig; + readonly epic: LabelConfig; + readonly feature: LabelConfig; + readonly story: LabelConfig; + readonly thread: LabelConfig; + readonly severity: LabelConfig; + readonly tag: LabelConfig; + readonly owner: LabelConfig; - readonly [key: string]: LabelExtractor; + readonly [key: string]: LabelConfig; }>; -export type LabelExtractor = TestCaseExtractor; +export type LabelConfig = LabelValue | LabelExtractor; + +export type LabelValue = string | string[]; + +export type LabelExtractor = TestCaseExtractor; export type Extractor, R = T> = ( context: Readonly, @@ -288,9 +295,18 @@ export interface ExtractorContext { export interface GlobalExtractorContext extends ExtractorContext { globalConfig: Config.GlobalConfig; config: ReporterConfig; + /** + * The contents of the `package.json` file if it exists. + */ + manifest: { + name: string; + version: string; + [key: string]: any; + } | null; } export interface TestCaseExtractorContext extends GlobalExtractorContext { + filePath: string[]; testFile: TestResult; testCase: TestCaseResult; testCaseMetadata: AllureTestCaseMetadata; diff --git a/packages/library/src/options/aggregateLabelCustomizers.ts b/packages/library/src/options/aggregateLabelCustomizers.ts index 52c5621..8c602a9 100644 --- a/packages/library/src/options/aggregateLabelCustomizers.ts +++ b/packages/library/src/options/aggregateLabelCustomizers.ts @@ -2,10 +2,12 @@ import type { Label } from '@noomorph/allure-js-commons'; import type { + LabelExtractor, LabelsCustomizer, TestCaseExtractor, TestCaseExtractorContext, } from './ReporterOptions'; +import { asExtractor } from './asExtractor'; export function aggregateLabelCustomizers( labels: LabelsCustomizer | undefined, @@ -14,9 +16,15 @@ export function aggregateLabelCustomizers( return labels; } - const names = Object.keys(labels).filter( - (key) => typeof labels[key] === 'function', - ); + const extractors = Object.keys(labels).reduce((accumulator, key) => { + const extractor = asExtractor(labels[key]) as LabelExtractor; + if (extractor) { + accumulator[key] = extractor; + } + return accumulator; + }, {} as Record); + + const names = Object.keys(extractors); return (context: TestCaseExtractorContext) => { const other: Label[] = []; @@ -34,18 +42,22 @@ export function aggregateLabelCustomizers( return [ ...other, ...names.flatMap((name) => { - const extractor = labels[name]; - const value = asArray(extractor!({ ...context, value: found[name] })); - return value.map((value) => ({ name, value } as Label)); + const extractor = extractors[name]!; + const value = asArray( + extractor({ ...context, value: asArray(found[name]) }), + ); + return value ? value.map((value) => ({ name, value } as Label)) : []; }), ]; }; } -function asArray(value: T | T[] | undefined): T[] { +function asArray( + value: T | T[] | undefined, +): T[] | undefined { if (Array.isArray(value)) { - return value; + return value.length > 0 ? value : undefined; + } else { + return value ? [value] : []; } - - return value ? [value] : []; } diff --git a/packages/library/src/options/asExtractor.ts b/packages/library/src/options/asExtractor.ts new file mode 100644 index 0000000..542f1f2 --- /dev/null +++ b/packages/library/src/options/asExtractor.ts @@ -0,0 +1,35 @@ +import type { Extractor } from './ReporterOptions'; + +/** + * Resolves the unknown value either as an extractor or it + * builds a fallback extractor that returns the given value. + * + * Since Allure 2 has a quirky convention that the first value + * in an array takes precedence, we on purpose put the custom + * value first and the default value second. + * + * The fallback extractor is capable both of merging arrays and + * defaulting the values. The former is useful for tags, the latter + * for the rest of the labels which don't support multiple occurrences. + */ +export function asExtractor>( + value: R | E | undefined, +): E | undefined { + if (value === undefined) { + return undefined; + } + + return ( + typeof value === 'function' + ? value + : ({ value: customValue }) => { + if (Array.isArray(customValue)) { + return Array.isArray(value) + ? [...customValue, ...value] + : [...customValue, value]; + } else { + return customValue ?? value; + } + } + ) as E; +} diff --git a/packages/library/src/options/composeOptions.ts b/packages/library/src/options/composeOptions.ts index f4773a9..eae73ec 100644 --- a/packages/library/src/options/composeOptions.ts +++ b/packages/library/src/options/composeOptions.ts @@ -8,6 +8,7 @@ import type { import { aggregateLabelCustomizers } from './aggregateLabelCustomizers'; import { aggregateLinkCustomizers } from './aggregateLinkCustomizers'; import { composeExtractors } from './composeExtractors'; +import { asExtractor } from './asExtractor'; export function composeOptions( base: ReporterConfig, @@ -27,9 +28,15 @@ export function composeOptions( base.testStep as TestStepCustomizer, custom.testStep, ), - environment: composeExtractors(custom.environment, base.environment), - executor: composeExtractors(custom.executor, base.executor), - categories: composeExtractors(custom.categories, base.categories), + environment: composeExtractors( + asExtractor(custom.environment), + base.environment, + ), + executor: composeExtractors(asExtractor(custom.executor), base.executor), + categories: composeExtractors( + asExtractor(custom.categories), + base.categories, + ), }; } diff --git a/packages/library/src/options/defaultOptions.ts b/packages/library/src/options/defaultOptions.ts index 788ca13..8032f58 100644 --- a/packages/library/src/options/defaultOptions.ts +++ b/packages/library/src/options/defaultOptions.ts @@ -73,7 +73,7 @@ export function defaultOptions(): ReporterConfig { parameters: ({ testStep }) => testStep.parameters ?? [], }; - return { + const config: ReporterConfig = { overwrite: true, resultsDir: 'allure-results', testCase, @@ -82,6 +82,8 @@ export function defaultOptions(): ReporterConfig { executor: identity, categories: identity, }; + + return config; } function getTestCaseStatus(testCase: TestCaseResult): Status { diff --git a/packages/library/src/reporter/JestAllure2Reporter.ts b/packages/library/src/reporter/JestAllure2Reporter.ts index 55b4d06..1ed4332 100644 --- a/packages/library/src/reporter/JestAllure2Reporter.ts +++ b/packages/library/src/reporter/JestAllure2Reporter.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import type { AggregatedResult, Config, @@ -5,6 +7,7 @@ import type { TestCaseResult, } from '@jest/reporters'; import { JestMetadataReporter, query } from 'jest-metadata/reporter'; +import pkgUp from 'pkg-up'; import rimraf from 'rimraf'; import type { ExecutableItemWrapper } from '@noomorph/allure-js-commons'; import { AllureRuntime } from '@noomorph/allure-js-commons'; @@ -21,6 +24,8 @@ import { resolveOptions } from '../options'; import type { AllureTestStepMetadata } from '../metadata'; import { MetadataSquasher, StepExtractor } from '../metadata'; import { STOP, WORKER_ID } from '../constants'; +import attempt from '../utils/attempt'; +import isError from '../utils/isError'; export class JestAllure2Reporter extends JestMetadataReporter { private readonly _globalConfig: Config.GlobalConfig; @@ -66,9 +71,20 @@ export class JestAllure2Reporter extends JestMetadataReporter { resultsDir: config.resultsDir, }); + const { rootDir } = this._globalConfig; + const packageJsonPath = await pkgUp({ cwd: rootDir }); + let manifest: any = packageJsonPath + ? attempt(() => require(packageJsonPath)) + : null; + + if (isError(manifest)) { + manifest = null; + } + const globalContext: GlobalExtractorContext = { globalConfig: this._globalConfig, config, + manifest, value: undefined, }; @@ -100,6 +116,12 @@ export class JestAllure2Reporter extends JestMetadataReporter { ); const testCaseContext: TestCaseExtractorContext = { ...globalContext, + filePath: path + .relative( + globalContext.globalConfig.rootDir, + testResult.testFilePath, + ) + .split(path.sep), testFile: testResult, testCase: testCaseResult, testCaseMetadata, diff --git a/packages/presets/jest-config/index.js b/packages/presets/jest-config/index.js index 1d00c1e..bc74a7c 100644 --- a/packages/presets/jest-config/index.js +++ b/packages/presets/jest-config/index.js @@ -6,7 +6,7 @@ module.exports = { ], "preset": "ts-jest", "testEnvironment": "node", - "testMatch": ["/src/**/*.test.ts"], + "testMatch": ["/**/*.test.ts"], "reporters": ["default"], "verbose": true };