From 77f4c5ab350fa9fed155391980c0ccf7bd303e4d Mon Sep 17 00:00:00 2001 From: Yaroslav Serhieiev Date: Fri, 10 Nov 2023 22:30:09 +0200 Subject: [PATCH] feat: plugin subsystem (#15) * fix: broken/failed status support --- .eslintrc.js | 1 + docs/docs/api/01-descriptions.mdx | 262 +++++--- docs/docs/api/02-steps.mdx | 56 +- docs/docs/api/03-attachments.mdx | 2 +- docs/docs/api/05-people.mdx | 12 +- docs/docs/api/06-severity.mdx | 18 +- docs/docs/api/07-links.mdx | 24 +- docs/docs/config/01-grouping/01-by-suite.mdx | 6 +- docs/docs/config/01-grouping/02-by-story.mdx | 126 ++-- .../docs/config/01-grouping/03-by-package.mdx | 49 +- docs/docs/config/01-grouping/04-by-defect.mdx | 44 +- docs/docs/config/02-statuses.mdx | 4 +- docs/docs/config/04-environment.mdx | 6 +- docs/docs/config/05-executor.mdx | 2 +- docs/docs/config/06-history.mdx | 4 +- docs/docs/config/07-errors.mdx | 28 +- e2e/configs/default.js | 9 + e2e/plugins/jsdocProcessor.js | 16 - e2e/plugins/remarkProcessor.js | 20 - .../grouping/client/auth/LoginScreen.test.ts | 10 + index.d.ts | 590 ++++++++++++++++++ index.js | 1 + package.json | 36 +- src/builtin-plugins/augs.d.ts | 12 + src/builtin-plugins/index.ts | 4 + src/builtin-plugins/jsdoc.ts | 54 ++ src/builtin-plugins/manifest.ts | 30 + src/builtin-plugins/prettier.ts | 52 ++ src/builtin-plugins/remark.ts | 33 + src/constants.ts | 1 + src/decorators/Attachment.ts | 6 +- src/decorators/FileAttachment.ts | 9 +- src/decorators/Step.ts | 12 +- src/environment/decorator.ts | 52 +- src/index.ts | 18 +- src/metadata/MetadataSquasher.ts | 14 +- src/metadata/StepExtractor.ts | 3 +- src/metadata/index.ts | 1 - src/metadata/metadata.ts | 50 -- src/metadata/utils/chain.ts | 10 + src/metadata/utils/extractCode.ts | 2 +- src/metadata/utils/getStart.ts | 3 +- src/metadata/utils/getStop.ts | 3 +- src/metadata/utils/index.ts | 1 - src/options/ReporterOptions.ts | 348 ----------- src/options/aggregateLabelCustomizers.ts | 34 +- src/options/aggregateLinkCustomizers.ts | 3 +- src/options/asExtractor.ts | 2 +- src/options/composeExtractors.ts | 2 +- src/options/composeOptions.ts | 11 +- src/options/composePlugins.ts | 25 + src/options/defaultOptions.ts | 81 ++- src/options/index.ts | 1 - src/options/resolveOptions.ts | 12 +- src/options/resolvePlugins.ts | 42 ++ .../utils => options}/stripStatusDetails.ts | 0 src/realms/AllureRealm.ts | 2 +- src/reporter/JestAllure2Reporter.ts | 101 +-- src/runtime/AllureRuntime.ts | 35 +- src/runtime/AttachmentsHandler.ts | 2 +- src/runtime/IAllureRuntime.ts | 78 --- src/runtime/index.ts | 1 - src/utils/splitDocblock.test.ts | 33 + src/utils/splitDocblock.ts | 13 + src/utils/types.ts | 2 - 65 files changed, 1592 insertions(+), 932 deletions(-) delete mode 100644 e2e/plugins/jsdocProcessor.js delete mode 100644 e2e/plugins/remarkProcessor.js create mode 100644 index.d.ts create mode 100644 index.js create mode 100644 src/builtin-plugins/augs.d.ts create mode 100644 src/builtin-plugins/index.ts create mode 100644 src/builtin-plugins/jsdoc.ts create mode 100644 src/builtin-plugins/manifest.ts create mode 100644 src/builtin-plugins/prettier.ts create mode 100644 src/builtin-plugins/remark.ts delete mode 100644 src/metadata/metadata.ts delete mode 100644 src/options/ReporterOptions.ts create mode 100644 src/options/composePlugins.ts create mode 100644 src/options/resolvePlugins.ts rename src/{metadata/utils => options}/stripStatusDetails.ts (100%) delete mode 100644 src/runtime/IAllureRuntime.ts create mode 100644 src/utils/splitDocblock.test.ts create mode 100644 src/utils/splitDocblock.ts diff --git a/.eslintrc.js b/.eslintrc.js index 48e4a84..eb48eef 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -40,6 +40,7 @@ module.exports = { "unicorn/no-null": "off", "prefer-rest-params": "off", "@typescript-eslint/ban-types": "off", + "@typescript-eslint/triple-slash-reference": "off", "unicorn/no-this-assignment": "off", "unicorn/prefer-event-target": "off", "unicorn/prefer-module": "off", diff --git a/docs/docs/api/01-descriptions.mdx b/docs/docs/api/01-descriptions.mdx index f5f0554..a2ecba6 100644 --- a/docs/docs/api/01-descriptions.mdx +++ b/docs/docs/api/01-descriptions.mdx @@ -14,72 +14,89 @@ Please use GitHub docs for the latest stable version, `1.x.x`. ::: -A well-written description can enhance the usefulness of a test by providing clear context and expected outcomes. It can also assist in deciphering test failures. +A well-written description can enhance the usefulness of a test by providing clear context and expected outcomes. +Anyone who sees your test for the first time may benefit from a rich description. -There are two ways to define a description for an executable block: +There are two ways to define a description for a test: -* declaratively, via `@desc`, `@description`, `@descriptionHtml` JSDoc annotations (or even without them); -* programmatically, via `$Description('')` or `$DescriptionHtml('')` annotation functions. +* declaratively, via `@desc`, `@description`, `@descriptionHtml` docblocks (or even without them); +* programmatically, via our DSL – `$Description` or `$DescriptionHtml` pseudo-decorators. -The **description** can be defined for the built-in `it`, `test`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach` blocks and [custom steps](02-steps.mdx). +:::info Note -This article will focus on the built-in blocks. +Descriptions are **not supported** on a _suite_ (`describe`) or _test hooks_ (`beforeAll`, `beforeEach`, `afterAll`, `afterEach`) level due to limitations of Jest and Allure Framework. + +Anyway, we have a few workarounds for you down below. :wink: + +::: ## Test cases -Allure Framework supports rich text descriptions for tests. But don't worry if you don't provide a description. The reporter will still present the test neatly by displaying the source code of the test. +Allure Framework supports rich text descriptions for tests in Markdown and HTML formats. + +To make your experience better, `jest-allure2-reporter` appends a _source code_ of every +test to its description, so you can always get value from this feature. - + + +:::info Note + +Docblocks must be **inside** the test function to work. + +::: + ```js -it('should add two numbers', () => { +test('should add two numbers', () => { /** - * _Implicit_ annotation without `@desc` or `@description`. - */ - const a = 1; - const b = 2; - const sum = a + b; - expect(sum).toBe(3); + * This test demonstrates the `+` operator. + */ + expect(1 + 2).toBe(3); }); -it('should subtract two numbers', () => { +test('should multiply two numbers', () => { /** - * @descriptionHtml - * Explicit annotation with @descriptionHtml. - */ - const a = 1; - const b = 2; - const diff = b - a; - expect(diff).toBe(1); + * @description + * This test demonstrates the `*` operator. + */ + expect(3 * 2).toBe(6); +}); + +test('should subtract two numbers', () => { + /** + * @descriptionHtml + * This test demonstrates the - operator. + */ + expect(2 - 1).toBe(1); }); ``` - + + +:::info Note + +Pseudo-annotations must be **before** the `test` statement to work. + +::: ```js import { $Description } from 'jest-allure2-reporter'; -$Description('_Implicit_ annotation without `@desc` or `@description`.') -it('should add two numbers', () => { - const a = 1; - const b = 2; - const sum = a + b; - expect(sum).toBe(3); +$Description('This test demonstrates the `+` operator.') +test('should add two numbers', () => { + expect(1 + 2).toBe(3); }); -$DescriptionHtml('Explicit annotation with @descriptionHtml.') -it('should subtract two numbers', () => { - const a = 1; - const b = 2; - const diff = b - a; - expect(diff).toBe(1); +$DescriptionHtml('This test demonstrates the - operator.') +test('should subtract two numbers', () => { + expect(2 - 1).toBe(1); }); ``` - + TODO: add screenshot @@ -88,12 +105,10 @@ it('should subtract two numbers', () => { ## Test hooks -Test hooks such as `beforeAll`, `beforeEach`, `afterAll`, `afterEach` can also have descriptions since technically they are considered [steps](02-steps.mdx) in the Allure report. - -However, they are limited to plain text descriptions only due to Allure Framework limitations. +Test hooks such as `beforeAll`, `beforeEach`, `afterAll`, `afterEach` are treated [as steps](02-steps.mdx) in Allure Framework. Therefore, they can have only a plain name, but no description. - + ```js beforeAll(() => { @@ -101,19 +116,16 @@ beforeAll(() => { }); beforeEach(() => { - /** @desc This hook runs before each test. */ + /** This hook runs before each test. */ }); afterEach(() => { - /** - * @description - * This hook runs after each test. - */ + /** This hook runs after each test. */ }); ``` - + ```js import { $Description } from 'jest-allure2-reporter'; @@ -135,7 +147,7 @@ afterEach(() => { ``` - + TODO: add screenshot @@ -145,47 +157,109 @@ afterEach(() => { ## Test suites -Unfortunately, it is not possible to define a description for the entire test suite or an individual test suite (`describe` block) due to Allure Framework limitations. +Allure Framework doesn't treat test suites as separate entities, so the best we can offer is to prepend their descriptions to every test within the suite. -However, if you add a description on top of the test suite, it will be prepended to every test description within the suite, e.g.: +Due to Jest limitations, you can't use docblocks on a suite level, so the only way to add a description is to use our DSL. - + ```js -describe('Sanity: Login flow', () => { - /** This description will be prepended to every test description. */ +import { $Description } from 'jest-allure2-reporter'; +$Description('The test is operating on `/login` page.') +describe('Sanity: Login flow', () => { it('should login with valid credentials', () => { - /** This test logs in with valid credentials. */ + /** Testing the transition to the `/dashboard` page. */ + // ... }); it('should login with invalid credentials', () => { - /** This test logs in with invalid credentials. */ + /** Testing the validation summary component. */ + // ... }); }); ``` - + + + TODO: add screenshot + + + + +## Test files + +In many cases you may find it acceptable to describe the whole test file, which usually is equal to adding a description to the top-level `describe` block. More often than not you have a single top-level `describe` block, so you won't notice the difference: + + + ```js +/** + * @description + * The test is operating on `/login` page. + */ import { $Description } from 'jest-allure2-reporter'; -$Description('This description will be prepended to every test description.') describe('Sanity: Login flow', () => { - $Description('This test logs in with valid credentials.') it('should login with valid credentials', () => { + /** Testing the transition to the `/dashboard` page. */ + // ... + }); + + it('should login with invalid credentials', () => { + /** Testing the validation summary component. */ + // ... + }); +}); +``` + +:::info Note + +You **must** use `@desc` or `@description` pragma due to Jest limitations regarding file-level docblocks. + +::: + + + + +```js +import { allure } from 'jest-allure2-reporter'; + +allure.description('The test is operating on `/login` page.') + +describe('Sanity: Login flow', () => { + it('should login with valid credentials', () => { + /** Testing the transition to the `/dashboard` page. */ + // ... }); - $Description('This test logs in with invalid credentials.') it('should login with invalid credentials', () => { + /** Testing the validation summary component. */ + // ... }); }); ``` +:::info Note + +We use `allure.description` to ensure that the metadata is added to exactly to the actual context, which is the **test file** itself. + +To simulate the behavior of `$Description` pseudo-decorator, we'd have to put it inside the `describe` block: + +```js +describe('Sanity: Login flow', () => { + allure.description('The test is operating on `/login` page.') + // ... +}); +``` + +::: + - + TODO: add screenshot @@ -194,23 +268,63 @@ describe('Sanity: Login flow', () => { ## Configuration -:::caution Work in progress -::: +### Description template -You can configure whether the source code of the test is included or not, using the `includeSourceCode` and `omitJSDoc` options. By default, both are set to `true`. +As mentioned before, a test description is a sequence of user-defined paragraphs, followed by a source code of the test itself. -As a result, the following test will be reported without JSDoc annotations: +To customize the template, you can use `description` option in your `jest.config.js`. Below is a rough example of how you can do it: ```js -it('should add two numbers', () => { - /** - * This test adds two numbers. - */ - const a = 1; - const b = 2; - const sum = a + b; - expect(sum).toBe(3); -}); +/** @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} */ + { + testCase: { + description: ({ testCaseMetadata }) => [ + ...testCaseMetadata.description, + '```js', + ...(testCaseMetadata.code?.beforeAll ?? []), + ...(testCaseMetadata.code?.beforeEach ?? []), + ...(testCaseMetadata.code?.test ?? []), + ...(testCaseMetadata.code?.afterEach ?? []), + ...(testCaseMetadata.code?.afterAll ?? []), + '```', + ].join('\n\n'), + } + } + ], +}; +``` + +To switch to a HTML template, reset the `description` customizer to +return undefined and use `descriptionHtml` option instead: + +```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} */ + { + testCase: { + description: () => {}, // suppress the default template + descriptionHtml: ({ testCaseMetadata }) => { /* ... */ }, + } + } + ], +}; ``` -TODO: See the configuration section for more details. +### Markdown support + +By default, `jest-allure2-reporter` uses `remark` processor to render Markdown descriptions. It is not possible to customize it right now, but we're working on it. + +You'll be able to define your own `remark` plugins and configure the processor in one of the next releases. diff --git a/docs/docs/api/02-steps.mdx b/docs/docs/api/02-steps.mdx index 2c97ef7..700e4ab 100644 --- a/docs/docs/api/02-steps.mdx +++ b/docs/docs/api/02-steps.mdx @@ -26,36 +26,36 @@ The simplest steps to start with are the built-in hooks in Jest: `beforeAll`, `b This way, you will see the name and status of each hook in the report. - + ```js beforeAll(async () => { /** - * Launch the browser for all tests - */ + * Launch the browser for all tests + */ }); beforeEach(async () => { /** - * Visit the page before the test starts - */ + * Visit the page before the test starts + */ }); afterEach(async () => { /** - * Take a screenshot after each test - */ + * Take a screenshot after each test + */ }); afterAll(async () => { /** - * Close the browser after all tests - */ + * Close the browser after all tests + */ }); ``` - + ```js import { $Description } from 'jest-allure2-reporter'; @@ -82,7 +82,7 @@ afterAll(async () => { ``` - + TODO: add screenshot @@ -185,16 +185,16 @@ class LoginPageObject { ``` - + TODO: add screenshot -### Instant steps +### Status override -Sometimes you need to log a step in the middle of the test, for example, to add a screenshot when an exception is thrown. Use `allure.logStep` function for this: +In some cases, you might want to have control over the step status and its status details. Furthermore, you might want to make the status conditional and programmatic, and here's how: @@ -206,25 +206,29 @@ test('Login test', async () => { try { // ... } catch (error) { - allure.logStep('Unexpected error', Status.FAILED, [ - { - name: 'screenshot', - content: await page.screenshot({ fullPage: true }), - type: 'image/png', - }, - ]); - - throw error; + await allure.step('Unexpected error (Recoverable)', () => { + await allure.attachment( + 'screenshot.png', + page.screenshot({ fullPage: true }, + ); + + if (isRecoverable()) { + allure.status(Status.SKIPPED, { + message: error.message, + trace: error.stack, + }); + } else { + throw error; + } + }); } }); ``` - + TODO: add screenshot - -As you can see, `allure.logStep` has an optional third parameter (TODO) for attachments. This is just one of possible ways to add attachments to the report. See [Attachments](03-attachments.mdx) for more details. diff --git a/docs/docs/api/03-attachments.mdx b/docs/docs/api/03-attachments.mdx index cc6cd4e..4e42f6d 100644 --- a/docs/docs/api/03-attachments.mdx +++ b/docs/docs/api/03-attachments.mdx @@ -27,7 +27,7 @@ There are several ways to add attachments to a test: The simplest way to start with attachments is to use the built-in ones: - + ```js import { allure } from 'jest-allure2-reporter'; diff --git a/docs/docs/api/05-people.mdx b/docs/docs/api/05-people.mdx index 7959f82..c73ef8c 100644 --- a/docs/docs/api/05-people.mdx +++ b/docs/docs/api/05-people.mdx @@ -31,7 +31,7 @@ The owner of a test suite is the person who is responsible for the test suite an Here is how you can associate an entire test file with an owner: - + ```js /** @@ -52,7 +52,7 @@ describe('Sanity: Dashboard', () => { ``` - + ```js import { allure } from 'jest-allure2-reporter'; @@ -78,7 +78,7 @@ describe('Sanity: Dashboard', () => { Here is how you can associate a selected test suite with an owner: - + ```js describe('Sanity: Login flow', () => { @@ -95,7 +95,7 @@ describe('Sanity: Login flow', () => { Please note that you have to put the JSDoc comment inside the test suite function body. - + ```js import { $Owner } from 'jest-allure2-reporter'; @@ -114,7 +114,7 @@ describe('Sanity: Login flow', () => { You can also assign an owner for each test case individually. - + ```js it('should login with valid credentials', () => { @@ -129,7 +129,7 @@ it('should login with valid credentials', () => { Please note that you have to put the JSDoc comment inside the test function body. - + ```js $Owner('John Doe '); diff --git a/docs/docs/api/06-severity.mdx b/docs/docs/api/06-severity.mdx index 924d9e7..c3c1623 100644 --- a/docs/docs/api/06-severity.mdx +++ b/docs/docs/api/06-severity.mdx @@ -36,7 +36,7 @@ In a test file, you can define the severity for all test cases in the file. This is especially useful for test files that contain multiple top-level `describe` blocks. - + ```js /** @@ -57,7 +57,7 @@ describe('Sanity: Dashboard', () => { ``` - + ```js import { allure } from 'jest-allure2-reporter'; @@ -78,7 +78,7 @@ describe('Sanity: Dashboard', () => { ``` - + TODO: add screenshot @@ -90,7 +90,7 @@ describe('Sanity: Dashboard', () => { You can define the severity for each test suite individually. - + ```js describe('Sanity: Login flow', () => { @@ -117,7 +117,7 @@ describe('Dashboard', () => { Please note that you have to put the JSDoc comment inside the test suite function body. - + ```js import { $Severity } from 'jest-allure2-reporter'; @@ -138,7 +138,7 @@ describe('Sanity: Dashboard', () => { ``` - + TODO: add screenshot @@ -150,7 +150,7 @@ describe('Sanity: Dashboard', () => { You can define the severity for each test case individually. - + ```js it('should login with valid credentials', () => { @@ -165,7 +165,7 @@ it('should login with valid credentials', () => { Please note that you have to put the JSDoc comment inside the test function body. - + ```js $Severity('critical') @@ -175,7 +175,7 @@ it('should login with valid credentials', () => { ``` - + TODO: add screenshot diff --git a/docs/docs/api/07-links.mdx b/docs/docs/api/07-links.mdx index c3d272b..111b230 100644 --- a/docs/docs/api/07-links.mdx +++ b/docs/docs/api/07-links.mdx @@ -30,7 +30,7 @@ There are two ways to add links to your test cases: You can link an issue in your issue tracker to a test case. - + ```js it('should validate non-ASCII passwords', () => { @@ -43,7 +43,7 @@ it('should validate non-ASCII passwords', () => { }); ``` - + ```js import { $Issue } from 'jest-allure2-reporter'; @@ -55,7 +55,7 @@ it('should validate non-ASCII passwords', () => { }); ``` - + TODO: add screenshot @@ -67,7 +67,7 @@ it('should validate non-ASCII passwords', () => { You can link a test case in your Test Management System (TMS) to a test case as shown below. - + ```js it('should be connected to TMS', () => { @@ -79,7 +79,7 @@ it('should be connected to TMS', () => { }); ``` - + ```js import { $TmsLink } from 'jest-allure2-reporter'; @@ -90,7 +90,7 @@ it('should be connected to TMS', () => { }); ``` - + TODO: add screenshot @@ -102,7 +102,7 @@ it('should be connected to TMS', () => { You can link an arbitrary URL to a test case: - + ```js it('should demonstrate how the links work', () => { @@ -114,7 +114,7 @@ it('should demonstrate how the links work', () => { }); ``` - + ```js import { $Link } from 'jest-allure2-reporter'; @@ -125,7 +125,7 @@ it('should demonstrate how the links work', () => { }); ``` - + TODO: add screenshot @@ -135,7 +135,7 @@ it('should demonstrate how the links work', () => { Advanced users can also specify a custom link type and [configure the URL pattern](#configuration) for it. - + ```js it('should demonstrate how the links work', () => { @@ -147,7 +147,7 @@ it('should demonstrate how the links work', () => { }); ``` - + ```js import { $Link } from 'jest-allure2-reporter'; @@ -158,7 +158,7 @@ it('should demonstrate how the links work', () => { }); ``` - + TODO: add screenshot diff --git a/docs/docs/config/01-grouping/01-by-suite.mdx b/docs/docs/config/01-grouping/01-by-suite.mdx index 7bd95ce..7d14021 100644 --- a/docs/docs/config/01-grouping/01-by-suite.mdx +++ b/docs/docs/config/01-grouping/01-by-suite.mdx @@ -49,7 +49,7 @@ By default, `jest-allure2-reporter` provides 3 levels of grouping: **suite**, ** 1. The **test case** level is based on the _test name_ (including the inner describe block names). - + ![Default grouping](../../../img/screenshots/config-01-grouping-02.jpg) @@ -100,7 +100,7 @@ module.exports = { This example might be useful for projects with many test files and relatively few test cases per file. - + ![File-oriented grouping](../../../img/screenshots/config-01-grouping-03.jpg) @@ -164,7 +164,7 @@ module.exports = { This example should fit projects with a smaller number of test files and numerous test cases per file. - + ![Test-oriented grouping](../../../img/screenshots/config-01-grouping-04.jpg) diff --git a/docs/docs/config/01-grouping/02-by-story.mdx b/docs/docs/config/01-grouping/02-by-story.mdx index 497d7ff..5d84519 100644 --- a/docs/docs/config/01-grouping/02-by-story.mdx +++ b/docs/docs/config/01-grouping/02-by-story.mdx @@ -47,7 +47,7 @@ Before you start using this grouping option, you need to decide how exactly you * 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 +## Using annotations 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. @@ -56,30 +56,28 @@ Both them deal with the same functionality – authentication and restoring forg 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. - + -![Grouping by Story: annotation-based](../../../img/screenshots/config-01-grouping-06.jpg) - - - +```js title="login.test.js" +/** + * @epic Authentication + * @feature Login screen + */ +describe('Login controller', () => { + it('should validate e-mail', () => { + /** @story Validation */ + // ... + }); -```plain -└─ Authentication - ├─ Login screen - │ ├─ should validate e-mail on client - │ ├─ should validate e-mail on server - │ ├─ should display login form on client - │ ├─ should return 401 if user is not found - │ └─ should return 401 if password is incorrect - └─ Forgot password screen - ├─ should validate e-mail on client - ├─ should validate e-mail on server - ├─ should return 401 if user is not found - └─ should return 401 if password is incorrect + it('should return 401 if user is not found', () => { + /** @story Validation */ + // ... + }); +}); ``` - + ```js title="login.test.js" import { $Epic, $Feature, $Story } from 'jest-allure2-reporter'; @@ -87,18 +85,39 @@ import { $Epic, $Feature, $Story } from 'jest-allure2-reporter'; $Epic('Authentication'); $Feature('Login screen'); describe('Login controller', () => { - $Story('should validate e-mail on server'); + $Story('Validation'); it('should validate e-mail', () => { // ... }); - $Story('should return 401 if user is not found'); + $Story('Validation'); it('should return 401 if user is not found', () => { // ... }); }); ``` + + + +```plain +└─ Authentication + ├─ Login screen + │ ├─ General + │ │ ├─ should display login form on client + │ ├─ Validation + │ │ ├─ should validate e-mail on client + │ │ ├─ should validate e-mail on server + │ │ ├─ should return 401 if user is not found + │ │ └─ should return 401 if password is incorrect + └─ Forgot password screen + └─ Validation + ├─ should validate e-mail on client + ├─ should validate e-mail on server + ├─ should return 401 if user is not found + └─ should return 401 if password is incorrect +``` + @@ -115,20 +134,24 @@ 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', + [ + '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 +## Using configuration 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. @@ -145,12 +168,27 @@ Let's explore a simple example, where we'll map: * **story** to the lowest-level describe block - + -![Grouping by Story: configuration-based](../../../img/screenshots/config-01-grouping-07.jpg) +```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: ({ testCase }) => testCase.ancestorTitles.at(0) ?? '(uncategorized)', + feature: ({ testCase }) => testCase.ancestorTitles.slice(1, -1).join(' > ') || '(uncategorized)', + story: ({ testCase }) => testCase.ancestorTitles.slice(2).at(-1) ?? '(uncategorized)', + }, + }], + ], +}; +``` - + ```plain ├─ Login screen @@ -172,26 +210,6 @@ Let's explore a simple example, where we'll map: └─ should return 401 if password is incorrect ``` - - - -```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: ({ testCase }) => testCase.ancestorTitles.at(0) ?? '(uncategorized)', - feature: ({ testCase }) => testCase.ancestorTitles.slice(1, -1).join(' > ') ?? '(uncategorized)', - story: ({ testCase }) => testCase.ancestorTitles.at(-1) ?? '(uncategorized)', - }, - }], - ], -}; -``` - @@ -201,7 +219,7 @@ It is worth mentioning that Allure allows you to map a test case to multiple epi 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', () => { @@ -219,7 +237,7 @@ it('should validate e-mail', () => { }); ``` - + ```js title="login.test.js" $Epic('Authentication'); diff --git a/docs/docs/config/01-grouping/03-by-package.mdx b/docs/docs/config/01-grouping/03-by-package.mdx index e521345..394d9b9 100644 --- a/docs/docs/config/01-grouping/03-by-package.mdx +++ b/docs/docs/config/01-grouping/03-by-package.mdx @@ -1,5 +1,6 @@ --- description: Developer-oriented way to group test results. +toc_min_heading_level: 3 --- import Tabs from '@theme/Tabs'; @@ -24,8 +25,10 @@ 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 map well to JavaScript, hence for the most time you'll be able to utilize -only two grouping levels: **package** and **test method**. [^1] +It doesn't map well to JavaScript +[⁽¹⁾](https://github.com/orgs/allure-framework/discussions/2027). +Hence, normally you'll be able to utilize only two grouping levels: **package** and **test method**. + A couple of feasible options are: @@ -34,27 +37,6 @@ A couple of feasible options are: * use `testMethod` to group tests by the full test name - - -![Grouping by Story: configuration-based](../../../img/screenshots/config-01-grouping-07.jpg) - - - - -```plain -└─ @my-company/my-package - ├─ Forgot password controller should return 401 if password is incorrect - ├─ Forgot password controller should return 401 if user is not found - ├─ Forgot password screen when loaded and typed should validate e-mail - ├─ Forgot password screen when loaded should display forgot password form - ├─ Login controller should return 401 if password is incorrect - ├─ Login controller should return 401 if user is not found - ├─ Login screen when loaded and typed should validate password - ├─ Login screen when loaded and typed should validate password - └─ Login screen when loaded should display login form -``` - - ```js title="jest.config.js" @@ -66,7 +48,7 @@ module.exports = { ['jest-allure2-reporter', /** @type {import('jest-allure2-reporter').Options}*/ { labels: { package: ({ manifest }) => manifest.name, - // NOTE: `testClass` won't work due to the aforementioned issue + // ⚠️ `testClass` won't work due to the aforementioned issue testClass: ({ file }) => file.path, testMethod: ({ test }) => test.fullName, }, @@ -75,10 +57,26 @@ module.exports = { }; ``` + + + +```plain +└─ @my-company/my-package + ├─ Forgot password controller should return 401 if password is incorrect + ├─ Forgot password controller should return 401 if user is not found + ├─ Forgot password screen when loaded and typed should validate e-mail + ├─ Forgot password screen when loaded should display forgot password form + ├─ Login controller should return 401 if password is incorrect + ├─ Login controller should return 401 if user is not found + ├─ Login screen when loaded and typed should validate password + ├─ Login screen when loaded and typed should validate password + └─ Login screen when loaded should display login form +``` + -### Achieving three levels +## Achieving three levels :::info Disclaimer @@ -111,4 +109,3 @@ module.exports = { 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/docs/docs/config/01-grouping/04-by-defect.mdx b/docs/docs/config/01-grouping/04-by-defect.mdx index 06614db..4b1e986 100644 --- a/docs/docs/config/01-grouping/04-by-defect.mdx +++ b/docs/docs/config/01-grouping/04-by-defect.mdx @@ -5,7 +5,7 @@ description: QA and Product Manager perspective on test results. import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# By Defect Category +# By Category :::caution @@ -18,18 +18,25 @@ Please use GitHub docs for the latest stable version, `1.x.x`. One of the most important things when your builds start failing is to understand _what exactly is broken_. -By defining defect categories, you can easily distinguish between different types of errors and failures. +By defining **test result categories**, you can easily distinguish between different types of errors and failures. -There are two built-in defect categories: +There are two built-in categories for test results:
-
Product defect
+
+ 🔴  Product defect +
A failed Jest assertion, e.g. expect(countOfPosts).toBe(1).
-
Test defect
-
A failed test, e.g. throw new TypeError('Cannot read property 'name' of null').
+
+ 🟡  Test defect +
+
A broken test, e.g. TypeError: Cannot read property 'foo' of undefined, timeout or syntax errors.
-We recommend to invest some time into configuring the reporter to distinguish between different types of product defects, especially if you have end-to-end tests that are prone to flakiness, e.g.: +This doesn't mean that you can't have categories for 🟢 _passed_ or ⚪ _skipped_ tests, but usually it makes more sense +to develop a comprehensive taxonomy for various failure types. + +For example, you can also distinguish between **Visual regression**, **Timeout error** and as many others as you need. ```js title="jest.config.js" /** @@ -63,3 +70,26 @@ module.exports = { ], }; ``` + +:::tip + +If you need to overwrite the default categories, use a function customizer instead of an array: + +```js title="jest.config.js" +module.exports = { + // ... your jest config + reporters: [ + 'default', + ['jest-allure2-reporter', + /** @type {import('jest-allure2-reporter').JestAllure2ReporterOptions} */ + { +// highlight-start + categories: () => [/* ... */], +// highlight-end + } + ], + ], +}; +``` + +::: diff --git a/docs/docs/config/02-statuses.mdx b/docs/docs/config/02-statuses.mdx index 41c8d3d..6f48706 100644 --- a/docs/docs/config/02-statuses.mdx +++ b/docs/docs/config/02-statuses.mdx @@ -63,7 +63,7 @@ test('broken test', () => { If your test assertions _throw errors directly_ instead of using or extending Jest's [`expect` API][expect], they will be reported as ⁠🟡 Broken tests by default. -To report them as 🔴 Failed tests, you should disable `brokenStatus`: +To report them as 🔴 Failed tests, you can use a `status` customizer function: ```js title="jest.config.js" module.exports = { @@ -72,7 +72,7 @@ module.exports = { // ... ['jest-allure2-reporter', { // highlight-next-line - brokenStatus: false, + status: ({ value }) => value === 'broken' ? 'failed' : value, }], ], }; diff --git a/docs/docs/config/04-environment.mdx b/docs/docs/config/04-environment.mdx index 424b609..0c94947 100644 --- a/docs/docs/config/04-environment.mdx +++ b/docs/docs/config/04-environment.mdx @@ -22,7 +22,9 @@ It is always a good idea to include environment information in your test reports ## Configuration -By default, the environment information is not included in the report. To enable it, you need to add the following configuration to your `jest.config.js` file. In the example below, we're using the [lodash] library to filter out any sensitive information from the environment variables, and we also include the name and version of the package under test, as well as the type of the operating system: +By default, the environment information is not included in the report. To enable it, you need to add the following configuration to your `jest.config.js` file. + +In the example below, we're using the [lodash] library to filter out any sensitive information from the environment variables, and we also include the name and version of the package under test, as well as the type of the operating system: @@ -54,7 +56,7 @@ module.exports = { ``` - + Environment diff --git a/docs/docs/config/05-executor.mdx b/docs/docs/config/05-executor.mdx index 3652944..7d98fb7 100644 --- a/docs/docs/config/05-executor.mdx +++ b/docs/docs/config/05-executor.mdx @@ -31,7 +31,7 @@ By default, the executor information is included in the report if a CI/CD enviro However, if you want to report local test runs as well, you'll need to tweak the configuration at your taste, e.g.: - + Executor diff --git a/docs/docs/config/06-history.mdx b/docs/docs/config/06-history.mdx index bae00fd..5e3f06d 100644 --- a/docs/docs/config/06-history.mdx +++ b/docs/docs/config/06-history.mdx @@ -35,7 +35,7 @@ and inspect all the necessary details of their execution. :::tip -If you attempt to retry your tests using methods not typically employed, such as +If you attempt to retry your tests using unconventional methods, such as running `jest` multiple times, you'll need to make sure that you don't delete the `allure-results` directory between the runs: @@ -106,7 +106,7 @@ To make the history feature work in an environment where tests can be renamed, skipped, or moved around, Allure 2 Framework needs a way to identify tests across multiple test runs in the past, present, and future. -The core component behind test identification is `testCaseId` — a unique identifier +The property behind test identification is `testCaseId` — a unique identifier generated for each test[^1]. When Allure 2 framework aggregates reports from multiple test runs in the past, this identifier is the only way to tell which tests are the same, and which are different. diff --git a/docs/docs/config/07-errors.mdx b/docs/docs/config/07-errors.mdx index 13b62ce..91da102 100644 --- a/docs/docs/config/07-errors.mdx +++ b/docs/docs/config/07-errors.mdx @@ -19,39 +19,19 @@ This article is just a draft. It is not yet complete, and you should not read it There are a few things to know about Jest failures and how they are reported to Allure. -## Defect categories - -## `beforeAll` and `afterAll` hooks - -Jest's test runner lifecycle is not similar to Java testing frameworks, therefore some concepts do not translate well. Here are some things to keep in mind: - -* a failure in a `beforeAll` hook will be reported as a test failure for all tests in the suite. (TODO: check if this is true for `afterAll` as well) - ``` - TODO: add a screenshot - ``` -* a `beforeAll` hook is not considered a test, so it will not be reported as a test in Allure. The only sane place to report it is as the first step of the first test in the suite. - ``` - TODO: add a screenshot - ``` -* even this approach is not perfect, especially when you use `jest.retryTimes()` – if the first test is retried, the `beforeAll` step will be reported only once, whereas the test will be reported multiple times in "Retries" section. - ``` - TODO: add a screenshot - ``` - -## `--bail` option +## Early failures -If you use the `--bail` option, Jest will stop running tests after the first failure. This is useful for debugging, but it also means that you will not see all the failures in the report, and the report itself might be incomplete. +If your test environment setup fails, Jest will not run any tests. If your test file has syntax errors, Jest even won't be able to understand which tests are defined in the file. Therefore, the only way to report these failures is to report them as a test case failure. ``` TODO: add a screenshot ``` -## Early failures +## `--bail` option -If your test environment setup fails, Jest will not run any tests. If your test file has syntax errors, Jest even won't be able to understand which tests are defined in the file. Therefore, the only way to report these failures is to report them as a test case failure. +If you use the `--bail` option, Jest will stop running tests after the first failure. This is useful for debugging, but it also means that you will not see all the failures in the report, and the report itself might be incomplete. ``` TODO: add a screenshot ``` -TODO: how to configure this? diff --git a/e2e/configs/default.js b/e2e/configs/default.js index 1ade58d..20882ab 100644 --- a/e2e/configs/default.js +++ b/e2e/configs/default.js @@ -5,6 +5,14 @@ const PRESET = process.env.ALLURE_PRESET ?? 'default'; /** @type {import('jest-allure2-reporter').ReporterOptions} */ const jestAllure2ReporterOptions = { resultsDir: `allure-results/${PRESET}`, + environment: (context) => { + return ({ + 'version.node': process.version, + 'version.jest': require('jest/package.json').version, + 'package.name': context.manifest.name, + 'package.version': context.manifest.version, + }); + }, testCase: { name: ({ testCase }) => [...testCase.ancestorTitles, testCase.title].join(' » '), @@ -18,6 +26,7 @@ const jestAllure2ReporterOptions = { package: ({ filePath }) => filePath.slice(0, -1).join('.'), testClass: ({ filePath }) => filePath.join('.').replace(/\.test\.[jt]s$/, ''), testMethod: ({ testCase }) => testCase.fullName, + owner: ({ value }) => value ?? 'Unknown', }, }, }; diff --git a/e2e/plugins/jsdocProcessor.js b/e2e/plugins/jsdocProcessor.js deleted file mode 100644 index 25d5410..0000000 --- a/e2e/plugins/jsdocProcessor.js +++ /dev/null @@ -1,16 +0,0 @@ -const parser = require('jsdoc-undocumented'); - -/** @type {import('jest-allure2-reporter').Plugin} */ -const jsdocProcessorPlugin = (options) => { - return { - name: 'jest-allure2-jsdoc-processor', - - testEntry: (context) => { - const metadata = context.metadata.entry; - const { code } = metadata.get(['allure2']); - const { body, description, descriptionHtml, owner, link } = parser.parse(code); - }, - }; -}; - -module.exports = {}; diff --git a/e2e/plugins/remarkProcessor.js b/e2e/plugins/remarkProcessor.js deleted file mode 100644 index 0142167..0000000 --- a/e2e/plugins/remarkProcessor.js +++ /dev/null @@ -1,20 +0,0 @@ -/** @type {import('jest-allure2-reporter').Plugin} */ -const remarkProcessorPlugin = (options) => { - return { - name: 'jest-allure2-remark-processor', - - beforeReport: (context) => { - for (const metadata of context.metadata.all()) { - const { description, descriptionHtml } = metadata.get(['allure2']); - if (!descriptionHtml && description) { - metadata.assign(['allure2'], { - description: undefined, - descriptionHtml: context.processMd(description), - }); - } - } - }, - }; -}; - -module.exports = {}; diff --git a/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts b/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts index c046a45..5a3f772 100644 --- a/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts +++ b/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts @@ -1,6 +1,14 @@ import { $Epic, $Feature, $Story, $Tag } from 'jest-allure2-reporter'; import LoginHelper from '../../../../utils/LoginHelper'; +/** + * Client tests for login screen + * @owner Security Team + * @severity Critical + * @tag regression,auth + * @tag smoke + */ + $Tag('client'); $Epic('Authentication'); $Feature('Login'); @@ -15,6 +23,8 @@ describe('Login screen', () => { $Story('Validation'); describe('Form Submission', () => { it('should show error on invalid e-mail format', async () => { + /** @owner Samantha Jones */ + await LoginHelper.typeEmail('someone#example.com'); await LoginHelper.typePassword('123456'); expect(LoginHelper.snapshotForm()).toContain('someone#example.com'); diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..32aa576 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,590 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ + +import type { Config, TestCaseResult, TestResult } from '@jest/reporters'; +import type { + Attachment, + Category, + ExecutorInfo, + Label, + LabelName, + Link, + LinkType, + Parameter, + ParameterOptions, + Severity, + Stage, + Status, + StatusDetails, +} from '@noomorph/allure-js-commons'; + +import type { Function_, MaybePromise } from './utils/types'; +import type { JestAllure2Reporter } from './src'; + +declare module 'jest-allure2-reporter' { + /** + * Configuration options for the `jest-allure2-reporter` package. + * These options are used in your Jest config. + * + * @example + * /** @type {import('@jest/types').Config.InitialOptions} *\/ + * module.exports = { + * // ... + * reporters: [ + * 'default', + * ['jest-allure2-reporter', { + * resultsDir: 'allure-results', + * testCase: {}, + * environment: () => process.env, + * executor: ({ value }) => value ?? ({ + * type: process.platform, + * name: require('os').hostname() + * }), + * categories: ({ value }) => [ + * ...value, + * { + * name: 'Custom defect category', + * messageRegex: '.*Custom defect message.*', + * }, + * ], + * }], + * ], + * }; + */ + export type ReporterOptions = { + /** + * Overwrite the results directory if it already exists. + * @default true + */ + overwrite?: boolean; + /** + * Specifies where to output test result files. + * Please note that the results directory is not a ready-to-use Allure report. + * You'll need to generate the report using the `allure` CLI. + * + * @default 'allure-results' + */ + resultsDir?: string; + /** + * Configures how external attachments are attached to the report. + */ + attachments?: AttachmentsOptions; + /** + * 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. + */ + categories?: Category[] | CategoriesCustomizer; + /** + * Configures the environment information that will be reported. + */ + 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?: ExecutorInfo | ExecutorCustomizer; + /** + * Customize how test cases are reported: names, descriptions, labels, status, etc. + */ + testCase?: Partial; + /** + * Customize how individual test steps are reported. + */ + testStep?: Partial; + /** + * Plugins to extend the reporter functionality. + * Via plugins, you can extend the context used by customizers. + */ + plugins?: PluginDeclaration[]; + }; + + export type ReporterConfig = { + overwrite: boolean; + resultsDir: string; + attachments: Required; + categories: CategoriesCustomizer; + environment: EnvironmentCustomizer; + executor: ExecutorCustomizer; + testCase: ResolvedTestCaseCustomizer; + testStep: ResolvedTestStepCustomizer; + plugins: Promise; + }; + + export type SharedReporterConfig = Pick< + ReporterConfig, + 'resultsDir' | 'overwrite' | 'attachments' + >; + + export type AttachmentsOptions = { + /** + * Defines a subdirectory within the {@link ReporterOptions#resultsDir} where attachments will be stored. + * @default 'attachments' + */ + subDir?: string; + /** + * Specifies strategy for attaching files to the report by their path. + * - `copy` - copy the file to {@link AttachmentsOptions#subDir} + * - `move` - move the file to {@link AttachmentsOptions#subDir} + * - `ref` - use the file path as is + * @default 'ref' + * @see {@link AllureRuntime#createFileAttachment} + */ + fileHandler?: BuiltinFileHandler; + }; + + /** @see {@link AttachmentsOptions#fileHandler} */ + export type BuiltinFileHandler = 'copy' | 'move' | 'ref'; + + /** + * Global customizations for how test cases are reported + */ + export interface TestCaseCustomizer { + /** + * Test case ID extractor to fine-tune Allure's history feature. + * @example ({ package, file, test }) => `${package.name}:${file.path}:${test.fullName}` + * @example ({ test }) => `${test.identifier}:${test.title}` + * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/history/#test-case-id + */ + historyId: TestCaseExtractor; + /** + * Extractor for the default test or step name. + * @default ({ test }) => test.title + */ + name: TestCaseExtractor; + /** + * Extractor for the full test case name. + * @default ({ test }) => test.fullName + */ + fullName: TestCaseExtractor; + /** + * Extractor for the test case start timestamp. + */ + start: TestCaseExtractor; + /** + * Extractor for the test case stop timestamp. + */ + stop: TestCaseExtractor; + /** + * Extractor for the test case description. + * @example ({ testCaseMetadata }) => '```js\n' + testCaseMetadata.code + '\n```' + */ + description: TestCaseExtractor; + /** + * Extractor for the test case description in HTML format. + * @example ({ testCaseMetadata }) => '
' + testCaseMetadata.code + '
' + */ + descriptionHtml: TestCaseExtractor; + /** + * Extractor for the test case stage. + */ + stage: TestCaseExtractor; + /** + * Extractor for the test case status. + * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/statuses/ + * @example ({ value }) => value === 'broken' ? 'failed' : value + */ + status: TestCaseExtractor; + /** + * Extractor for the test case status details. + */ + statusDetails: TestCaseExtractor; + /** + * Customize Allure labels for the test case. + * + * @example + * { + * suite: ({ file }) => file.path, + * subSuite: ({ test }) => test.ancestorTitles[0], + * } + */ + labels: LabelsCustomizer; + /** + * Resolve issue links for the test case. + * + * @example + * { + * issue: ({ value }) => ({ + * type: 'issue', + * name: value.name ?? `Open ${value.url} in JIRA`, + * url: `https://jira.company.com/${value.url}`, + * }), + * } + */ + links: LinksCustomizer; + /** + * Customize step or test case attachments. + */ + attachments: TestCaseExtractor; + /** + * Customize step or test case parameters. + */ + parameters: TestCaseExtractor; + } + + export type ResolvedTestCaseCustomizer = Required & { + labels: TestCaseExtractor; + links: TestCaseExtractor; + }; + + export type ResolvedTestStepCustomizer = Required; + + export interface TestStepCustomizer { + /** + * Extractor for the step name. + * @example ({ value }) => value.replace(/(before|after)(Each|All)/, (_, p1, p2) => p1 + ' ' + p2.toLowerCase()) + */ + name: TestStepExtractor; + /** + * Extractor for the test step start timestamp. + */ + start: TestStepExtractor; + /** + * Extractor for the test step stop timestamp. + */ + stop: TestStepExtractor; + /** + * Extractor for the test step stage. + * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/statuses/ + * TODO: add example + */ + stage: TestStepExtractor; + /** + * Extractor for the test step status. + * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/statuses/ + * @example ({ value }) => value === 'broken' ? 'failed' : value + */ + status: TestStepExtractor; + /** + * Extractor for the test step status details. + */ + statusDetails: TestStepExtractor; + /** + * Customize step or test step attachments. + */ + attachments: TestStepExtractor; + /** + * Customize step or test step parameters. + */ + parameters: TestStepExtractor; + } + + export type EnvironmentCustomizer = GlobalExtractor>; + + export type ExecutorCustomizer = GlobalExtractor; + + export type CategoriesCustomizer = GlobalExtractor; + + export type LinksCustomizer = + | TestCaseExtractor + | Record>; + + export type LabelsCustomizer = + | TestCaseExtractor + | Partial<{ + 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]: LabelConfig; + }>; + + export type LabelConfig = LabelValue | LabelExtractor; + + export type LabelValue = string | string[]; + + export type LabelExtractor = TestCaseExtractor; + + export type Extractor, R = T> = ( + context: Readonly, + ) => R | undefined; + + export type GlobalExtractor = Extractor< + T, + GlobalExtractorContext, + R + >; + + export type TestCaseExtractor = Extractor< + T, + TestCaseExtractorContext, + R + >; + + export type TestStepExtractor = Extractor< + T, + TestStepExtractorContext, + R + >; + + export interface ExtractorContext { + value: T | undefined; + } + + export interface GlobalExtractorContext + extends ExtractorContext, + GlobalExtractorContextAugmentation { + globalConfig: Config.GlobalConfig; + config: ReporterConfig; + } + + export interface TestFileExtractorContext + extends GlobalExtractorContext, + TestFileExtractorContextAugmentation { + filePath: string[]; + testFile: TestResult; + } + + export interface TestCaseExtractorContext + extends TestFileExtractorContext, + TestCaseExtractorContextAugmentation { + testCase: TestCaseResult; + testCaseMetadata: AllureTestCaseMetadata; + } + + export interface TestStepExtractorContext + extends TestCaseExtractorContext, + TestStepExtractorContextAugmentation { + testStepMetadata: AllureTestStepMetadata; + } + + export interface AllureTestStepMetadata { + steps?: AllureTestStepMetadata[]; + hidden?: boolean; + /** + * Source code of the test case, test step or a hook. + */ + code?: string[]; + + name?: string; + status?: Status; + statusDetails?: StatusDetails; + stage?: Stage; + attachments?: Attachment[]; + parameters?: Parameter[]; + start?: number; + stop?: number; + } + + export interface AllureTestCaseMetadata extends AllureTestStepMetadata { + /** + * Pointer to the child step that is currently being added or executed. + * @example ['steps', '0', 'steps', '0'] + * @internal + */ + currentStep?: string[]; + /** + * Jest worker ID. + * @internal Used to generate unique thread names. + * @see {import('@noomorph/allure-js-commons').LabelName.THREAD} + */ + workerId?: string; + /** + * Only steps can have names. + */ + name?: never; + description?: string[]; + descriptionHtml?: string[]; + labels?: Label[]; + links?: Link[]; + } + + export interface GlobalExtractorContextAugmentation { + processMarkdown?(markdown: string): MaybePromise; + + // This should be extended by plugins + } + + export interface TestFileExtractorContextAugmentation { + // This should be extended by plugins + } + + export interface TestCaseExtractorContextAugmentation { + // This should be extended by plugins + } + + export interface TestStepExtractorContextAugmentation { + // This should be extended by plugins + } + + export type PluginDeclaration = + | PluginReference + | [PluginReference, Record]; + + export type PluginReference = string | PluginConstructor; + + export type PluginConstructor = ( + options: Record, + context: PluginContext, + ) => Plugin; + + export type PluginContext = Readonly<{ + globalConfig: Config.GlobalConfig; + }>; + + export interface Plugin { + /** Also used to deduplicate plugins if they are declared multiple times. */ + readonly name: string; + + /** Optional method for deduplicating plugins. Return the instance which you want to keep. */ + extend?(previous: Plugin): Plugin; + + /** Method to extend global context. */ + globalContext?(context: GlobalExtractorContext): void | Promise; + + /** Method to extend test file context. */ + testFileContext?( + context: TestFileExtractorContext, + ): void | Promise; + + /** Method to extend test entry context. */ + testCaseContext?( + context: TestCaseExtractorContext, + ): void | Promise; + + /** Method to extend test step context. */ + testStepContext?( + context: TestStepExtractorContext, + ): void | Promise; + } + + export type PluginHookName = + | 'globalContext' + | 'testFileContext' + | 'testCaseContext' + | 'testStepContext'; + + export interface IAllureRuntime { + flush(): Promise; + + description(value: string): void; + + descriptionHtml(value: string): void; + + status(status: Status, statusDetails?: StatusDetails): void; + + statusDetails(statusDetails: StatusDetails): void; + + label(name: LabelName | string, value: string): void; + + link(name: string, url: string, type?: string): void; + + parameter(name: string, value: unknown, options?: ParameterOptions): void; + + parameters(parameters: Record): void; + + attachment( + name: string, + content: MaybePromise, + mimeType?: string, + ): typeof content; + + createAttachment( + function_: Function_>, + name: string, + ): typeof function_; + createAttachment( + function_: Function_>, + options: AttachmentOptions, + ): typeof function_; + + fileAttachment(filePath: string, name?: string): string; + fileAttachment(filePath: string, options?: AttachmentOptions): string; + fileAttachment( + filePathPromise: Promise, + name?: string, + ): Promise; + fileAttachment( + filePathPromise: Promise, + options?: AttachmentOptions, + ): Promise; + + createFileAttachment( + function_: Function_>, + ): typeof function_; + createFileAttachment( + function_: Function_>, + name: string, + ): typeof function_; + createFileAttachment( + function_: Function_>, + options: AttachmentOptions, + ): typeof function_; + + createStep(name: string, function_: F): F; + createStep( + name: string, + arguments_: ParameterOrString[], + function_: F, + ): F; + + step(name: string, function_: () => T): T; + } + + export type ParameterOrString = string | Omit; + + export type AttachmentContent = Buffer | string; + + export type AttachmentOptions = { + name?: string; + mimeType?: string; + }; + + // Reporter + export const reporter: JestAllure2Reporter; + export default reporter; + + // Runtime + export const allure: IAllureRuntime; + + // Pseudo-annotations + export const $Description: (description: string) => void; + export const $DescriptionHtml: (descriptionHtml: string) => void; + export const $Epic: (epic: string) => void; + export const $Feature: (feature: string) => void; + export const $Issue: (issue: string) => void; + export const $Link: + | ((link: Link) => void) + | ((url: string, name?: string) => void); + export const $Owner: (owner: string) => void; + export const $Severity: (severity: Severity[keyof Severity]) => void; + export const $Story: (story: string) => void; + export const $Tag: (...tagNames: string[]) => void; + export const $TmsLink: (tmsLink: string) => void; + + // Decorators + export function Attachment(name: string, mimeType?: string): MethodDecorator; + export function FileAttachment( + name: string, + mimeType?: string, + ): MethodDecorator; + export function Step( + name: string, + arguments_?: ParameterOrString[], + ): MethodDecorator; + + // Common types + export { + Category, + Link, + LinkType, + Parameter, + ParameterOptions, + ExecutorInfo, + Severity, + Status, + Stage, + } from '@noomorph/allure-js-commons'; +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..f4f0e99 --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require('./dist/index'); diff --git a/package.json b/package.json index e2d9cc3..d69fb51 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,13 @@ "engines": { "node": ">=16.20.0" }, - "main": "./dist/index.js", - "typings": "./dist/index.d.ts", + "main": "./index.js", + "typings": "./index.d.ts", "exports": { ".": { - "import": "./dist/index.js", - "require": "./dist/index.js", - "types": "./dist/index.d.ts" + "import": "./index.js", + "require": "./index.js", + "types": "./index.d.ts" }, "./environment-decorator": { "import": "./dist/environment/decorator.js", @@ -76,14 +76,14 @@ "@typescript-eslint/eslint-plugin": "^5.28.0", "@typescript-eslint/parser": "^5.28.0", "cz-conventional-changelog": "^3.3.0", - "eslint": "^8.17.0", - "eslint-config-prettier": "^8.5.0", + "eslint": "^8.52.0", + "eslint-config-prettier": "^9.0.0", "eslint-plugin-ecmascript-compat": "^3.0.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-jsdoc": "^39.3.2", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prefer-arrow": "^1.2.3", - "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-unicorn": "^47.0.0", "fs-extra": "^10.1.0", "glob": "^8.0.3", @@ -92,7 +92,6 @@ "lerna": "^6.6.2", "lint-staged": "^14.0.1", "lodash": "^4.17.21", - "prettier": "^2.7.0", "semantic-release": "^22.0.5", "tempfile": "^3.0.0", "ts-jest": "^29.0.0", @@ -102,13 +101,28 @@ "dependencies": { "@noomorph/allure-js-commons": "^2.3.0", "ci-info": "^3.8.0", - "jest-metadata": "^1.1.1", + "jest-metadata": "^1.2.1", "pkg-up": "^3.1.0", + "prettier": "^3.0.3", + "rehype-highlight": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.0", + "remark": "^15.0.1", + "remark-rehype": "^11.0.0", "rimraf": "^4.3.1", "strip-ansi": "^6.0.0" }, "peerDependencies": { - "jest": ">=27.2.5" + "jest": ">=27.2.5", + "jest-docblock": ">=27.2.5" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + }, + "jest-docblock": { + "optional": true + } }, "browserslist": [ "node 16" diff --git a/src/builtin-plugins/augs.d.ts b/src/builtin-plugins/augs.d.ts new file mode 100644 index 0000000..01f7324 --- /dev/null +++ b/src/builtin-plugins/augs.d.ts @@ -0,0 +1,12 @@ +declare module 'jest-allure2-reporter' { + interface GlobalExtractorContextAugmentation { + /** + * The contents of the `package.json` file if it exists. + */ + manifest?: { + name: string; + version: string; + [key: string]: unknown; + } | null; + } +} diff --git a/src/builtin-plugins/index.ts b/src/builtin-plugins/index.ts new file mode 100644 index 0000000..613b0da --- /dev/null +++ b/src/builtin-plugins/index.ts @@ -0,0 +1,4 @@ +export { jsdocPlugin as jsdoc } from './jsdoc'; +export { manifestPlugin as manifest } from './manifest'; +export { prettierPlugin as prettier } from './prettier'; +export { remarkPlugin as remark } from './remark'; diff --git a/src/builtin-plugins/jsdoc.ts b/src/builtin-plugins/jsdoc.ts new file mode 100644 index 0000000..a3c0c32 --- /dev/null +++ b/src/builtin-plugins/jsdoc.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports,import/no-extraneous-dependencies */ +import type { Plugin, PluginConstructor } from 'jest-allure2-reporter'; +import { state } from 'jest-metadata'; +import type { Metadata } from 'jest-metadata'; +import type { Label } from '@noomorph/allure-js-commons'; + +import { DOCBLOCK, DESCRIPTION, LABELS } from '../constants'; + +type ParseWithComments = typeof import('jest-docblock').parseWithComments; + +function mergeJsDocument( + parseWithComments: ParseWithComments, + metadata: Metadata, +) { + const jsdoc = metadata.get(DOCBLOCK, ''); + if (jsdoc) { + const { comments, pragmas } = parseWithComments(jsdoc); + if (comments) { + metadata.unshift(DESCRIPTION, [comments]); + } + + if (pragmas) { + const labels = Object.entries(pragmas).map(createLabel); + metadata.unshift(LABELS, labels); + } + } +} + +function createLabel(entry: [string, string]): Label { + const [name, value] = entry; + return { name, value }; +} + +export const jsdocPlugin: PluginConstructor = () => { + const plugin: Plugin = { + name: 'jest-allure2-reporter/plugins/jsdoc', + async globalContext() { + try { + const { parseWithComments } = await import('jest-docblock'); + for (const testFileMetadata of state.testFiles) { + for (const testEntryMetadata of testFileMetadata.allTestEntries()) { + mergeJsDocument(parseWithComments, testEntryMetadata); + } + } + } catch (error: any) { + if (error?.code !== 'MODULE_NOT_FOUND') { + throw error; + } + } + }, + }; + + return plugin; +}; diff --git a/src/builtin-plugins/manifest.ts b/src/builtin-plugins/manifest.ts new file mode 100644 index 0000000..6f0b0d2 --- /dev/null +++ b/src/builtin-plugins/manifest.ts @@ -0,0 +1,30 @@ +/// + +import fs from 'node:fs'; + +import type { Plugin, PluginConstructor } from 'jest-allure2-reporter'; +import pkgUp from 'pkg-up'; + +export const manifestPlugin: PluginConstructor = (_1, { globalConfig }) => { + const plugin: Plugin = { + name: 'jest-allure2-reporter/plugins/manifest', + async globalContext(context) { + const manifestPath = await pkgUp({ + cwd: globalConfig.rootDir, + }); + + context.manifest = null; + if (manifestPath) { + try { + context.manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch { + console.warn( + `[${plugin.name}] Failed to read package.json from ${manifestPath}`, + ); + } + } + }, + }; + + return plugin; +}; diff --git a/src/builtin-plugins/prettier.ts b/src/builtin-plugins/prettier.ts new file mode 100644 index 0000000..7829e65 --- /dev/null +++ b/src/builtin-plugins/prettier.ts @@ -0,0 +1,52 @@ +/// + +import type { Plugin, PluginConstructor } from 'jest-allure2-reporter'; +import type { Options } from 'prettier'; + +export const prettierPlugin: PluginConstructor = ( + overrides, + { globalConfig }, +) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const prettier = require('prettier'); + let prettierConfig: Options; + + function formatCode( + code: string | string[] | undefined, + ): undefined | Promise { + if (!code) { + return; + } + + return Array.isArray(code) + ? Promise.all( + code.map((fragment) => { + const trimmed = fragment.trim(); + return prettier + .format(trimmed, prettierConfig) + .catch((error: unknown) => { + throw error; + }); + }), + ) + : formatCode([code]); + } + + const plugin: Plugin = { + name: 'jest-allure2-reporter/plugins/prettier', + async globalContext() { + prettierConfig = { + parser: 'acorn', + ...(await prettier.resolveConfig(globalConfig.rootDir)), + ...overrides, + }; + }, + async testCaseContext(context) { + context.testCaseMetadata.code = await formatCode( + context.testCaseMetadata.code, + ); + }, + }; + + return plugin; +}; diff --git a/src/builtin-plugins/remark.ts b/src/builtin-plugins/remark.ts new file mode 100644 index 0000000..43bbd22 --- /dev/null +++ b/src/builtin-plugins/remark.ts @@ -0,0 +1,33 @@ +/// + +import type { Plugin, PluginConstructor } from 'jest-allure2-reporter'; + +export const remarkPlugin: PluginConstructor = () => { + const plugin: Plugin = { + name: 'jest-allure2-reporter/plugins/remark', + async globalContext(context) { + const remark = await import('remark'); + const [remarkRehype, rehypeSanitize, rehypeStringify, rehypeHighlight] = + await Promise.all([ + import('remark-rehype'), + import('rehype-sanitize'), + import('rehype-stringify'), + import('rehype-highlight'), + ]); + + const processor = remark + .remark() + .use(remarkRehype.default) + .use(rehypeSanitize.default) + .use(rehypeHighlight.default) + .use(rehypeStringify.default); + + context.processMarkdown = (markdown: string) => { + const result = processor.processSync(markdown); + return String(result); + }; + }, + }; + + return plugin; +}; diff --git a/src/constants.ts b/src/constants.ts index b49156a..1d1e8a8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,7 @@ export const PREFIX = 'allure2' as const; export const SHARED_CONFIG = [PREFIX, 'config'] as const; export const CODE = [PREFIX, 'code'] as const; +export const DOCBLOCK = [PREFIX, 'docblock'] as const; export const WORKER_ID = [PREFIX, 'workerId'] as const; export const HIDDEN = [PREFIX, 'hidden'] as const; diff --git a/src/decorators/Attachment.ts b/src/decorators/Attachment.ts index 7f73864..e65e1f3 100644 --- a/src/decorators/Attachment.ts +++ b/src/decorators/Attachment.ts @@ -2,11 +2,11 @@ import realm from '../realms'; const allure = realm.runtime; -export function Attachment(name: string, mimeType?: string) { +export function Attachment(name: string, mimeType?: string): MethodDecorator { return function ( _target: object, - _propertyName: string, - descriptor: TypedPropertyDescriptor<(...arguments_: any[]) => any>, + _propertyName: string | symbol, + descriptor: TypedPropertyDescriptor, ) { descriptor.value = allure.createAttachment(descriptor.value!, { name, diff --git a/src/decorators/FileAttachment.ts b/src/decorators/FileAttachment.ts index aa0ed71..517c9de 100644 --- a/src/decorators/FileAttachment.ts +++ b/src/decorators/FileAttachment.ts @@ -2,11 +2,14 @@ import realm from '../realms'; const allure = realm.runtime; -export function FileAttachment(name: string, mimeType?: string) { +export function FileAttachment( + name: string, + mimeType?: string, +): MethodDecorator { return function ( _target: object, - _propertyName: string, - descriptor: TypedPropertyDescriptor<(...arguments_: any[]) => any>, + _propertyName: string | symbol, + descriptor: TypedPropertyDescriptor, ) { descriptor.value = allure.createFileAttachment(descriptor.value!, { name, diff --git a/src/decorators/Step.ts b/src/decorators/Step.ts index a8470b7..5987892 100644 --- a/src/decorators/Step.ts +++ b/src/decorators/Step.ts @@ -1,13 +1,17 @@ +import type { ParameterOrString } from 'jest-allure2-reporter'; + import realm from '../realms'; -import type { ParameterOrString } from '../runtime'; const allure = realm.runtime; -export function Step(name: string, arguments_?: ParameterOrString[]) { +export function Step( + name: string, + arguments_?: ParameterOrString[], +): MethodDecorator { return function ( _target: object, - _propertyName: string, - descriptor: TypedPropertyDescriptor<(...arguments_: any[]) => Promise>, + _propertyName: string | symbol, + descriptor: TypedPropertyDescriptor, ) { descriptor.value = arguments_ ? allure.createStep(name, arguments_, descriptor.value!) diff --git a/src/environment/decorator.ts b/src/environment/decorator.ts index 66cf625..e07ae4a 100644 --- a/src/environment/decorator.ts +++ b/src/environment/decorator.ts @@ -5,13 +5,14 @@ import type { } from 'jest-metadata/environment-decorator'; import { state } from 'jest-metadata'; import { Stage, Status } from '@noomorph/allure-js-commons'; - import type { AllureTestCaseMetadata, AllureTestStepMetadata, -} from '../metadata'; +} from 'jest-allure2-reporter'; + import { PREFIX, WORKER_ID } from '../constants'; import realm from '../realms'; +import { splitDocblock } from '../utils/splitDocblock'; export function WithAllure2( JestEnvironmentClass: new (...arguments_: any[]) => E, @@ -49,26 +50,30 @@ export function WithAllure2( #addHook({ event, }: ForwardedCircusEvent) { - const sourceCode = event.fn.toString(); - const hidden = sourceCode.includes( + const code = event.fn.toString(); + const hidden = code.includes( "during setup, this cannot be null (and it's fine to explode if it is)", ); const metadata = { - code: [sourceCode], - } as AllureTestStepMetadata; + code, + } as Record; + if (hidden) { delete metadata.code; metadata.hidden = true; } + state.currentMetadata.assign(PREFIX, metadata); } #addTest({ event, }: ForwardedCircusEvent) { - const metadata: AllureTestCaseMetadata = { - code: [event.fn.toString()], + const [docblock, code] = splitDocblock(event.fn.toString()); + const metadata: Record = { + code, + docblock, }; state.currentMetadata.assign(PREFIX, metadata); @@ -93,14 +98,15 @@ export function WithAllure2( stop: Date.now(), stage: Stage.INTERRUPTED, status: Status.FAILED, - statusDetails: event.error - ? { - message: event.error.message, - trace: event.error.stack, - } - : {}, }; + if (event.error) { + metadata.statusDetails = { + message: event.error.message, + trace: event.error.stack, + }; + } + state.currentMetadata.assign(PREFIX, metadata); } @@ -114,7 +120,6 @@ export function WithAllure2( stop: Date.now(), stage: Stage.FINISHED, status: Status.PASSED, - statusDetails: {}, }; state.currentMetadata.assign(PREFIX, metadata); @@ -133,10 +138,19 @@ export function WithAllure2( #testDone({ event, }: ForwardedCircusEvent) { + const hasErrors = event.test.errors.length > 0; + const errorStatus = event.test.errors.some((errors) => { + return Array.isArray(errors) + ? errors.some(isMatcherError) + : isMatcherError(errors); + }) + ? Status.FAILED + : Status.BROKEN; + const metadata: AllureTestCaseMetadata = { stop: Date.now(), - stage: event.test.failing ? Stage.INTERRUPTED : Stage.FINISHED, - status: event.test.failing ? Status.FAILED : Status.PASSED, + stage: hasErrors ? Stage.INTERRUPTED : Stage.FINISHED, + status: hasErrors ? errorStatus : Status.PASSED, }; state.currentMetadata.assign(PREFIX, metadata); @@ -145,6 +159,10 @@ export function WithAllure2( }[compositeName] as unknown as new (...arguments_: any[]) => E; } +function isMatcherError(error: any) { + return Boolean(error?.matcherResult); +} + /** * @inheritDoc */ diff --git a/src/index.ts b/src/index.ts index eb62414..e311c00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,24 @@ +/// + +import type { IAllureRuntime } from 'jest-allure2-reporter'; + import realm from './realms'; -import type { IAllureRuntime } from './runtime'; export { JestAllure2Reporter } from './reporter/JestAllure2Reporter'; export { JestAllure2Reporter as default } from './reporter/JestAllure2Reporter'; -export { ReporterOptions } from './options/ReporterOptions'; export * from './annotations'; export * from './decorators'; +export { + Category, + Link, + LinkType, + Parameter, + ParameterOptions, + ExecutorInfo, + Severity, + Status, + Stage, +} from '@noomorph/allure-js-commons'; + export const allure = realm.runtime as IAllureRuntime; diff --git a/src/metadata/MetadataSquasher.ts b/src/metadata/MetadataSquasher.ts index b61ad5a..48ae886 100644 --- a/src/metadata/MetadataSquasher.ts +++ b/src/metadata/MetadataSquasher.ts @@ -7,11 +7,11 @@ import type { TestInvocationMetadata, HookInvocationMetadata, } from 'jest-metadata'; +import type { AllureTestCaseMetadata } from 'jest-allure2-reporter'; import { WORKER_ID } from '../constants'; -import { chain, extractCode, getStart, getStop } from './utils'; -import type { AllureTestCaseMetadata } from './metadata'; +import { chain, chainLast, extractCode, getStart, getStop } from './utils'; export class MetadataSquasher { protected readonly testInvocationConfig: MetadataSquasherConfig; @@ -55,6 +55,8 @@ export class MetadataSquasher { ]), attachments: chain(['testEntry', 'testInvocation', 'anyInvocation']), parameters: chain(['testEntry', 'testInvocation', 'anyInvocation']), + status: chainLast(['testInvocation']), + statusDetails: chainLast(['anyInvocation', 'testInvocation']), labels: chain([ 'globalMetadata', 'testFile', @@ -75,14 +77,6 @@ export class MetadataSquasher { stop: getStop, }; } - - // private static deepConfig(): MetadataSquasherConfig { - // return { - // ...this.flatConfig(), - // attachments: chain(['testEntry', 'testInvocation']), - // parameters: chain(['testEntry', 'testInvocation']), - // }; - // } } export type MetadataSquasherConfig = { diff --git a/src/metadata/StepExtractor.ts b/src/metadata/StepExtractor.ts index ba477f5..3f1a46f 100644 --- a/src/metadata/StepExtractor.ts +++ b/src/metadata/StepExtractor.ts @@ -3,11 +3,10 @@ import type { HookInvocationMetadata, TestFnInvocationMetadata, } from 'jest-metadata'; +import type { AllureTestStepMetadata } from 'jest-allure2-reporter'; import { HIDDEN, PREFIX } from '../constants'; -import type { AllureTestStepMetadata } from './metadata'; - export class StepExtractor { public extractFromInvocation( metadata: HookInvocationMetadata | TestFnInvocationMetadata, diff --git a/src/metadata/index.ts b/src/metadata/index.ts index 94ed2e9..efd92c3 100644 --- a/src/metadata/index.ts +++ b/src/metadata/index.ts @@ -1,3 +1,2 @@ -export * from './metadata'; export * from './MetadataSquasher'; export * from './StepExtractor'; diff --git a/src/metadata/metadata.ts b/src/metadata/metadata.ts deleted file mode 100644 index fbd7e19..0000000 --- a/src/metadata/metadata.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { - Attachment, - Label, - Link, - Parameter, - Stage, - Status, - StatusDetails, -} from '@noomorph/allure-js-commons'; - -export interface AllureTestStepMetadata { - steps?: AllureTestStepMetadata[]; - hidden?: boolean; - /** - * Source code of the test case, test step or a hook. - */ - code?: string[]; - - name?: string; - status?: Status; - statusDetails?: StatusDetails; - stage?: Stage; - attachments?: Attachment[]; - parameters?: Parameter[]; - start?: number; - stop?: number; -} - -export interface AllureTestCaseMetadata extends AllureTestStepMetadata { - /** - * Pointer to the child step that is currently being added or executed. - * @example ['steps', '0', 'steps', '0'] - * @internal - */ - currentStep?: string[]; - /** - * Jest worker ID. - * @internal Used to generate unique thread names. - * @see {import('@noomorph/allure-js-commons').LabelName.THREAD} - */ - workerId?: string; - /** - * Only steps can have names. - */ - name?: never; - description?: string[]; - descriptionHtml?: string[]; - labels?: Label[]; - links?: Link[]; -} diff --git a/src/metadata/utils/chain.ts b/src/metadata/utils/chain.ts index 9f23b49..0112d2c 100644 --- a/src/metadata/utils/chain.ts +++ b/src/metadata/utils/chain.ts @@ -20,3 +20,13 @@ export function chain( return metadatas.flatMap((metadata) => metadata.get(path, [])) as T[K]; }; } + +export function chainLast( + sources: (keyof MetadataSquasherContext)[], +): MetadataSquasherMapping { + const function_ = chain(sources); + + return (context: MetadataSquasherContext, key: K) => { + return (function_(context, key) as unknown as any[]).pop(); + }; +} diff --git a/src/metadata/utils/extractCode.ts b/src/metadata/utils/extractCode.ts index 577263c..45334cf 100644 --- a/src/metadata/utils/extractCode.ts +++ b/src/metadata/utils/extractCode.ts @@ -1,8 +1,8 @@ import type { HookInvocationMetadata } from 'jest-metadata'; import type { Metadata } from 'jest-metadata'; +import type { AllureTestCaseMetadata } from 'jest-allure2-reporter'; import type { MetadataSquasherMapping } from '../MetadataSquasher'; -import type { AllureTestCaseMetadata } from '../metadata'; import { CODE } from '../../constants'; export const extractCode: MetadataSquasherMapping< diff --git a/src/metadata/utils/getStart.ts b/src/metadata/utils/getStart.ts index f129725..4bf98d9 100644 --- a/src/metadata/utils/getStart.ts +++ b/src/metadata/utils/getStart.ts @@ -1,5 +1,6 @@ +import type { AllureTestCaseMetadata } from 'jest-allure2-reporter'; + import type { MetadataSquasherMapping } from '../MetadataSquasher'; -import type { AllureTestCaseMetadata } from '../metadata'; import { START } from '../../constants'; export const getStart: MetadataSquasherMapping< diff --git a/src/metadata/utils/getStop.ts b/src/metadata/utils/getStop.ts index 51a4e16..963166d 100644 --- a/src/metadata/utils/getStop.ts +++ b/src/metadata/utils/getStop.ts @@ -1,5 +1,6 @@ +import type { AllureTestCaseMetadata } from 'jest-allure2-reporter'; + import type { MetadataSquasherMapping } from '../MetadataSquasher'; -import type { AllureTestCaseMetadata } from '../metadata'; import { STOP } from '../../constants'; export const getStop: MetadataSquasherMapping< diff --git a/src/metadata/utils/index.ts b/src/metadata/utils/index.ts index 661a5e9..13ebde6 100644 --- a/src/metadata/utils/index.ts +++ b/src/metadata/utils/index.ts @@ -2,4 +2,3 @@ export * from './chain'; export * from './extractCode'; export * from './getStart'; export * from './getStop'; -export * from './stripStatusDetails'; diff --git a/src/options/ReporterOptions.ts b/src/options/ReporterOptions.ts deleted file mode 100644 index a294d54..0000000 --- a/src/options/ReporterOptions.ts +++ /dev/null @@ -1,348 +0,0 @@ -import type { TestCaseResult, TestResult } from '@jest/reporters'; -import type { - Attachment, - Category, - ExecutorInfo, - Label, - Link, - LinkType, - Parameter, - Stage, - Status, - StatusDetails, -} from '@noomorph/allure-js-commons'; -import type { Config } from '@jest/reporters'; - -import type { - AllureTestCaseMetadata, - AllureTestStepMetadata, -} from '../metadata'; - -/** - * Configuration options for the `jest-allure2-reporter` package. - * These options are used in your Jest config. - * - * @example - * /** @type {import('@jest/types').Config.InitialOptions} *\/ - * module.exports = { - * // ... - * reporters: [ - * 'default', - * ['jest-allure2-reporter', { - * resultsDir: 'allure-results', - * testCase: {}, - * environment: () => process.env, - * executor: ({ value }) => value ?? ({ - * type: process.platform, - * name: require('os').hostname() - * }), - * categories: ({ value }) => [ - * ...value, - * { - * name: 'Custom defect category', - * messageRegex: '.*Custom defect message.*', - * }, - * ], - * }], - * ], - * }; - */ -export type ReporterOptions = { - /** - * Overwrite the results directory if it already exists. - * @default true - */ - overwrite?: boolean; - /** - * Specifies where to output test result files. - * Please note that the results directory is not a ready-to-use Allure report. - * You'll need to generate the report using the `allure` CLI. - * - * @default 'allure-results' - */ - resultsDir?: string; - /** - * Configures how external attachments are attached to the report. - */ - attachments?: AttachmentsOptions; - /** - * 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. - */ - categories?: Category[] | CategoriesCustomizer; - /** - * Configures the environment information that will be reported. - */ - 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?: ExecutorInfo | ExecutorCustomizer; - /** - * Customize how test cases are reported: names, descriptions, labels, status, etc. - */ - testCase?: Partial; - /** - * Customize how individual test steps are reported. - */ - testStep?: Partial; -}; - -export type ReporterConfig = Required & { - attachments: Required; - testCase: ResolvedTestCaseCustomizer; - testStep: ResolvedTestStepCustomizer; - categories: CategoriesCustomizer; - environment: EnvironmentCustomizer; - executor: ExecutorCustomizer; -}; - -export type SharedReporterConfig = Pick< - ReporterConfig, - 'resultsDir' | 'overwrite' | 'attachments' ->; - -export type AttachmentsOptions = { - /** - * Defines a subdirectory within the {@link ReporterOptions#resultsDir} where attachments will be stored. - * @default 'attachments' - */ - subDir?: string; - /** - * Specifies strategy for attaching files to the report by their path. - * - `copy` - copy the file to {@link AttachmentsOptions#subDir} - * - `move` - move the file to {@link AttachmentsOptions#subDir} - * - `ref` - use the file path as is - * @default 'ref' - * @see {@link AllureRuntime#createFileAttachment} - */ - fileHandler?: BuiltinFileHandler; -}; - -/** @see {@link AttachmentsOptions#fileHandler} */ -export type BuiltinFileHandler = 'copy' | 'move' | 'ref'; - -/** - * Global customizations for how test cases are reported - */ -export interface TestCaseCustomizer { - /** - * Test case ID extractor to fine-tune Allure's history feature. - * @example ({ package, file, test }) => `${package.name}:${file.path}:${test.fullName}` - * @example ({ test }) => `${test.identifier}:${test.title}` - * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/history/#test-case-id - */ - historyId: TestCaseExtractor; - /** - * Extractor for the default test or step name. - * @default ({ test }) => test.title - */ - name: TestCaseExtractor; - /** - * Extractor for the full test case name. - * @default ({ test }) => test.fullName - */ - fullName: TestCaseExtractor; - /** - * Extractor for the test case start timestamp. - */ - start: TestCaseExtractor; - /** - * Extractor for the test case stop timestamp. - */ - stop: TestCaseExtractor; - /** - * Extractor for the test case description. - * @example ({ testCaseMetadata }) => '```js\n' + testCaseMetadata.code + '\n```' - */ - description: TestCaseExtractor; - /** - * Extractor for the test case description in HTML format. - * @example ({ testCaseMetadata }) => '
' + testCaseMetadata.code + '
' - */ - descriptionHtml: TestCaseExtractor; - /** - * Extractor for the test case stage. - */ - stage: TestCaseExtractor; - /** - * Extractor for the test case status. - * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/statuses/ - * @example ({ value }) => value === 'broken' ? 'failed' : value - */ - status: TestCaseExtractor; - /** - * Extractor for the test case status details. - */ - statusDetails: TestCaseExtractor; - /** - * Customize Allure labels for the test case. - * - * @example - * { - * suite: ({ file }) => file.path, - * subSuite: ({ test }) => test.ancestorTitles[0], - * } - */ - labels: LabelsCustomizer; - /** - * Resolve issue links for the test case. - * - * @example - * { - * issue: ({ value }) => ({ - * type: 'issue', - * name: value.name ?? `Open ${value.url} in JIRA`, - * url: `https://jira.company.com/${value.url}`, - * }), - * } - */ - links: LinksCustomizer; - /** - * Customize step or test case attachments. - */ - attachments: TestCaseExtractor; - /** - * Customize step or test case parameters. - */ - parameters: TestCaseExtractor; -} - -export type ResolvedTestCaseCustomizer = Required & { - labels: TestCaseExtractor; - links: TestCaseExtractor; -}; - -export type ResolvedTestStepCustomizer = Required; - -export interface TestStepCustomizer { - /** - * Extractor for the step name. - * @example ({ value }) => value.replace(/(before|after)(Each|All)/, (_, p1, p2) => p1 + ' ' + p2.toLowerCase()) - */ - name: TestStepExtractor; - /** - * Extractor for the test step start timestamp. - */ - start: TestStepExtractor; - /** - * Extractor for the test step stop timestamp. - */ - stop: TestStepExtractor; - /** - * Extractor for the test step stage. - * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/statuses/ - * TODO: add example - */ - stage: TestStepExtractor; - /** - * Extractor for the test step status. - * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/statuses/ - * @example ({ value }) => value === 'broken' ? 'failed' : value - */ - status: TestStepExtractor; - /** - * Extractor for the test step status details. - */ - statusDetails: TestStepExtractor; - /** - * Customize step or test step attachments. - */ - attachments: TestStepExtractor; - /** - * Customize step or test step parameters. - */ - parameters: TestStepExtractor; -} - -export type EnvironmentCustomizer = GlobalExtractor>; - -export type ExecutorCustomizer = GlobalExtractor; - -export type CategoriesCustomizer = GlobalExtractor; - -export type LinksCustomizer = - | TestCaseExtractor - | Record>; - -export type LabelsCustomizer = - | TestCaseExtractor - | Partial<{ - 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]: LabelConfig; - }>; - -export type LabelConfig = LabelValue | LabelExtractor; - -export type LabelValue = string | string[]; - -export type LabelExtractor = TestCaseExtractor; - -export type Extractor, R = T> = ( - context: Readonly, -) => R | undefined; - -export type GlobalExtractor = Extractor< - T, - GlobalExtractorContext, - R ->; - -export type TestCaseExtractor = Extractor< - T, - TestCaseExtractorContext, - R ->; - -export type TestStepExtractor = Extractor< - T, - TestStepExtractorContext, - R ->; - -export interface ExtractorContext { - value: T | undefined; -} - -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; -} - -export interface TestStepExtractorContext - extends TestCaseExtractorContext { - testStep: AllureTestStepMetadata; -} diff --git a/src/options/aggregateLabelCustomizers.ts b/src/options/aggregateLabelCustomizers.ts index 4305f3a..b55e4b8 100644 --- a/src/options/aggregateLabelCustomizers.ts +++ b/src/options/aggregateLabelCustomizers.ts @@ -1,12 +1,12 @@ /* eslint-disable unicorn/no-array-reduce */ import type { Label } from '@noomorph/allure-js-commons'; - import type { LabelExtractor, LabelsCustomizer, TestCaseExtractor, TestCaseExtractorContext, -} from './ReporterOptions'; +} from 'jest-allure2-reporter'; + import { asExtractor } from './asExtractor'; export function aggregateLabelCustomizers( @@ -16,22 +16,28 @@ export function aggregateLabelCustomizers( return labels; } - 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 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[] = []; - const found = names.reduce((found, key) => { - found[key] = []; - return found; - }, {} as Record); + const found = names.reduce( + (found, key) => { + found[key] = []; + return found; + }, + {} as Record, + ); if (context.value) { for (const label of context.value) { @@ -50,7 +56,7 @@ export function aggregateLabelCustomizers( const value = asArray( extractor({ ...context, value: asArray(found[name]) }), ); - return value ? value.map((value) => ({ name, value } as Label)) : []; + return value ? value.map((value) => ({ name, value }) as Label) : []; }), ]; diff --git a/src/options/aggregateLinkCustomizers.ts b/src/options/aggregateLinkCustomizers.ts index a133bee..bc65bc7 100644 --- a/src/options/aggregateLinkCustomizers.ts +++ b/src/options/aggregateLinkCustomizers.ts @@ -1,10 +1,9 @@ import type { Link } from '@noomorph/allure-js-commons'; - import type { LinksCustomizer, TestCaseExtractor, TestCaseExtractorContext, -} from './ReporterOptions'; +} from 'jest-allure2-reporter'; export function aggregateLinkCustomizers( links: LinksCustomizer | undefined, diff --git a/src/options/asExtractor.ts b/src/options/asExtractor.ts index 542f1f2..bc025c6 100644 --- a/src/options/asExtractor.ts +++ b/src/options/asExtractor.ts @@ -1,4 +1,4 @@ -import type { Extractor } from './ReporterOptions'; +import type { Extractor } from 'jest-allure2-reporter'; /** * Resolves the unknown value either as an extractor or it diff --git a/src/options/composeExtractors.ts b/src/options/composeExtractors.ts index af73e8b..ddaea46 100644 --- a/src/options/composeExtractors.ts +++ b/src/options/composeExtractors.ts @@ -1,4 +1,4 @@ -import type { Extractor, ExtractorContext } from './ReporterOptions'; +import type { Extractor, ExtractorContext } from 'jest-allure2-reporter'; export function composeExtractors>( a: Extractor | undefined, diff --git a/src/options/composeOptions.ts b/src/options/composeOptions.ts index fdc0d25..74e9435 100644 --- a/src/options/composeOptions.ts +++ b/src/options/composeOptions.ts @@ -1,3 +1,4 @@ +import type { PluginContext } from 'jest-allure2-reporter'; import type { ReporterOptions, ReporterConfig, @@ -5,13 +6,17 @@ import type { TestCaseCustomizer, TestStepCustomizer, AttachmentsOptions, -} from './ReporterOptions'; +} from 'jest-allure2-reporter'; + import { aggregateLabelCustomizers } from './aggregateLabelCustomizers'; import { aggregateLinkCustomizers } from './aggregateLinkCustomizers'; import { composeExtractors } from './composeExtractors'; import { asExtractor } from './asExtractor'; +import { resolvePlugins } from './resolvePlugins'; +import { composePlugins } from './composePlugins'; export function composeOptions( + context: PluginContext, base: ReporterConfig, custom: ReporterOptions | undefined, ): ReporterConfig { @@ -39,6 +44,10 @@ export function composeOptions( asExtractor(custom.categories), base.categories, ), + plugins: composePlugins( + base.plugins, + resolvePlugins(context, custom.plugins), + ), }; } diff --git a/src/options/composePlugins.ts b/src/options/composePlugins.ts new file mode 100644 index 0000000..b30d053 --- /dev/null +++ b/src/options/composePlugins.ts @@ -0,0 +1,25 @@ +import type { Plugin } from 'jest-allure2-reporter'; + +export async function composePlugins( + basePromise: Promise, + customPromise: Promise, +): Promise { + const [base, custom] = await Promise.all([basePromise, customPromise]); + + const result: Plugin[] = []; + const indices: Record = {}; + + // eslint-disable-next-line unicorn/prefer-spread + for (const plugin of base.concat(custom)) { + const index = indices[plugin.name]; + if (index === undefined) { + indices[plugin.name] = result.push(plugin) - 1; + } else { + const previous = result[index]; + const extended = plugin?.extend?.(previous) ?? plugin; + result[index] = extended; + } + } + + return result; +} diff --git a/src/options/defaultOptions.ts b/src/options/defaultOptions.ts index 591095b..7b4117d 100644 --- a/src/options/defaultOptions.ts +++ b/src/options/defaultOptions.ts @@ -3,22 +3,26 @@ import path from 'node:path'; import type { TestCaseResult } from '@jest/reporters'; import type { Attachment, StatusDetails } from '@noomorph/allure-js-commons'; import { Stage, Status } from '@noomorph/allure-js-commons'; - -import { stripStatusDetails } from '../metadata/utils'; - +import type { PluginContext } from 'jest-allure2-reporter'; import type { ExtractorContext, ReporterConfig, ResolvedTestCaseCustomizer, ResolvedTestStepCustomizer, -} from './ReporterOptions'; +} from 'jest-allure2-reporter'; + +import * as plugins from '../builtin-plugins'; + +import { stripStatusDetails } from './stripStatusDetails'; import { aggregateLabelCustomizers } from './aggregateLabelCustomizers'; +import { resolvePlugins } from './resolvePlugins'; +import { composeExtractors } from './composeExtractors'; const identity = (context: ExtractorContext) => context.value; const last = (context: ExtractorContext) => context.value?.at(-1); const all = identity; -export function defaultOptions(): ReporterConfig { +export function defaultOptions(context: PluginContext): ReporterConfig { const testCase: ResolvedTestCaseCustomizer = { historyId: ({ testCase }) => testCase.fullName, name: ({ testCase }) => testCase.title, @@ -36,39 +40,47 @@ export function defaultOptions(): ReporterConfig { (testCaseMetadata.stop ?? Date.now()) - (testCase.duration ?? 0), stop: ({ testCaseMetadata }) => testCaseMetadata.stop ?? Date.now(), stage: ({ testCase }) => getTestCaseStage(testCase), - status: ({ testCase }) => getTestCaseStatus(testCase), - statusDetails: ({ testCase }) => getTestCaseStatusDetails(testCase), + status: ({ testCase, testCaseMetadata }) => + testCaseMetadata.status ?? getTestCaseStatus(testCase), + statusDetails: ({ testCase, testCaseMetadata }) => + stripStatusDetails( + testCaseMetadata.statusDetails ?? getTestCaseStatusDetails(testCase), + ), attachments: ({ config, testCaseMetadata }) => (testCaseMetadata.attachments ?? []).map(relativizeAttachment, config), parameters: ({ testCaseMetadata }) => testCaseMetadata.parameters ?? [], - labels: aggregateLabelCustomizers({ - package: last, - testClass: last, - testMethod: last, - parentSuite: last, - suite: ({ testCase, testFile }) => - testCase.ancestorTitles[0] ?? path.basename(testFile.testFilePath), - subSuite: ({ testCase }) => testCase.ancestorTitles.slice(1).join(' '), - epic: all, - feature: all, - story: all, - thread: ({ testCaseMetadata }) => testCaseMetadata.workerId, - severity: last, - tag: all, - owner: last, - })!, + labels: composeExtractors( + aggregateLabelCustomizers({ + package: last, + testClass: last, + testMethod: last, + parentSuite: last, + suite: ({ testCase, testFile }) => + testCase.ancestorTitles[0] ?? path.basename(testFile.testFilePath), + subSuite: ({ testCase }) => testCase.ancestorTitles.slice(1).join(' '), + epic: all, + feature: all, + story: all, + thread: ({ testCaseMetadata }) => testCaseMetadata.workerId, + severity: last, + tag: all, + owner: last, + }), + ({ testCaseMetadata }) => testCaseMetadata.labels ?? [], + ), links: ({ testCaseMetadata }) => testCaseMetadata.links ?? [], }; const testStep: ResolvedTestStepCustomizer = { - name: ({ testStep }) => testStep.name, - start: ({ testStep }) => testStep.start, - stop: ({ testStep }) => testStep.stop, - stage: ({ testStep }) => testStep.stage, - status: ({ testStep }) => testStep.status, - statusDetails: ({ testStep }) => stripStatusDetails(testStep.statusDetails), - attachments: ({ testStep }) => testStep.attachments ?? [], - parameters: ({ testStep }) => testStep.parameters ?? [], + name: ({ testStepMetadata }) => testStepMetadata.name, + start: ({ testStepMetadata }) => testStepMetadata.start, + stop: ({ testStepMetadata }) => testStepMetadata.stop, + stage: ({ testStepMetadata }) => testStepMetadata.stage, + status: ({ testStepMetadata }) => testStepMetadata.status, + statusDetails: ({ testStepMetadata }) => + stripStatusDetails(testStepMetadata.statusDetails), + attachments: ({ testStepMetadata }) => testStepMetadata.attachments ?? [], + parameters: ({ testStepMetadata }) => testStepMetadata.parameters ?? [], }; const config: ReporterConfig = { @@ -83,6 +95,12 @@ export function defaultOptions(): ReporterConfig { environment: identity, executor: identity, categories: identity, + plugins: resolvePlugins(context, [ + plugins.jsdoc, + plugins.manifest, + plugins.prettier, + plugins.remark, + ]), }; return config; @@ -90,7 +108,6 @@ export function defaultOptions(): ReporterConfig { function getTestCaseStatus(testCase: TestCaseResult): Status { const hasErrors = testCase.failureMessages?.length > 0; - // TODO: Add support for 'broken' status switch (testCase.status) { case 'passed': { return Status.PASSED; diff --git a/src/options/index.ts b/src/options/index.ts index 170ed21..4fce690 100644 --- a/src/options/index.ts +++ b/src/options/index.ts @@ -1,2 +1 @@ export * from './resolveOptions'; -export * from './ReporterOptions'; diff --git a/src/options/resolveOptions.ts b/src/options/resolveOptions.ts index fc2b80f..98dc09c 100644 --- a/src/options/resolveOptions.ts +++ b/src/options/resolveOptions.ts @@ -1,11 +1,15 @@ -import type { ReporterOptions, ReporterConfig } from './ReporterOptions'; +import type { + PluginContext, + ReporterOptions, + ReporterConfig, +} from 'jest-allure2-reporter'; + import { composeOptions } from './composeOptions'; import { defaultOptions } from './defaultOptions'; -export * from './ReporterOptions'; - export function resolveOptions( + context: PluginContext, options?: ReporterOptions | undefined, ): ReporterConfig { - return composeOptions(defaultOptions(), options); + return composeOptions(context, defaultOptions(context), options); } diff --git a/src/options/resolvePlugins.ts b/src/options/resolvePlugins.ts new file mode 100644 index 0000000..c05c65c --- /dev/null +++ b/src/options/resolvePlugins.ts @@ -0,0 +1,42 @@ +import type { + Plugin, + PluginConstructor, + PluginReference, + PluginDeclaration, + PluginContext, +} from 'jest-allure2-reporter'; + +export function resolvePlugins( + context: PluginContext, + plugins: PluginDeclaration[] | undefined, +): Promise { + if (!plugins) { + return Promise.resolve([]); + } + + const promises = plugins.map((plugin) => { + return Array.isArray(plugin) + ? resolvePlugin(context, plugin[0], plugin[1]) + : resolvePlugin(context, plugin, {}); + }); + + return Promise.all(promises); +} + +async function resolvePlugin( + context: PluginContext, + reference: PluginReference, + options: Record, +): Promise { + let createPlugin: PluginConstructor; + + if (typeof reference === 'string') { + const rootDirectory = context.globalConfig.rootDir; + const resolved = require.resolve(reference, { paths: [rootDirectory] }); + createPlugin = await import(resolved); + } else { + createPlugin = reference; + } + + return createPlugin(options, context); +} diff --git a/src/metadata/utils/stripStatusDetails.ts b/src/options/stripStatusDetails.ts similarity index 100% rename from src/metadata/utils/stripStatusDetails.ts rename to src/options/stripStatusDetails.ts diff --git a/src/realms/AllureRealm.ts b/src/realms/AllureRealm.ts index 97f468f..f400ca3 100644 --- a/src/realms/AllureRealm.ts +++ b/src/realms/AllureRealm.ts @@ -1,9 +1,9 @@ import { state } from 'jest-metadata'; +import type { SharedReporterConfig } from 'jest-allure2-reporter'; import { AllureRuntime } from '../runtime'; import { SHARED_CONFIG } from '../constants'; import { AttachmentsHandler } from '../runtime/AttachmentsHandler'; -import type { SharedReporterConfig } from '../options'; export class AllureRealm { runtime = new AllureRuntime({ diff --git a/src/reporter/JestAllure2Reporter.ts b/src/reporter/JestAllure2Reporter.ts index d067f03..99a9021 100644 --- a/src/reporter/JestAllure2Reporter.ts +++ b/src/reporter/JestAllure2Reporter.ts @@ -6,34 +6,36 @@ import type { ReporterOnStartOptions, Test, TestCaseResult, + TestContext, TestResult, } from '@jest/reporters'; import { state } from 'jest-metadata'; 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'; -import type { TestContext } from '@jest/reporters'; - import type { + AllureTestStepMetadata, GlobalExtractorContext, + Plugin, + PluginHookName, ReporterConfig, ReporterOptions, ResolvedTestStepCustomizer, SharedReporterConfig, TestCaseExtractorContext, -} from '../options'; + TestStepExtractorContext, + TestFileExtractorContext, +} from 'jest-allure2-reporter'; + import { resolveOptions } from '../options'; -import type { AllureTestStepMetadata } from '../metadata'; import { MetadataSquasher, StepExtractor } from '../metadata'; import { SHARED_CONFIG, STOP, WORKER_ID } from '../constants'; -import attempt from '../utils/attempt'; -import isError from '../utils/isError'; import { ThreadService } from '../utils/ThreadService'; import md5 from '../utils/md5'; export class JestAllure2Reporter extends JestMetadataReporter { + private _plugins: readonly Plugin[] = []; private readonly _globalConfig: Config.GlobalConfig; private readonly _config: ReporterConfig; private readonly _threadService = new ThreadService(); @@ -42,7 +44,8 @@ export class JestAllure2Reporter extends JestMetadataReporter { super(globalConfig); this._globalConfig = globalConfig; - this._config = resolveOptions(options); + const pluginContext = { globalConfig }; + this._config = resolveOptions(pluginContext, options); state.set(SHARED_CONFIG, { resultsDir: this._config.resultsDir, @@ -55,6 +58,8 @@ export class JestAllure2Reporter extends JestMetadataReporter { results: AggregatedResult, options: ReporterOnStartOptions, ): Promise { + this._plugins = await this._config.plugins; + await super.onRunStart(results, options); if (this._config.overwrite) { @@ -100,23 +105,14 @@ 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, }; + await this._callPlugins('globalContext', globalContext); + const environment = config.environment(globalContext); if (environment) { allure.writeEnvironmentInfo(environment); @@ -136,6 +132,16 @@ export class JestAllure2Reporter extends JestMetadataReporter { const stepper = new StepExtractor(); for (const testResult of results.testResults) { + const testFileContext: TestFileExtractorContext = { + ...globalContext, + filePath: path + .relative(globalContext.globalConfig.rootDir, testResult.testFilePath) + .split(path.sep), + testFile: testResult, + }; + + await this._callPlugins('testFileContext', testFileContext); + for (const testCaseResult of testResult.testResults) { const allInvocations = query.testCaseResult(testCaseResult).invocations ?? []; @@ -145,18 +151,13 @@ export class JestAllure2Reporter extends JestMetadataReporter { testInvocationMetadata, ); const testCaseContext: TestCaseExtractorContext = { - ...globalContext, - filePath: path - .relative( - globalContext.globalConfig.rootDir, - testResult.testFilePath, - ) - .split(path.sep), - testFile: testResult, + ...testFileContext, testCase: testCaseResult, testCaseMetadata, }; + await this._callPlugins('testCaseContext', testCaseContext); + const invocationIndex = allInvocations.indexOf( testInvocationMetadata, ); @@ -170,10 +171,23 @@ export class JestAllure2Reporter extends JestMetadataReporter { config.testCase.historyId(testCaseContext)!, ); allureTest.fullName = config.testCase.fullName(testCaseContext)!; - allureTest.description = config.testCase.description(testCaseContext); - allureTest.descriptionHtml = + + const description = config.testCase.description(testCaseContext); + const descriptionHtml = config.testCase.descriptionHtml(testCaseContext); + if ( + !descriptionHtml && + description && + testCaseContext.processMarkdown + ) { + const newHTML = await testCaseContext.processMarkdown(description); + allureTest.descriptionHtml = newHTML; + } else { + allureTest.description = description; + allureTest.descriptionHtml = descriptionHtml; + } + allureTest.status = config.testCase.status(testCaseContext)!; allureTest.statusDetails = config.testCase.statusDetails(testCaseContext)!; @@ -209,7 +223,7 @@ export class JestAllure2Reporter extends JestMetadataReporter { stepper.extractFromInvocation(invocationMetadata); if (testStepMetadata) { const executable = createExecutable(); - this._createStep( + await this._createStep( testCaseContext, executable, testStepMetadata, @@ -226,17 +240,19 @@ export class JestAllure2Reporter extends JestMetadataReporter { } } - _createStep( + private async _createStep( testCaseContext: TestCaseExtractorContext, executable: ExecutableItemWrapper, - testStep: AllureTestStepMetadata, + testStepMetadata: AllureTestStepMetadata, isTest: boolean, ) { const customize: ResolvedTestStepCustomizer = this._config!.testStep; const testStepContext = { ...testCaseContext, - testStep, - }; + testStepMetadata, + } as TestStepExtractorContext; + + await this._callPlugins('testStepContext', testStepContext); if (!isTest) { executable.name = customize.name(testStepContext) ?? executable.name; @@ -245,8 +261,7 @@ export class JestAllure2Reporter extends JestMetadataReporter { executable.stage = customize.stage(testStepContext) ?? executable.stage; executable.status = customize.status(testStepContext) ?? executable.status; - executable.statusDetails = - customize.statusDetails(testStepContext) ?? executable.statusDetails; + executable.statusDetails = customize.statusDetails(testStepContext) ?? {}; executable.wrappedItem.attachments = customize.attachments(testStepContext)!; @@ -254,9 +269,9 @@ export class JestAllure2Reporter extends JestMetadataReporter { customize.parameters(testStepContext)!; } - if (testStep.steps) { - for (const innerStep of testStep.steps) { - this._createStep( + if (testStepMetadata.steps) { + for (const innerStep of testStepMetadata.steps) { + await this._createStep( testCaseContext, executable.startStep('', 0), innerStep, @@ -265,4 +280,12 @@ export class JestAllure2Reporter extends JestMetadataReporter { } } } + + async _callPlugins(method: PluginHookName, context: any) { + await Promise.all( + this._plugins.map((p) => { + return p[method]?.(context); + }), + ); + } } diff --git a/src/runtime/AllureRuntime.ts b/src/runtime/AllureRuntime.ts index fdd6113..ff2df28 100644 --- a/src/runtime/AllureRuntime.ts +++ b/src/runtime/AllureRuntime.ts @@ -1,12 +1,19 @@ import path from 'node:path'; -import type { Metadata } from 'jest-metadata'; import type { LabelName, ParameterOptions, StatusDetails, } from '@noomorph/allure-js-commons'; import { Stage, Status } from '@noomorph/allure-js-commons'; +import type { Metadata } from 'jest-metadata'; +import type { + AllureTestStepMetadata, + AttachmentContent, + AttachmentOptions, + IAllureRuntime, + ParameterOrString, +} from 'jest-allure2-reporter'; import { CURRENT_STEP, @@ -16,25 +23,15 @@ import { LINKS, PREFIX, } from '../constants'; -import type { AllureTestStepMetadata } from '../metadata'; import { isPromiseLike } from '../utils/isPromiseLike'; import { inferMimeType } from '../utils/inferMimeType'; import { hijackFunction } from '../utils/hijackFunction'; -import type { - AttachmentContent, - Function_, - MaybePromise, -} from '../utils/types'; +import type { Function_, MaybePromise } from '../utils/types'; import { processMaybePromise } from '../utils/processMaybePromise'; import { wrapFunction } from '../utils/wrapFunction'; import { formatString } from '../utils/formatString'; import type { IAttachmentsHandler } from './AttachmentsHandler'; -import type { - AttachmentOptions, - IAllureRuntime, - ParameterOrString, -} from './IAllureRuntime'; export type AllureRuntimeConfig = { attachmentsHandler: IAttachmentsHandler; @@ -96,6 +93,14 @@ export class AllureRuntime implements IAllureRuntime { } } + status(status?: Status | StatusDetails, statusDetails?: StatusDetails) { + this.#metadata.assign(this.#localPath(), { status, statusDetails }); + } + + statusDetails(statusDetails: StatusDetails) { + this.#metadata.assign(this.#localPath(), { statusDetails }); + } + attachment( name: string, content: MaybePromise, @@ -223,10 +228,12 @@ export class AllureRuntime implements IAllureRuntime { }; #stopStep = (status: Status, statusDetails?: StatusDetails) => { + const existing = this.#metadata.get(this.#localPath(), {} as any); + this.#metadata.assign(this.#localPath(), { stage: Stage.FINISHED, - status, - statusDetails, + status: existing.status ?? status, + statusDetails: existing.statusDetails ?? statusDetails, stop: this.#now(), }); diff --git a/src/runtime/AttachmentsHandler.ts b/src/runtime/AttachmentsHandler.ts index 2d2d4fd..97a914d 100644 --- a/src/runtime/AttachmentsHandler.ts +++ b/src/runtime/AttachmentsHandler.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; -import type { SharedReporterConfig } from '../options'; +import type { SharedReporterConfig } from 'jest-allure2-reporter'; export interface IAttachmentsHandler { placeAttachment(name: string, content?: Buffer | string): string; diff --git a/src/runtime/IAllureRuntime.ts b/src/runtime/IAllureRuntime.ts deleted file mode 100644 index 778a15b..0000000 --- a/src/runtime/IAllureRuntime.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { LabelName, ParameterOptions } from '@noomorph/allure-js-commons'; -import type { Parameter } from '@noomorph/allure-js-commons'; - -import type { - AttachmentContent, - Function_, - MaybePromise, -} from '../utils/types'; - -export interface IAllureRuntime { - flush(): Promise; - - description(value: string): void; - - descriptionHtml(value: string): void; - - label(name: LabelName | string, value: string): void; - - link(name: string, url: string, type?: string): void; - - parameter(name: string, value: unknown, options?: ParameterOptions): void; - - parameters(parameters: Record): void; - - attachment( - name: string, - content: MaybePromise, - mimeType?: string, - ): typeof content; - - createAttachment( - function_: Function_>, - name: string, - ): typeof function_; - createAttachment( - function_: Function_>, - options: AttachmentOptions, - ): typeof function_; - - fileAttachment(filePath: string, name?: string): string; - fileAttachment(filePath: string, options?: AttachmentOptions): string; - fileAttachment( - filePathPromise: Promise, - name?: string, - ): Promise; - fileAttachment( - filePathPromise: Promise, - options?: AttachmentOptions, - ): Promise; - - createFileAttachment( - function_: Function_>, - ): typeof function_; - createFileAttachment( - function_: Function_>, - name: string, - ): typeof function_; - createFileAttachment( - function_: Function_>, - options: AttachmentOptions, - ): typeof function_; - - createStep(name: string, function_: F): F; - createStep( - name: string, - arguments_: ParameterOrString[], - function_: F, - ): F; - - step(name: string, function_: () => T): T; -} - -export type ParameterOrString = string | Omit; - -export type AttachmentOptions = { - name?: string; - mimeType?: string; -}; diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 30e6fbb..61480b4 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,2 +1 @@ export * from './AllureRuntime'; -export * from './IAllureRuntime'; diff --git a/src/utils/splitDocblock.test.ts b/src/utils/splitDocblock.test.ts new file mode 100644 index 0000000..9fae2dc --- /dev/null +++ b/src/utils/splitDocblock.test.ts @@ -0,0 +1,33 @@ +/* eslint-disable unicorn/prevent-abbreviations */ + +import { splitDocblock } from './splitDocblock'; + +describe('splitDocblock', () => { + it('should split a docblock from code', function testFunction() { + /** + * @severity blocker + * @issue 123 + */ + + const [docblock, code] = splitDocblock(testFunction.toString()); + const docblockLines = docblock + .split('\n') + .map((s) => s.trimStart()) + .filter(Boolean); + + expect(docblockLines).toEqual([ + '/**', + '* @severity blocker', + '* @issue 123', + '*/', + ]); + + expect(code.includes(docblock)).toBe(false); + }); + + it('should return an empty docblock if code has no such', function testFunction() { + const [docblock, code] = splitDocblock(testFunction.toString()); + expect(docblock).toBe(''); + expect(code).toBe(testFunction.toString()); + }); +}); diff --git a/src/utils/splitDocblock.ts b/src/utils/splitDocblock.ts new file mode 100644 index 0000000..5a9cef6 --- /dev/null +++ b/src/utils/splitDocblock.ts @@ -0,0 +1,13 @@ +/* eslint-disable unicorn/prevent-abbreviations */ + +const DOCBLOCK_REGEXP = /\s*\/\*\*[\S\s]*?\*\//m; + +export function splitDocblock(rawCode: string): [string, string] { + let docblock = ''; + const code = rawCode.replace(DOCBLOCK_REGEXP, (match) => { + docblock = match.trim(); + return ''; + }); + + return [docblock, code]; +} diff --git a/src/utils/types.ts b/src/utils/types.ts index f326184..577afbd 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,5 +1,3 @@ export type MaybePromise = T | Promise; export type Function_ = (...arguments_: any[]) => T; - -export type AttachmentContent = Buffer | string;