diff --git a/api.mjs b/api.mjs index e2688af..849266e 100644 --- a/api.mjs +++ b/api.mjs @@ -1 +1 @@ -export * from './dist/api.js'; +export * from './dist/api/index.js'; diff --git a/e2e/configs/default.js b/e2e/configs/default.js index cdc5d89..5a4cbe9 100644 --- a/e2e/configs/default.js +++ b/e2e/configs/default.js @@ -40,6 +40,9 @@ const jestAllure2ReporterOptions = { testMethod: ({ testCase }) => testCase.fullName, owner: ({ value }) => value ?? 'Unknown', }, + links: { + issue: ({ value }) => ({ ...value, url: `https://youtrack.jetbrains.com/issue/${value.url}/` }), + }, }, }; diff --git a/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts b/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts index c66a4d8..7485de4 100644 --- a/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts +++ b/e2e/src/programmatic/grouping/client/auth/LoginScreen.test.ts @@ -1,5 +1,3 @@ -import LoginHelper from '../../../../utils/LoginHelper'; - /** * Client tests for login screen * @owner Security Team @@ -8,6 +6,9 @@ import LoginHelper from '../../../../utils/LoginHelper'; * @tag smoke */ +import LoginHelper from '../../../../utils/LoginHelper'; +import {allure} from "../../../../../../src/api"; + $Tag('client'); $Epic('Authentication'); $Feature('Login'); @@ -22,12 +23,18 @@ describe('Login screen', () => { $Story('Validation'); describe('Form Submission', () => { it('should show error on invalid e-mail format', async () => { - /** @owner Samantha Jones */ + /** + * @owner Samantha Jones + * @issue IDEA-235211 + */ await LoginHelper.typeEmail('someone#example.com'); await LoginHelper.typePassword('123456'); - expect(LoginHelper.snapshotForm()).toContain('someone#example.com'); - expect(LoginHelper.getValidationSummary()).toBe('fixtures/invalid-email.xml'); + await allure.step('Hello', () => { + expect(LoginHelper.snapshotForm()).toContain('someone#example.com'); + expect(LoginHelper.getValidationSummary()).toBe('fixtures/invalid-email.xml'); + allure.status('passed', { message: 'All is good' }); + }); }); it('should show error on short or invalid password format', () => { diff --git a/e2e/src/programmatic/grouping/server/controllers/login.test.ts b/e2e/src/programmatic/grouping/server/controllers/login.test.ts index 544b289..481ea9e 100644 --- a/e2e/src/programmatic/grouping/server/controllers/login.test.ts +++ b/e2e/src/programmatic/grouping/server/controllers/login.test.ts @@ -1,19 +1,25 @@ -$Tag('server'); -$Epic('Authentication'); -$Feature('Login'); +/** + * Server login controller tests. + * ------------------------------ + * @tag server + * @epic Authentication + * @feature Login + */ describe('POST /login', () => { - $Story('Validation'); + beforeEach(() => { + /** Environment setup */ + // This hook should set up the environment for each test case. + }); + it('should return 401 if user is not found', () => { - // ... + /** @story Validation */ }); - $Story('Validation'); it('should return 401 if password is incorrect', () => { - // ... + /** @story Validation */ }); - $Story('Happy path'); it('should return 200 and user details if login is successful', () => { - // ... + /** @story Happy path */ }); }); diff --git a/e2e/src/utils/LoginHelper.ts b/e2e/src/utils/LoginHelper.ts index 0c35352..0c1cdd8 100644 --- a/e2e/src/utils/LoginHelper.ts +++ b/e2e/src/utils/LoginHelper.ts @@ -1,3 +1,5 @@ +import { Step, Attachment, FileAttachment } from 'jest-allure2-reporter/api'; + class LoginHelper { #email?: string; #password?: string; diff --git a/index.d.ts b/index.d.ts index 926acc4..730b318 100644 --- a/index.d.ts +++ b/index.d.ts @@ -117,23 +117,35 @@ declare module 'jest-allure2-reporter' { */ subDir?: string; /** - * Specifies strategy for attaching files to the report by their path. + * Specifies default 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; + fileHandler?: BuiltinFileAttachmentHandler | string; + /** + * Specifies default strategy for attaching dynamic content to the report. + * Uses simple file writing by default. + */ + contentHandler?: BuiltinContentAttachmentHandler | string; }; /** @see {@link AttachmentsOptions#fileHandler} */ - export type BuiltinFileHandler = 'copy' | 'move' | 'ref'; + export type BuiltinFileAttachmentHandler = 'copy' | 'move' | 'ref'; + + /** @see {@link AttachmentsOptions#contentHandler} */ + export type BuiltinContentAttachmentHandler = 'write'; /** * Global customizations for how test cases are reported */ export interface TestCaseCustomizer { + /** + * Extractor to omit test file cases from the report. + */ + hidden: TestCaseExtractor; /** * Test case ID extractor to fine-tune Allure's history feature. * @example ({ package, file, test }) => `${package.name}:${file.path}:${test.fullName}` @@ -228,9 +240,9 @@ declare module 'jest-allure2-reporter' { */ export interface TestFileCustomizer { /** - * Extractor to omit test cases from the report. + * Extractor to omit test file cases from the report. */ - ignored: TestFileExtractor; + hidden: TestFileExtractor; /** * Test file ID extractor to fine-tune Allure's history feature. * @default ({ filePath }) => filePath.join('/') @@ -331,6 +343,10 @@ declare module 'jest-allure2-reporter' { export type ResolvedTestStepCustomizer = Required; export interface TestStepCustomizer { + /** + * Extractor to omit test steps from the report. + */ + hidden: TestStepExtractor; /** * Extractor for the step name. * @example ({ value }) => value.replace(/(before|after)(Each|All)/, (_, p1, p2) => p1 + ' ' + p2.toLowerCase()) @@ -410,96 +426,145 @@ declare module 'jest-allure2-reporter' { value: T | undefined; } - export interface GlobalExtractorContext + export interface GlobalExtractorContext extends ExtractorContext, GlobalExtractorContextAugmentation { globalConfig: Config.GlobalConfig; config: ReporterConfig; } - export interface TestFileExtractorContext + export interface TestFileExtractorContext extends GlobalExtractorContext, TestFileExtractorContextAugmentation { filePath: string[]; testFile: TestResult; - testFileMetadata: AllureTestCaseMetadata; + testFileDocblock?: DocblockContext; + testFileMetadata: AllureTestFileMetadata; } - export interface TestCaseExtractorContext + export interface TestCaseExtractorContext extends TestFileExtractorContext, TestCaseExtractorContextAugmentation { testCase: TestCaseResult; + testCaseDocblock?: DocblockContext; testCaseMetadata: AllureTestCaseMetadata; } - export interface TestStepExtractorContext + export interface TestStepExtractorContext extends TestCaseExtractorContext, TestStepExtractorContextAugmentation { + testStepDocblock?: DocblockContext; testStepMetadata: AllureTestStepMetadata; } - export interface AllureTestStepMetadata { - steps?: AllureTestStepMetadata[]; - hidden?: boolean; + export interface AllureTestItemSourceLocation { + fileName?: string; + lineNumber?: number; + columnNumber?: number; + } + + export type AllureTestStepPath = string[]; + + export interface AllureTestItemMetadata { /** - * Source code of the test case, test step or a hook. + * File attachments to be added to the test case, test step or a test file. */ - code?: string[]; - - name?: string; - status?: Status; - statusDetails?: StatusDetails; - stage?: Stage; attachments?: Attachment[]; + /** + * Property path to the current step metadata object. + * @see {steps} + * @example ['steps', '0'] + */ + currentStep?: AllureTestStepPath; + /** + * Source code of the test case, test step or a hook. + */ + sourceCode?: string; + /** + * Location (file, line, column) of the test case, test step or a hook. + */ + sourceLocation?: AllureTestItemSourceLocation; + /** + * Markdown description of the test case or test file, or plain text description of a test step. + */ + description?: string[]; + /** + * Key-value pairs to disambiguate test cases or to provide additional information. + */ parameters?: Parameter[]; + /** + * Indicates test item execution progress. + */ + stage?: Stage; + /** + * Start timestamp in milliseconds. + */ 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 + * Test result: failed, broken, passed, skipped or unknown. */ - currentStep?: string[]; + status?: Status; /** - * Jest worker ID. - * @internal Used to generate unique thread names. - * @see {import('@noomorph/allure-js-commons').LabelName.THREAD} + * Extra information about the test result: message and stack trace. */ - workerId?: string; + statusDetails?: StatusDetails; /** - * Only steps can have names. + * Recursive data structure to represent test steps for more granular reporting. */ - name?: never; - description?: string[]; + steps?: Omit[]; + /** + * Stop timestamp in milliseconds. + */ + stop?: number; + } + + export interface AllureTestStepMetadata extends AllureTestItemMetadata { + /** + * Steps produced by Jest hooks will have this property set. + * User-defined steps don't have this property. + */ + hookType?: 'beforeAll' | 'beforeEach' | 'afterEach' | 'afterAll'; + } + + export interface AllureTestCaseMetadata extends AllureTestItemMetadata { descriptionHtml?: string[]; labels?: Label[]; links?: Link[]; } export interface AllureTestFileMetadata extends AllureTestCaseMetadata { - currentStep?: never; + code?: never; + steps?: never; + workerId?: string; + } + + export interface AllureGlobalMetadata { + config: Pick; + } + + export interface DocblockContext { + comments: string; + pragmas: Record; + raw: string; } export interface GlobalExtractorContextAugmentation { - detectLanguage?(filePath: string, contents: string): string | undefined; + detectLanguage?(contents: string, filePath?: string): string | undefined; processMarkdown?(markdown: string): Promise; - // This should be extended by plugins + // This may be extended by plugins } export interface TestFileExtractorContextAugmentation { - // This should be extended by plugins + // This may be extended by plugins } export interface TestCaseExtractorContextAugmentation { - // This should be extended by plugins + // This may be extended by plugins } export interface TestStepExtractorContextAugmentation { - // This should be extended by plugins + // This may be extended by plugins } export type PluginDeclaration = @@ -527,11 +592,6 @@ declare module 'jest-allure2-reporter' { /** Method to extend global context. */ globalContext?(context: GlobalExtractorContext): void | Promise; - /** Method to affect test file metadata before it is created. */ - beforeTestFileContext?( - context: Omit, - ): void | Promise; - /** Method to extend test file context. */ testFileContext?(context: TestFileExtractorContext): void | Promise; @@ -544,7 +604,6 @@ declare module 'jest-allure2-reporter' { export type PluginHookName = | 'globalContext' - | 'beforeTestFileContext' | 'testFileContext' | 'testCaseContext' | 'testStepContext'; diff --git a/package-e2e/api.test.ts b/package-e2e/api.test.ts index feebaae..811d09e 100644 --- a/package-e2e/api.test.ts +++ b/package-e2e/api.test.ts @@ -1,5 +1,4 @@ import { - IAllureRuntime, Attachment, FileAttachment, Step, @@ -16,10 +15,24 @@ import { $TmsLink, allure, } from 'jest-allure2-reporter/api'; -import {Status} from "@noomorph/allure-js-commons"; -import {take} from "lodash"; -declare var test: (name: string, fn: () => unknown) => void; +import type { + AllureRuntimePluginCallback, + AllureRuntimePluginContext, + AttachmentContent, + ContentAttachmentContext, + ContentAttachmentHandler, + ContentAttachmentOptions, + FileAttachmentContext, + FileAttachmentHandler, + FileAttachmentOptions, + IAllureRuntime, + MIMEInferer, + MIMEInfererContext, + ParameterOrString, +} from 'jest-allure2-reporter/api'; + +enablePlugins(); $Description('This is _a test description_'); $DescriptionHtml('This is a test description'); @@ -69,20 +82,44 @@ test('typings of jest-allure2-reporter/api', async () => { await allure.attachment('file-async.txt', Promise.resolve('Example log content')); + const contentAttachmentOptions: ContentAttachmentOptions = { + name: '%s.png', + mimeType: assertOptional('image/png'), + handler: assertOptional( + assertOneOf( + 'copy', + async (context) => { + assertType(context); + return '/path/to/file'; + } + ) + ), + }; + const takeScreenshotA1 = allure.createAttachment((s: string) => Buffer.from(s), 'screenshot.png'); - const takeScreenshotA2 = allure.createAttachment(async (s: string) => Buffer.from(s), { - name: 'Screenshot', - mimeType: 'image/png', - }); + const takeScreenshotA2 = allure.createAttachment(async (_name: string) => 'content', contentAttachmentOptions); + + const fileAttachmentOptions: FileAttachmentOptions = { + name: assertOptional('file.png'), + mimeType: assertOptional('image/png'), + handler: assertOptional( + assertOneOf( + 'copy', + async (context) => { + assertType(context); + return '/path/to/file'; + } + ) + ), + }; + const takeScreenshotB1 = allure.createFileAttachment((file: string) => `./${file}.png`); const takeScreenshotB2 = allure.createFileAttachment(async (file: string) => `./${file}.png`, 'Screenshot'); - const takeScreenshotB3 = allure.createFileAttachment(async (file: string, ext: string) => `./${file}${ext}`, { - name: 'Screenshot', - mimeType: 'image/png', - }); + const takeScreenshotB3 = allure.createFileAttachment(async (file: string, ext: string) => `./${file}${ext}`, fileAttachmentOptions); assertType(takeScreenshotA1('1')); - assertType>(takeScreenshotA2('2')); + assertType>(takeScreenshotA2('2')); + assertType(takeScreenshotB1('file1')); assertType>(takeScreenshotB2('file2')); assertType>(takeScreenshotB3('file3', '.png')); @@ -95,8 +132,8 @@ test('typings of jest-allure2-reporter/api', async () => { }); const login2 = allure.createStep('Login', [ - 'username', - { name: 'password', mode: 'masked'}, + assertType('username'), + assertType({ name: 'password', mode: 'masked'}), ], async (username: string, password: string) => { console.log('Login executed via %s and %s', username, password); }); @@ -138,4 +175,39 @@ test('typings of jest-allure2-reporter/api', async () => { assertType>(helper.login('admin', 'qwerty')); }); -function assertType(_value: T): void {} +function enablePlugins() { + const inferMimeType: MIMEInferer = (context: MIMEInfererContext) => 'application/octet-stream'; + const customContent: ContentAttachmentHandler = async ({ content, mimeType, name, outDir }: ContentAttachmentContext) => { + assertType(name); + assertType(mimeType); + assertType(outDir); + assertType(content); + return '/path/to/file'; + }; + + const customFile: FileAttachmentHandler = async ({ mimeType, name, sourcePath, outDir }: FileAttachmentContext) => { + assertType(name); + assertType(mimeType); + assertType(outDir); + assertType(sourcePath); + + return '/path/to/file'; + }; + + assertType('content'); + assertType(Buffer.from('content')); + + const plugin: AllureRuntimePluginCallback = (context: AllureRuntimePluginContext) => { + assertType(context.runtime); + context.contentAttachmentHandlers['custom'] = customContent; + context.fileAttachmentHandlers['custom'] = customFile; + context.inferMimeType = inferMimeType; + }; + + allure.$plug(plugin); +} + +declare function test(name: string, fn: () => unknown): void; +declare function assertType(value: T): T; +declare function assertOptional(value: T | undefined): T | undefined; +declare function assertOneOf(a: A, b: B): A | B; diff --git a/package-e2e/test.mjs b/package-e2e/test.mjs index 4147d4c..45aeb14 100644 --- a/package-e2e/test.mjs +++ b/package-e2e/test.mjs @@ -1,4 +1,4 @@ -import assert from 'assert'; +import assert from 'node:assert'; import JestAllure2Reporter from 'jest-allure2-reporter'; import environmentListener from 'jest-allure2-reporter/environment-listener'; import JsdomTestEnvironment from 'jest-allure2-reporter/environment-jsdom'; diff --git a/package.json b/package.json index 36e94ee..12cfc73 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "./api": { "import": "./api.mjs", "require": "./api.js", - "types": "./dist/api.d.ts" + "types": "./dist/api/index.d.ts" }, "./globals": { "import": "./globals.mjs", @@ -94,7 +94,7 @@ "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-jsdoc": "^48.0.2", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prettier": "^5.0.1", @@ -127,6 +127,7 @@ "remark": "^15.0.1", "remark-rehype": "^11.0.0", "rimraf": "^4.3.1", + "stacktrace-js": "^2.0.2", "strip-ansi": "^6.0.0" }, "peerDependencies": { diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index e243f4a..0000000 --- a/src/api.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { IAllureRuntime } from './runtime'; -import realm from './realms'; - -export * from './annotations'; -export * from './decorators'; - -export const allure = realm.runtime as IAllureRuntime; - -export type { - AttachmentContent, - AttachmentOptions, - IAllureRuntime, - ParameterOrString, -} from './runtime'; diff --git a/src/annotations/$Description.ts b/src/api/annotations/$Description.ts similarity index 69% rename from src/annotations/$Description.ts rename to src/api/annotations/$Description.ts index eafae45..b405ca7 100644 --- a/src/annotations/$Description.ts +++ b/src/api/annotations/$Description.ts @@ -1,6 +1,6 @@ import { $Push } from 'jest-metadata'; -import { DESCRIPTION } from '../constants'; +import { DESCRIPTION } from '../../metadata/constants'; export const $Description = (description: string) => $Push(DESCRIPTION, description); diff --git a/src/annotations/$DescriptionHtml.ts b/src/api/annotations/$DescriptionHtml.ts similarity index 70% rename from src/annotations/$DescriptionHtml.ts rename to src/api/annotations/$DescriptionHtml.ts index 6811f89..c488bfb 100644 --- a/src/annotations/$DescriptionHtml.ts +++ b/src/api/annotations/$DescriptionHtml.ts @@ -1,6 +1,6 @@ import { $Push } from 'jest-metadata'; -import { DESCRIPTION_HTML } from '../constants'; +import { DESCRIPTION_HTML } from '../../metadata/constants'; export const $DescriptionHtml = (descriptionHtml: string) => $Push(DESCRIPTION_HTML, descriptionHtml); diff --git a/src/annotations/$Epic.ts b/src/api/annotations/$Epic.ts similarity index 100% rename from src/annotations/$Epic.ts rename to src/api/annotations/$Epic.ts diff --git a/src/annotations/$Feature.ts b/src/api/annotations/$Feature.ts similarity index 100% rename from src/annotations/$Feature.ts rename to src/api/annotations/$Feature.ts diff --git a/src/annotations/$Issue.ts b/src/api/annotations/$Issue.ts similarity index 100% rename from src/annotations/$Issue.ts rename to src/api/annotations/$Issue.ts diff --git a/src/annotations/$Label.ts b/src/api/annotations/$Label.ts similarity index 82% rename from src/annotations/$Label.ts rename to src/api/annotations/$Label.ts index a6a27a8..8048f01 100644 --- a/src/annotations/$Label.ts +++ b/src/api/annotations/$Label.ts @@ -1,7 +1,7 @@ import { $Push } from 'jest-metadata'; import type { LabelName } from 'jest-allure2-reporter'; -import { LABELS } from '../constants'; +import { LABELS } from '../../metadata/constants'; export const $Label = (name: LabelName, ...values: unknown[]) => $Push(LABELS, ...values.map((value) => ({ name, value: `${value}` }))); diff --git a/src/annotations/$Link.ts b/src/api/annotations/$Link.ts similarity index 86% rename from src/annotations/$Link.ts rename to src/api/annotations/$Link.ts index 75394b6..fa74b24 100644 --- a/src/annotations/$Link.ts +++ b/src/api/annotations/$Link.ts @@ -1,7 +1,7 @@ import { $Push } from 'jest-metadata'; import type { Link } from 'jest-allure2-reporter'; -import { LINKS } from '../constants'; +import { LINKS } from '../../metadata/constants'; export const $Link = (maybeUrl: string | Link, maybeName?: string) => { const link = diff --git a/src/annotations/$Owner.ts b/src/api/annotations/$Owner.ts similarity index 100% rename from src/annotations/$Owner.ts rename to src/api/annotations/$Owner.ts diff --git a/src/annotations/$Severity.ts b/src/api/annotations/$Severity.ts similarity index 100% rename from src/annotations/$Severity.ts rename to src/api/annotations/$Severity.ts diff --git a/src/annotations/$Story.ts b/src/api/annotations/$Story.ts similarity index 100% rename from src/annotations/$Story.ts rename to src/api/annotations/$Story.ts diff --git a/src/annotations/$Tag.ts b/src/api/annotations/$Tag.ts similarity index 100% rename from src/annotations/$Tag.ts rename to src/api/annotations/$Tag.ts diff --git a/src/annotations/$TmsLink.ts b/src/api/annotations/$TmsLink.ts similarity index 100% rename from src/annotations/$TmsLink.ts rename to src/api/annotations/$TmsLink.ts diff --git a/src/annotations/index.ts b/src/api/annotations/index.ts similarity index 100% rename from src/annotations/index.ts rename to src/api/annotations/index.ts diff --git a/src/decorators/Attachment.ts b/src/api/decorators/Attachment.ts similarity index 91% rename from src/decorators/Attachment.ts rename to src/api/decorators/Attachment.ts index e65e1f3..ebed1e6 100644 --- a/src/decorators/Attachment.ts +++ b/src/api/decorators/Attachment.ts @@ -1,4 +1,4 @@ -import realm from '../realms'; +import realm from '../../realms'; const allure = realm.runtime; diff --git a/src/decorators/FileAttachment.ts b/src/api/decorators/FileAttachment.ts similarity index 91% rename from src/decorators/FileAttachment.ts rename to src/api/decorators/FileAttachment.ts index 517c9de..d0980c1 100644 --- a/src/decorators/FileAttachment.ts +++ b/src/api/decorators/FileAttachment.ts @@ -1,4 +1,4 @@ -import realm from '../realms'; +import realm from '../../realms'; const allure = realm.runtime; diff --git a/src/decorators/Step.ts b/src/api/decorators/Step.ts similarity index 82% rename from src/decorators/Step.ts rename to src/api/decorators/Step.ts index e9ae02e..1812a7d 100644 --- a/src/decorators/Step.ts +++ b/src/api/decorators/Step.ts @@ -1,5 +1,5 @@ -import type { ParameterOrString } from '../runtime'; -import realm from '../realms'; +import type { ParameterOrString } from '../../runtime'; +import realm from '../../realms'; const allure = realm.runtime; diff --git a/src/decorators/index.ts b/src/api/decorators/index.ts similarity index 100% rename from src/decorators/index.ts rename to src/api/decorators/index.ts diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..ec5bf0d --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,25 @@ +import realm from '../realms'; +import type { IAllureRuntime } from '../runtime'; + +export * from './annotations'; +export * from './decorators'; + +export const allure = realm.runtime as IAllureRuntime; + +export type { + AllureRuntimePluginCallback, + AllureRuntimePluginContext, + AttachmentContent, + AttachmentHandler, + AttachmentOptions, + ContentAttachmentContext, + ContentAttachmentHandler, + ContentAttachmentOptions, + FileAttachmentContext, + FileAttachmentHandler, + FileAttachmentOptions, + IAllureRuntime, + MIMEInferer, + MIMEInfererContext, + ParameterOrString, +} from '../runtime'; diff --git a/src/builtin-plugins/docblock.ts b/src/builtin-plugins/docblock.ts index e51955b..0a6652b 100644 --- a/src/builtin-plugins/docblock.ts +++ b/src/builtin-plugins/docblock.ts @@ -1,65 +1,177 @@ /* eslint-disable @typescript-eslint/consistent-type-imports,import/no-extraneous-dependencies */ import fs from 'node:fs/promises'; -import type { Plugin, PluginConstructor } from 'jest-allure2-reporter'; -import { state } from 'jest-metadata'; -import type { Metadata } from 'jest-metadata'; -import type { Label } from 'jest-allure2-reporter'; +import type { + AllureTestItemMetadata, + AllureTestCaseMetadata, + AllureTestFileMetadata, + AllureTestStepMetadata, + DocblockContext, + Label, + LabelName, + Link, + LinkType, + Plugin, + PluginConstructor, +} from 'jest-allure2-reporter'; -import { CODE, DESCRIPTION, LABELS } from '../constants'; -import { splitDocblock } from '../utils/splitDocblock'; +import { splitDocblock } from '../utils'; -type ParseWithComments = typeof import('jest-docblock').parseWithComments; +export const docblockPlugin: PluginConstructor = () => { + let parse: DocblockParser | undefined; + + const plugin: Plugin = { + name: 'jest-allure2-reporter/plugins/docblock', + async globalContext() { + parse = await initParser(); + }, + async testFileContext(context) { + const testFilePath = context.testFile.testFilePath; + const fileContents = await fs.readFile(testFilePath, 'utf8'); + context.testFileDocblock = parse?.(fileContents); + mergeIntoTestFile(context.testFileMetadata, context.testFileDocblock); + }, + async testCaseContext(context) { + if (parse && context.testCaseMetadata.sourceCode) { + context.testCaseDocblock = parse(context.testCaseMetadata.sourceCode); + mergeIntoTestCase(context.testCaseMetadata, context.testFileDocblock); + mergeIntoTestCase(context.testCaseMetadata, context.testCaseDocblock); + } + }, + async testStepContext(context) { + if (parse && context.testStepMetadata.sourceCode) { + context.testStepDocblock = parse(context.testStepMetadata.sourceCode); + mergeIntoTestStep(context.testStepMetadata, context.testStepDocblock); + } + }, + }; + + return plugin; +}; -function mergeDocumentBlocks( - parseWithComments: ParseWithComments, - metadata: Metadata, - codeDefault = '', +function mergeIntoTestItem( + metadata: AllureTestItemMetadata, + comments: string, + pragmas: Record, + rawDocblock: string, + shouldLeaveComments: boolean, ) { - const rawCode = metadata.get(CODE, codeDefault); - const [jsdoc, code] = splitDocblock(rawCode); - if (jsdoc) { - metadata.set(CODE, code); - - const { comments, pragmas } = parseWithComments(jsdoc); - if (comments) { - metadata.unshift(DESCRIPTION, [comments]); - } + if (comments) { + metadata.description ??= []; + metadata.description.push(comments); + } - if (pragmas) { - const labels = Object.entries(pragmas).flatMap(createLabel); - metadata.unshift(LABELS, labels); - } + if (pragmas.description) { + metadata.description ??= []; + metadata.description.push(...pragmas.description); + } + + if (metadata.sourceCode && rawDocblock) { + const [left, right, ...rest] = metadata.sourceCode.split(rawDocblock); + const leftTrimmed = left.trimEnd(); + const replacement = shouldLeaveComments + ? `/* ${comments.trimStart()} */\n` + : '\n'; + const joined = right ? [leftTrimmed, right].join(replacement) : leftTrimmed; + metadata.sourceCode = [joined, ...rest].join('\n'); } } -function createLabel(entry: [string, string | string[]]): Label | Label[] { - const [name, value] = entry; - return Array.isArray(value) - ? value.map((v) => ({ name, value: v })) - : { name, value }; +function mergeIntoTestFile( + metadata: AllureTestFileMetadata, + docblock: DocblockContext | undefined, +) { + return mergeIntoTestCase(metadata, docblock); } -export const docblockPlugin: PluginConstructor = () => { - const plugin: Plugin = { - name: 'jest-allure2-reporter/plugins/docblock', - async beforeTestFileContext({ testFile: { testFilePath } }) { - try { - const { parseWithComments } = await import('jest-docblock'); - const testFileMetadata = state.getTestFileMetadata(testFilePath); - const fileContents = await fs.readFile(testFilePath, 'utf8'); - - mergeDocumentBlocks(parseWithComments, testFileMetadata, fileContents); - for (const testEntryMetadata of testFileMetadata.allTestEntries()) { - mergeDocumentBlocks(parseWithComments, testEntryMetadata); - } - } catch (error: any) { - if (error?.code !== 'MODULE_NOT_FOUND') { - throw error; - } - } - }, - }; +function mergeIntoTestCase( + metadata: AllureTestCaseMetadata, + docblock: DocblockContext | undefined, +) { + const { raw = '', comments = '', pragmas = {} } = docblock ?? {}; + mergeIntoTestItem(metadata, comments, pragmas, raw, false); - return plugin; + const epic = pragmas.epic?.map(createLabelMapper('epic')) ?? []; + const feature = pragmas.feature?.map(createLabelMapper('feature')) ?? []; + const owner = pragmas.owner?.map(createLabelMapper('owner')) ?? []; + const severity = pragmas.severity?.map(createLabelMapper('severity')) ?? []; + const story = pragmas.story?.map(createLabelMapper('story')) ?? []; + const tag = pragmas.tag?.map(createLabelMapper('tag')) ?? []; + const labels = [...epic, ...feature, ...owner, ...severity, ...story, ...tag]; + if (labels.length > 0) { + metadata.labels ??= []; + metadata.labels.push(...labels); + } + + const issue = pragmas.issue?.map(createLinkMapper('issue')) ?? []; + const tms = pragmas.tms?.map(createLinkMapper('tms')) ?? []; + const links = [...issue, ...tms]; + if (links.length > 0) { + metadata.links ??= []; + metadata.links.push(...links); + } + + if (pragmas.descriptionHtml) { + metadata.descriptionHtml ??= []; + metadata.descriptionHtml.push(...pragmas.descriptionHtml); + } +} + +function createLabelMapper(name: LabelName) { + return (value: string): Label => ({ name, value }); +} + +function createLinkMapper(type?: LinkType) { + return (url: string): Link => ({ type, url, name: url }); +} + +function mergeIntoTestStep( + metadata: AllureTestStepMetadata, + docblock: DocblockContext | undefined, +) { + const { raw = '', comments = '', pragmas = {} } = docblock ?? {}; + mergeIntoTestItem(metadata, comments, pragmas, raw, true); +} + +type DocblockParser = (raw: string) => DocblockContext | undefined; + +async function initParser(): Promise { + try { + const jestDocblock = await import('jest-docblock'); + return (snippet) => { + const [jsdoc] = splitDocblock(snippet); + const result = jestDocblock.parseWithComments(jsdoc); + return { + raw: jsdoc, + comments: result.comments, + pragmas: normalize(result.pragmas), + }; + }; + } catch (error: any) { + if (error?.code === 'MODULE_NOT_FOUND') { + return () => void 0; + } + + throw error; + } +} + +const SPLITTERS: Record string[]> = { + tag: (string_) => string_.split(/\s*,\s*/), }; + +function normalize( + pragmas: Record, +): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(pragmas)) { + result[key] = Array.isArray(value) ? value : [value]; + const splitter = SPLITTERS[key]; + if (splitter) { + result[key] = result[key].flatMap(splitter); + } + } + + return result; +} diff --git a/src/builtin-plugins/prettier.ts b/src/builtin-plugins/prettier.ts index 7829e65..60e5326 100644 --- a/src/builtin-plugins/prettier.ts +++ b/src/builtin-plugins/prettier.ts @@ -11,27 +11,6 @@ export const prettierPlugin: PluginConstructor = ( 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() { @@ -42,9 +21,22 @@ export const prettierPlugin: PluginConstructor = ( }; }, async testCaseContext(context) { - context.testCaseMetadata.code = await formatCode( - context.testCaseMetadata.code, - ); + const code = context.testCaseMetadata.sourceCode; + if (code) { + context.testCaseMetadata.sourceCode = await prettier.format( + code.trim(), + prettierConfig, + ); + } + }, + async testStepContext(context) { + const code = context.testStepMetadata.sourceCode; + if (code) { + context.testStepMetadata.sourceCode = await prettier.format( + code.trim(), + prettierConfig, + ); + } }, }; diff --git a/src/environment/listener.ts b/src/environment/listener.ts index c6b5c59..adaaced 100644 --- a/src/environment/listener.ts +++ b/src/environment/listener.ts @@ -1,5 +1,4 @@ import type { Circus } from '@jest/types'; -import { state } from 'jest-metadata'; import type { AllureTestCaseMetadata, AllureTestStepMetadata, @@ -10,11 +9,10 @@ import type { TestEnvironmentCircusEvent, TestEnvironmentSetupEvent, } from 'jest-environment-emit'; +import * as StackTrace from 'stacktrace-js'; import * as api from '../api'; -import { CODE, PREFIX, SHARED_CONFIG, WORKER_ID } from '../constants'; import realm from '../realms'; -import type { SharedReporterConfig } from '../runtime'; const listener: EnvironmentListenerFn = (context) => { context.testEvents @@ -22,52 +20,96 @@ const listener: EnvironmentListenerFn = (context) => { 'test_environment_setup', function ({ env }: TestEnvironmentSetupEvent) { env.global.__ALLURE__ = realm; - const { injectGlobals } = state.get( - SHARED_CONFIG, - ) as SharedReporterConfig; - + const { injectGlobals } = realm.runtimeContext.getReporterConfig(); if (injectGlobals) { Object.assign(env.global, api); } - state.currentMetadata.set(WORKER_ID, process.env.JEST_WORKER_ID); + realm.runtimeContext + .getFileMetadata() + .set('workerId', process.env.JEST_WORKER_ID); }, ) - .on('add_hook', function ({ event }) { - 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, - } as Record; - - if (hidden) { - delete metadata.code; - metadata.hidden = true; - } - - state.currentMetadata.assign(PREFIX, metadata); - }) - .on('add_test', function ({ event }) { - state.currentMetadata.set(CODE, event.fn.toString()); - }) + .on('add_hook', addSourceLocation) + .on('add_hook', addHookType) + .on('add_test', addSourceLocation) .on('test_start', executableStart) .on('test_todo', testSkip) .on('test_skip', testSkip) .on('test_done', testDone) + .on('hook_start', addSourceCode) .on('hook_start', executableStart) .on('hook_failure', executableFailure) + .on('hook_failure', flush) .on('hook_success', executableSuccess) + .on('hook_success', flush) + .on('test_fn_start', addSourceCode) .on('test_fn_start', executableStart) .on('test_fn_success', executableSuccess) + .on('test_fn_success', flush) .on('test_fn_failure', executableFailure) - .on('teardown', async function () { - await realm.runtime.flush(); - }); + .on('test_fn_failure', flush) + .on('teardown', flush); }; +async function flush() { + await realm.runtime.flush(); +} + +function addSourceLocation({ + event, +}: TestEnvironmentCircusEvent< + Circus.Event & { name: 'add_hook' | 'add_test' } +>) { + const metadata = realm.runtimeContext.getCurrentMetadata(); + const task = StackTrace.fromError(event.asyncError).then(([frame]) => { + if (frame) { + metadata.set('sourceLocation', { + fileName: frame.fileName, + lineNumber: frame.lineNumber, + columnNumber: frame.columnNumber, + }); + } + }); + + realm.runtimeContext.enqueueTask(task); +} + +function addHookType({ + event, +}: TestEnvironmentCircusEvent) { + const metadata = realm.runtimeContext.getCurrentMetadata(); + metadata.set('hookType', event.hookType); +} + +function addSourceCode({ event }: TestEnvironmentCircusEvent) { + let code = ''; + if (event.name === 'hook_start') { + const { type, fn } = event.hook; + code = `${type}(${fn});`; + + if ( + code.includes( + "during setup, this cannot be null (and it's fine to explode if it is)", + ) + ) { + code = ''; + realm.runtimeContext + .getCurrentMetadata() + .push('description', ['Reset mocks, modules and timers (Jest)']); + } + } + + if (event.name === 'test_fn_start') { + const { name, fn } = event.test; + code = `test(${JSON.stringify(name)}, ${fn});`; + } + + if (code) { + realm.runtimeContext.getCurrentMetadata().set('sourceCode', code); + } +} + // eslint-disable-next-line no-empty-pattern function executableStart({}: TestEnvironmentCircusEvent) { const metadata: AllureTestStepMetadata = { @@ -75,7 +117,7 @@ function executableStart({}: TestEnvironmentCircusEvent) { stage: 'running', }; - state.currentMetadata.assign(PREFIX, metadata); + realm.runtimeContext.getCurrentMetadata().assign(metadata); } function executableFailure({ @@ -96,7 +138,7 @@ function executableFailure({ metadata.statusDetails = { message, trace }; } - state.currentMetadata.assign(PREFIX, metadata); + realm.runtimeContext.getCurrentMetadata().assign(metadata); } // eslint-disable-next-line no-empty-pattern @@ -107,7 +149,7 @@ function executableSuccess({}: TestEnvironmentCircusEvent) { status: 'passed', }; - state.currentMetadata.assign(PREFIX, metadata); + realm.runtimeContext.getCurrentMetadata().assign(metadata); } function testSkip() { @@ -117,7 +159,7 @@ function testSkip() { status: 'skipped', }; - state.currentMetadata.assign(PREFIX, metadata); + realm.runtimeContext.getCurrentMetadata().assign(metadata); } function testDone({ @@ -138,7 +180,7 @@ function testDone({ status: hasErrors ? errorStatus : 'passed', }; - state.currentMetadata.assign(PREFIX, metadata); + realm.runtimeContext.getCurrentMetadata().assign(metadata); } function isMatcherError(error: any) { diff --git a/src/metadata/MetadataSquasher.ts b/src/metadata/MetadataSquasher.ts index 0ff4e77..aaee65f 100644 --- a/src/metadata/MetadataSquasher.ts +++ b/src/metadata/MetadataSquasher.ts @@ -1,131 +1,60 @@ +import type { HookInvocationMetadata } from 'jest-metadata'; +import type { TestFileMetadata, TestInvocationMetadata } from 'jest-metadata'; import type { - DescribeBlockMetadata, - GlobalMetadata, - HookInvocationMetadata, - TestEntryMetadata, - TestFileMetadata, - TestFnInvocationMetadata, - TestInvocationMetadata, -} from 'jest-metadata'; -import type { - AllureTestFileMetadata, AllureTestCaseMetadata, + AllureTestFileMetadata, + AllureTestStepMetadata, } from 'jest-allure2-reporter'; -import { chain, chainLast, extractCode, getStart, getStop } from './utils'; +import { getStart, getStop } from './utils'; +import { PREFIX } from './constants'; +import { + mergeTestCaseMetadata, + mergeTestFileMetadata, + mergeTestStepMetadata, +} from './mergers'; export class MetadataSquasher { - protected readonly testFileConfig: MetadataSquasherConfig = - { - code: chainLast(['testFile']), - workerId: chainLast(['testFile']), - description: chain(['globalMetadata', 'testFile']), - descriptionHtml: chain(['globalMetadata', 'testFile']), - attachments: chain(['testFile']), - parameters: chain(['testFile']), - status: chainLast(['testFile']), - statusDetails: chainLast(['testFile']), - labels: chain(['globalMetadata', 'testFile']), - links: chain(['globalMetadata', 'testFile']), - start: chainLast(['testFile']), - stop: chainLast(['testFile']), - }; - - protected readonly testInvocationConfig: MetadataSquasherConfig = - { - code: extractCode, - workerId: chainLast(['testFile']), - description: chain([ - 'globalMetadata', - 'testFile', - 'testEntry', - 'testInvocation', - 'testFnInvocation', - ]), - descriptionHtml: chain([ - 'globalMetadata', - 'testFile', - 'testEntry', - 'testInvocation', - 'testFnInvocation', - ]), - attachments: chain(['testEntry', 'testInvocation', 'anyInvocation']), - parameters: chain(['testEntry', 'testInvocation', 'anyInvocation']), - status: chainLast(['testInvocation']), - statusDetails: chainLast(['anyInvocation', 'testInvocation']), - labels: chain([ - 'globalMetadata', - 'testFile', - 'describeBlock', - 'testEntry', - 'testInvocation', - 'anyInvocation', - ]), - links: chain([ - 'globalMetadata', - 'testFile', - 'describeBlock', - 'testEntry', - 'testInvocation', - 'anyInvocation', - ]), - start: getStart, - stop: getStop, - }; - testFile(metadata: TestFileMetadata): AllureTestFileMetadata { - const config = this.testFileConfig as any; - const keys = Object.keys(config) as (keyof AllureTestCaseMetadata)[]; - const result: Partial = {}; - const context: MetadataSquasherContext = { - globalMetadata: metadata.globalMetadata, - testFile: metadata, - }; - - for (const key of keys) { - result[key] = config[key](context, key); - } - - return result as AllureTestFileMetadata; + const input = [metadata.globalMetadata.get(PREFIX), metadata.get(PREFIX)]; + return (input as AllureTestFileMetadata[]).reduce( + mergeTestFileMetadata, + {}, + ); } testInvocation(metadata: TestInvocationMetadata): AllureTestCaseMetadata { - const config = this.testInvocationConfig as any; - const keys = Object.keys(config) as (keyof AllureTestCaseMetadata)[]; - const result: Partial = {}; - const context: MetadataSquasherContext = { - globalMetadata: metadata.file.globalMetadata, - testFile: metadata.file, - describeBlock: [...metadata.definition.ancestors()], - testEntry: metadata.definition, - testInvocation: metadata, - testFnInvocation: [...metadata.invocations()], - anyInvocation: [...metadata.allInvocations()], + const ancestors = [ + metadata.file.globalMetadata, + metadata.file, + ...metadata.definition.ancestors(), + metadata.definition, + metadata, + metadata.fn, + ].map((item) => + item ? (item.get(PREFIX) as AllureTestCaseMetadata) : undefined, + ); + const result = ancestors.reduce(mergeTestCaseMetadata, {}); + const befores = [...metadata.beforeAll, ...metadata.beforeEach].map( + resolveTestStep, + ); + const afters = [...metadata.afterEach, ...metadata.afterAll].map( + resolveTestStep, + ); + const steps = result.steps ?? []; + + return { + ...result, + + start: getStart(metadata), + stop: getStop(metadata), + steps: [...befores, ...steps, ...afters], }; - - for (const key of keys) { - result[key] = config[key](context, key); - } - - return result as AllureTestCaseMetadata; } } -export type MetadataSquasherConfig = { - [K in keyof T]: MetadataSquasherMapping; -}; - -export type MetadataSquasherMapping = ( - context: MetadataSquasherContext, - key: K, -) => T[K]; - -export type MetadataSquasherContext = { - globalMetadata: GlobalMetadata; - testFile: TestFileMetadata; - describeBlock?: DescribeBlockMetadata[]; - testEntry?: TestEntryMetadata; - testInvocation?: TestInvocationMetadata; - testFnInvocation?: (HookInvocationMetadata | TestFnInvocationMetadata)[]; - anyInvocation?: (HookInvocationMetadata | TestFnInvocationMetadata)[]; -}; +const resolveTestStep = (item: HookInvocationMetadata) => + mergeTestStepMetadata( + item.definition.get(PREFIX) as AllureTestStepMetadata, + item.get(PREFIX) as AllureTestStepMetadata, + ); diff --git a/src/metadata/StepExtractor.ts b/src/metadata/StepExtractor.ts deleted file mode 100644 index 3f1a46f..0000000 --- a/src/metadata/StepExtractor.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { - HookDefinitionMetadata, - HookInvocationMetadata, - TestFnInvocationMetadata, -} from 'jest-metadata'; -import type { AllureTestStepMetadata } from 'jest-allure2-reporter'; - -import { HIDDEN, PREFIX } from '../constants'; - -export class StepExtractor { - public extractFromInvocation( - metadata: HookInvocationMetadata | TestFnInvocationMetadata, - ): AllureTestStepMetadata | null { - const definition = metadata.definition as HookDefinitionMetadata; - const hidden = metadata.get(HIDDEN, definition.get(HIDDEN, false)); - if (hidden) { - return null; - } - - const hookType = definition.hookType; - const data = { - name: hookType, - ...(metadata.get([PREFIX]) as AllureTestStepMetadata), - }; - - if (!hookType) { - delete data.attachments; - delete data.parameters; - } - - return data; - } -} diff --git a/src/constants.ts b/src/metadata/constants.ts similarity index 61% rename from src/constants.ts rename to src/metadata/constants.ts index 1d1e8a8..ec1b020 100644 --- a/src/constants.ts +++ b/src/metadata/constants.ts @@ -1,12 +1,5 @@ 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; - export const START = [PREFIX, 'start'] as const; export const STOP = [PREFIX, 'stop'] as const; diff --git a/src/metadata/index.ts b/src/metadata/index.ts index efd92c3..4a918c5 100644 --- a/src/metadata/index.ts +++ b/src/metadata/index.ts @@ -1,2 +1,2 @@ +export * from './proxies'; export * from './MetadataSquasher'; -export * from './StepExtractor'; diff --git a/src/metadata/mergers.ts b/src/metadata/mergers.ts new file mode 100644 index 0000000..cddea46 --- /dev/null +++ b/src/metadata/mergers.ts @@ -0,0 +1,79 @@ +import type { + AllureTestItemMetadata, + AllureTestFileMetadata, + AllureTestCaseMetadata, + AllureTestStepMetadata, +} from 'jest-allure2-reporter'; + +export function mergeTestFileMetadata( + a: AllureTestFileMetadata, + b: AllureTestFileMetadata | undefined, +): AllureTestFileMetadata { + return b + ? { + ...mergeTestItemMetadata(a, b), + code: undefined, + steps: undefined, + workerId: b.workerId ?? a.workerId, + } + : a; +} + +export function mergeTestCaseMetadata( + a: AllureTestCaseMetadata, + b: AllureTestCaseMetadata | undefined, +): AllureTestCaseMetadata { + return b + ? { + ...mergeTestItemMetadata(a, b), + descriptionHtml: mergeArrays(a.descriptionHtml, b.descriptionHtml), + labels: mergeArrays(a.labels, b.labels), + links: mergeArrays(a.links, b.links), + } + : a; +} + +export function mergeTestStepMetadata( + a: AllureTestStepMetadata, + b: AllureTestStepMetadata | undefined, +): AllureTestStepMetadata { + return b + ? { + ...mergeTestItemMetadata(a, b), + hookType: b.hookType ?? a.hookType, + } + : a; +} + +function mergeTestItemMetadata( + a: AllureTestItemMetadata, + b: AllureTestItemMetadata | undefined, +): AllureTestItemMetadata { + return b + ? { + attachments: mergeArrays(a.attachments, b.attachments), + currentStep: b.currentStep ?? a.currentStep, + sourceCode: b.sourceCode ?? a.sourceCode, + sourceLocation: b.sourceLocation ?? a.sourceLocation, + description: mergeArrays(a.description, b.description), + parameters: mergeArrays(a.parameters, b.parameters), + stage: b.stage ?? a.stage, + start: b.start ?? a.start, + status: b.status ?? a.status, + statusDetails: b.statusDetails ?? a.statusDetails, + steps: b.steps ?? a.steps, + stop: b.stop ?? a.stop, + } + : a; +} + +function mergeArrays( + a: T[] | undefined, + b: T[] | undefined, +): T[] | undefined { + if (a && b) { + return [...a, ...b]; + } + + return a ?? b; +} diff --git a/src/metadata/proxies/AllureMetadataProxy.ts b/src/metadata/proxies/AllureMetadataProxy.ts new file mode 100644 index 0000000..8cabaf5 --- /dev/null +++ b/src/metadata/proxies/AllureMetadataProxy.ts @@ -0,0 +1,42 @@ +import type { Metadata } from 'jest-metadata'; + +import { PREFIX } from '../constants'; + +export class AllureMetadataProxy { + protected readonly $metadata: Metadata; + + constructor(metadata: Metadata) { + this.$metadata = metadata; + } + + get id(): string { + return this.$metadata.id; + } + + get(path?: keyof T, fallbackValue?: V): V { + const fullPath = this.$localPath(path); + return this.$metadata.get(fullPath, fallbackValue); + } + + set(path: K, value: T[K]): this { + const fullPath = this.$localPath(path); + this.$metadata.set(fullPath, value); + return this; + } + + push(key: keyof T, values: unknown[]): this { + const path = this.$localPath(key); + this.$metadata.push(path, values); + return this; + } + + assign(values: Partial): this { + this.$metadata.assign(this.$localPath(), values); + return this; + } + + protected $localPath(key?: keyof T, ...innerKeys: string[]): string[] { + const allKeys = key ? [key as string, ...innerKeys] : innerKeys; + return [PREFIX, ...allKeys]; + } +} diff --git a/src/metadata/proxies/AllureTestItemMetadataProxy.ts b/src/metadata/proxies/AllureTestItemMetadataProxy.ts new file mode 100644 index 0000000..d7885e4 --- /dev/null +++ b/src/metadata/proxies/AllureTestItemMetadataProxy.ts @@ -0,0 +1,52 @@ +import type { + AllureTestItemMetadata, + AllureTestStepMetadata, + AllureTestCaseMetadata, +} from 'jest-allure2-reporter'; +import type { Metadata } from 'jest-metadata'; + +import { CURRENT_STEP, PREFIX } from '../constants'; + +import { AllureMetadataProxy } from './AllureMetadataProxy'; + +export class AllureTestItemMetadataProxy< + T extends AllureTestItemMetadata = AllureTestStepMetadata & + AllureTestCaseMetadata, +> extends AllureMetadataProxy { + protected readonly $boundPath?: string[]; + + constructor(metadata: Metadata, boundPath?: string[]) { + super(metadata); + this.$boundPath = boundPath; + } + + get id(): string { + const localPath = this.$localPath().join('.'); + return localPath ? `${this.$metadata.id}:${localPath}` : this.$metadata.id; + } + + $bind(): AllureTestItemMetadataProxy { + return new AllureTestItemMetadataProxy(this.$metadata, [ + ...this.$metadata.get(CURRENT_STEP, []), + ]); + } + + $startStep(): this { + const count = this.get('steps', []).length; + this.push('steps', [{}]); + this.$metadata.push(CURRENT_STEP, ['steps', `${count}`]); + return this; + } + + $stopStep(): this { + const currentStep = this.$metadata.get(CURRENT_STEP, []) as string[]; + this.$metadata.set(CURRENT_STEP, currentStep.slice(0, -2)); + return this; + } + + protected $localPath(key?: keyof T, ...innerKeys: string[]): string[] { + const stepPath = this.$boundPath ?? this.$metadata.get(CURRENT_STEP, []); + const allKeys = key ? [key as string, ...innerKeys] : innerKeys; + return [PREFIX, ...stepPath, ...allKeys]; + } +} diff --git a/src/metadata/proxies/index.ts b/src/metadata/proxies/index.ts new file mode 100644 index 0000000..363f60f --- /dev/null +++ b/src/metadata/proxies/index.ts @@ -0,0 +1,2 @@ +export * from './AllureMetadataProxy'; +export * from './AllureTestItemMetadataProxy'; diff --git a/src/metadata/utils/chain.ts b/src/metadata/utils/chain.ts deleted file mode 100644 index 0112d2c..0000000 --- a/src/metadata/utils/chain.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Metadata } from 'jest-metadata'; - -import { PREFIX } from '../../constants'; -import type { - MetadataSquasherContext, - MetadataSquasherMapping, -} from '../MetadataSquasher'; - -export function chain( - sources: (keyof MetadataSquasherContext)[], -): MetadataSquasherMapping { - return (context: MetadataSquasherContext, key: K) => { - const path = [PREFIX, key as string]; - const metadatas: Metadata[] = sources.flatMap((sourceName) => { - const value: Metadata | Metadata[] | undefined = context[sourceName]; - if (!value) return []; - return Array.isArray(value) ? value : [value]; - }); - - 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 deleted file mode 100644 index 45334cf..0000000 --- a/src/metadata/utils/extractCode.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 { CODE } from '../../constants'; - -export const extractCode: MetadataSquasherMapping< - AllureTestCaseMetadata, - 'code' -> = ({ testInvocation }) => { - if (!testInvocation) return []; - - const getHookDefinition = (metadata: HookInvocationMetadata) => - metadata.definition; - const getCode = (functionName: string) => (metadata: Metadata) => { - const code = metadata.get(CODE); - return code ? `${functionName}(${code})` : ''; - }; - - return [ - ...testInvocation.beforeAll - .map(getHookDefinition) - .map(getCode('beforeAll')), - ...testInvocation.beforeEach - .map(getHookDefinition) - .map(getCode('beforeEach')), - getCode('test')(testInvocation.definition), - ...testInvocation.afterEach - .map(getHookDefinition) - .map(getCode('afterEach')), - ...testInvocation.afterAll.map(getHookDefinition).map(getCode('afterAll')), - ].filter(Boolean); -}; diff --git a/src/metadata/utils/getStart.ts b/src/metadata/utils/getStart.ts index 4bf98d9..9ed6236 100644 --- a/src/metadata/utils/getStart.ts +++ b/src/metadata/utils/getStart.ts @@ -1,19 +1,19 @@ -import type { AllureTestCaseMetadata } from 'jest-allure2-reporter'; +import type { TestInvocationMetadata } from 'jest-metadata'; -import type { MetadataSquasherMapping } from '../MetadataSquasher'; -import { START } from '../../constants'; +import { START } from '../constants'; -export const getStart: MetadataSquasherMapping< - AllureTestCaseMetadata, - 'start' -> = ({ testEntry, testInvocation }) => { +export const getStart = (testInvocation: TestInvocationMetadata) => { const firstBlock = - testInvocation && - (testInvocation.beforeAll[0] ?? - testInvocation.beforeEach[0] ?? - testInvocation.fn); + testInvocation.beforeAll[0] ?? + testInvocation.beforeEach[0] ?? + testInvocation.fn; - return (firstBlock?.get(START) ?? - testInvocation?.fn?.get(START) ?? - testEntry?.get(START)) as number; + const start1 = testInvocation.get(START); + const start2 = firstBlock?.get(START); + + if (typeof start1 === 'number') { + return typeof start2 === 'number' ? Math.min(start1, start2) : start1; + } else { + return typeof start2 === 'number' ? start2 : Number.NaN; + } }; diff --git a/src/metadata/utils/getStop.ts b/src/metadata/utils/getStop.ts index 963166d..d53e787 100644 --- a/src/metadata/utils/getStop.ts +++ b/src/metadata/utils/getStop.ts @@ -1,19 +1,19 @@ -import type { AllureTestCaseMetadata } from 'jest-allure2-reporter'; +import type { TestInvocationMetadata } from 'jest-metadata'; -import type { MetadataSquasherMapping } from '../MetadataSquasher'; -import { STOP } from '../../constants'; +import { STOP } from '../constants'; -export const getStop: MetadataSquasherMapping< - AllureTestCaseMetadata, - 'stop' -> = ({ testEntry, testInvocation }) => { +export const getStop = (testInvocation: TestInvocationMetadata) => { const lastBlock = - testInvocation && - (testInvocation.afterAll.at(-1) ?? - testInvocation.afterEach.at(-1) ?? - testInvocation.fn); + testInvocation.afterAll.at(-1) ?? + testInvocation.afterEach.at(-1) ?? + testInvocation.fn; - return (lastBlock?.get(STOP) ?? - testInvocation?.get(STOP) ?? - testEntry?.get(STOP)) as number; + const stop1 = testInvocation.get(STOP); + const stop2 = lastBlock?.get(STOP); + + if (typeof stop1 === 'number') { + return typeof stop2 === 'number' ? Math.max(stop1, stop2) : stop1; + } else { + return typeof stop2 === 'number' ? stop2 : Number.NaN; + } }; diff --git a/src/metadata/utils/index.ts b/src/metadata/utils/index.ts index 13ebde6..ac3d3e4 100644 --- a/src/metadata/utils/index.ts +++ b/src/metadata/utils/index.ts @@ -1,4 +1,2 @@ -export * from './chain'; -export * from './extractCode'; export * from './getStart'; export * from './getStop'; diff --git a/src/options/compose-options/attachments.ts b/src/options/compose-options/attachments.ts index b4c7323..cccbccc 100644 --- a/src/options/compose-options/attachments.ts +++ b/src/options/compose-options/attachments.ts @@ -10,6 +10,7 @@ export function composeAttachments( return { subDir: custom?.subDir ?? base.subDir, + contentHandler: custom?.contentHandler ?? base.contentHandler, fileHandler: custom?.fileHandler ?? base.fileHandler, }; } diff --git a/src/options/compose-options/testCase.ts b/src/options/compose-options/testCase.ts index 07db183..8dabc0d 100644 --- a/src/options/compose-options/testCase.ts +++ b/src/options/compose-options/testCase.ts @@ -18,6 +18,7 @@ export function composeTestCaseCustomizers( } return { + hidden: composeExtractors(custom.hidden, base.hidden), historyId: composeExtractors(custom.historyId, base.historyId), fullName: composeExtractors(custom.fullName, base.fullName), name: composeExtractors(custom.name, base.name), diff --git a/src/options/compose-options/testFile.ts b/src/options/compose-options/testFile.ts index 539eb3f..4c70c07 100644 --- a/src/options/compose-options/testFile.ts +++ b/src/options/compose-options/testFile.ts @@ -17,6 +17,6 @@ export function composeTestFileCustomizers( return { ...(composeTestCaseCustomizers(base, custom) as any), - ignored: composeExtractors(custom.ignored, base.ignored), + hidden: composeExtractors(custom.hidden, base.hidden), }; } diff --git a/src/options/compose-options/testStep.ts b/src/options/compose-options/testStep.ts index 6bf3e4f..338b760 100644 --- a/src/options/compose-options/testStep.ts +++ b/src/options/compose-options/testStep.ts @@ -11,6 +11,7 @@ export function composeTestStepCustomizers( } return { + hidden: composeExtractors(custom.hidden, base.hidden), name: composeExtractors(custom.name, base.name), stage: composeExtractors(custom.stage, base.stage), start: composeExtractors(custom.start, base.start), diff --git a/src/options/default-options/index.ts b/src/options/default-options/index.ts index 1cfde17..eeada8f 100644 --- a/src/options/default-options/index.ts +++ b/src/options/default-options/index.ts @@ -20,6 +20,7 @@ export function defaultOptions(context: PluginContext): ReporterConfig { injectGlobals: true, attachments: { subDir: 'attachments', + contentHandler: 'write', fileHandler: 'ref', }, testFile, diff --git a/src/options/default-options/testCase.ts b/src/options/default-options/testCase.ts index 4b6cea4..da077d0 100644 --- a/src/options/default-options/testCase.ts +++ b/src/options/default-options/testCase.ts @@ -12,6 +12,7 @@ import type { Stage, Status, StatusDetails, + AllureTestStepMetadata, } from 'jest-allure2-reporter'; import { @@ -24,16 +25,39 @@ const identity = (context: ExtractorContext) => context.value; const last = (context: ExtractorContext) => context.value?.at(-1); const all = identity; +function extractCode( + steps: AllureTestStepMetadata[] | undefined, +): string | undefined { + return joinCode(steps?.map((step) => step.sourceCode)); +} + +function joinCode( + code: undefined | (string | undefined)[], +): string | undefined { + return code?.filter(Boolean).join('\n\n') || undefined; +} + export const testCase: ResolvedTestCaseCustomizer = { + hidden: () => false, historyId: ({ testCase }) => testCase.fullName, name: ({ testCase }) => testCase.title, fullName: ({ testCase }) => testCase.fullName, description: ({ testCaseMetadata }) => { const text = testCaseMetadata.description?.join('\n') ?? ''; - const code = testCaseMetadata.code?.length - ? '```javascript\n' + testCaseMetadata.code.join('\n\n') + '\n```' - : ''; - return [text, code].filter(Boolean).join('\n\n'); + const before = extractCode( + testCaseMetadata.steps?.filter( + (step) => + step.hookType === 'beforeAll' || step.hookType === 'beforeEach', + ), + ); + const after = extractCode( + testCaseMetadata.steps?.filter( + (step) => step.hookType === 'afterAll' || step.hookType === 'afterEach', + ), + ); + const code = joinCode([before, testCaseMetadata.sourceCode, after]); + const snippet = code ? '```javascript\n' + code + '\n```' : ''; + return [text, snippet].filter(Boolean).join('\n\n'); }, descriptionHtml: () => void 0, start: ({ testCase, testCaseMetadata }) => @@ -61,7 +85,7 @@ export const testCase: ResolvedTestCaseCustomizer = { epic: all, feature: all, story: all, - thread: ({ testCaseMetadata }) => testCaseMetadata.workerId, + thread: ({ testFileMetadata }) => testFileMetadata.workerId, severity: last, tag: all, owner: last, diff --git a/src/options/default-options/testFile.ts b/src/options/default-options/testFile.ts index d3d20f3..de78ddd 100644 --- a/src/options/default-options/testFile.ts +++ b/src/options/default-options/testFile.ts @@ -21,7 +21,7 @@ const last = (context: ExtractorContext) => context.value?.at(-1); const all = identity; export const testFile: ResolvedTestFileCustomizer = { - ignored: ({ testFile }) => !testFile.testExecError, + hidden: ({ testFile }) => !testFile.testExecError, historyId: ({ filePath }) => filePath.join('/'), name: ({ filePath }) => filePath.join(path.sep), fullName: ({ globalConfig, testFile }) => diff --git a/src/options/default-options/testStep.ts b/src/options/default-options/testStep.ts index 18ee0a0..2be5724 100644 --- a/src/options/default-options/testStep.ts +++ b/src/options/default-options/testStep.ts @@ -3,7 +3,9 @@ import type { ResolvedTestStepCustomizer } from 'jest-allure2-reporter'; import { stripStatusDetails } from '../utils'; export const testStep: ResolvedTestStepCustomizer = { - name: ({ testStepMetadata }) => testStepMetadata.name, + hidden: () => false, + name: ({ testStepMetadata }) => + testStepMetadata.description?.at(-1) ?? testStepMetadata.hookType, start: ({ testStepMetadata }) => testStepMetadata.start, stop: ({ testStepMetadata }) => testStepMetadata.stop, stage: ({ testStepMetadata }) => testStepMetadata.stage, diff --git a/src/options/index.ts b/src/options/index.ts index eaa99f1..82a5d05 100644 --- a/src/options/index.ts +++ b/src/options/index.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import type { PluginContext, ReporterOptions, @@ -11,5 +13,7 @@ export function resolveOptions( context: PluginContext, options?: ReporterOptions | undefined, ): ReporterConfig { - return composeOptions(context, defaultOptions(context), options); + const result = composeOptions(context, defaultOptions(context), options); + result.resultsDir = path.resolve(result.resultsDir); + return result; } diff --git a/src/realms/AllureRealm.ts b/src/realms/AllureRealm.ts index c3787a2..88c6b58 100644 --- a/src/realms/AllureRealm.ts +++ b/src/realms/AllureRealm.ts @@ -1,16 +1,27 @@ import { state } from 'jest-metadata'; +import type { AllureGlobalMetadata } from 'jest-allure2-reporter'; -import { AllureRuntime } from '../runtime'; -import { SHARED_CONFIG } from '../constants'; -import { AttachmentsHandler } from '../runtime'; import type { SharedReporterConfig } from '../runtime'; +import { AllureRuntime, AllureRuntimeContext } from '../runtime'; +import { AllureMetadataProxy } from '../metadata'; export class AllureRealm { - runtime = new AllureRuntime({ - metadataProvider: () => state.currentMetadata, - nowProvider: () => Date.now(), - attachmentsHandler: new AttachmentsHandler(() => { - return state.get(SHARED_CONFIG) as SharedReporterConfig; - }), + runtimeContext = new AllureRuntimeContext({ + getCurrentMetadata: () => state.currentMetadata, + getFileMetadata: () => state.lastTestFile!, + getGlobalMetadata: () => state, + getNow: () => Date.now(), + getReporterConfig() { + const config = new AllureMetadataProxy(state).get( + 'config', + ); + if (!config) { + throw new Error('Shared reporter config is not defined'); + } + + return config as SharedReporterConfig; + }, }); + + runtime = new AllureRuntime(this.runtimeContext); } diff --git a/src/reporter/JestAllure2Reporter.ts b/src/reporter/JestAllure2Reporter.ts index c2d5c4a..f2315dc 100644 --- a/src/reporter/JestAllure2Reporter.ts +++ b/src/reporter/JestAllure2Reporter.ts @@ -13,20 +13,8 @@ import type { import { state } from 'jest-metadata'; import JestMetadataReporter from 'jest-metadata/reporter'; import rimraf from 'rimraf'; -import { AllureRuntime } from '@noomorph/allure-js-commons'; -import type { - AllureTestStepMetadata, - GlobalExtractorContext, - Plugin, - PluginHookName, - ReporterConfig, - ReporterOptions, - ResolvedTestStepCustomizer, - TestCaseExtractorContext, - TestStepExtractorContext, - TestFileExtractorContext, -} from 'jest-allure2-reporter'; import type { + AllureGroup, Attachment, Category, ExecutableItemWrapper, @@ -37,17 +25,29 @@ import type { Status, StatusDetails, } from '@noomorph/allure-js-commons'; +import { AllureRuntime } from '@noomorph/allure-js-commons'; +import type { + AllureGlobalMetadata, + AllureTestFileMetadata, + AllureTestStepMetadata, + GlobalExtractorContext, + Plugin, + PluginHookName, + ReporterConfig, + ReporterOptions, + TestCaseExtractorContext, + TestFileExtractorContext, + TestStepExtractorContext, +} from 'jest-allure2-reporter'; import { resolveOptions } from '../options'; -import { MetadataSquasher, StepExtractor } from '../metadata'; -import { SHARED_CONFIG, START, STOP, WORKER_ID } from '../constants'; -import type { SharedReporterConfig } from '../runtime'; -import { ThreadService } from '../utils/ThreadService'; -import md5 from '../utils/md5'; +import { AllureMetadataProxy, MetadataSquasher } from '../metadata'; +import { md5 } from '../utils'; + +import { ThreadService } from './ThreadService'; export class JestAllure2Reporter extends JestMetadataReporter { private _plugins: readonly Plugin[] = []; - private _processMarkdown?: (markdown: string) => Promise; private readonly _allure: AllureRuntime; private readonly _config: ReporterConfig; private readonly _globalConfig: Config.GlobalConfig; @@ -63,12 +63,13 @@ export class JestAllure2Reporter extends JestMetadataReporter { resultsDir: this._config.resultsDir, }); - state.set(SHARED_CONFIG, { + const globalMetadata = new AllureMetadataProxy(state); + globalMetadata.set('config', { resultsDir: this._config.resultsDir, overwrite: this._config.overwrite, attachments: this._config.attachments, injectGlobals: this._config.injectGlobals, - } as SharedReporterConfig); + }); } async onRunStart( @@ -90,8 +91,13 @@ export class JestAllure2Reporter extends JestMetadataReporter { const testFileMetadata = JestAllure2Reporter.query.test(test); const threadId = this._threadService.allocateThread(test.path); - testFileMetadata.set(WORKER_ID, String(1 + threadId)); - testFileMetadata.set(START, Date.now()); + const metadataProxy = new AllureMetadataProxy( + testFileMetadata, + ); + metadataProxy.assign({ + workerId: String(1 + threadId), + start: Date.now(), + }); } onTestCaseResult(test: Test, testCaseResult: TestCaseResult) { @@ -99,9 +105,12 @@ export class JestAllure2Reporter extends JestMetadataReporter { super.onTestCaseResult(test, testCaseResult); const metadata = JestAllure2Reporter.query.testCaseResult(testCaseResult).lastInvocation!; - const stop = metadata.get(STOP, Number.NaN); + const metadataProxy = new AllureMetadataProxy( + metadata, + ); + const stop = metadataProxy.get('stop', Number.NaN); if (Number.isNaN(stop)) { - metadata.set(STOP, now); + metadataProxy.set('stop', now); } } @@ -113,7 +122,10 @@ export class JestAllure2Reporter extends JestMetadataReporter { this._threadService.freeThread(test.path); const testFileMetadata = JestAllure2Reporter.query.test(test); - testFileMetadata.set(STOP, Date.now()); + const metadataProxy = new AllureMetadataProxy( + testFileMetadata, + ); + metadataProxy.set('stop', Date.now()); return super.onTestFileResult(test, testResult, aggregatedResult); } @@ -126,7 +138,7 @@ export class JestAllure2Reporter extends JestMetadataReporter { const config = this._config; - const globalContext: GlobalExtractorContext = { + const globalContext: GlobalExtractorContext = { globalConfig: this._globalConfig, config, value: undefined, @@ -150,51 +162,47 @@ export class JestAllure2Reporter extends JestMetadataReporter { } const squasher = new MetadataSquasher(); - const stepper = new StepExtractor(); for (const testResult of results.testResults) { - const beforeTestFileContext: Omit< - TestFileExtractorContext, - 'testFileMetadata' - > = { + const testFileContext: TestFileExtractorContext = { ...globalContext, filePath: path .relative(globalContext.globalConfig.rootDir, testResult.testFilePath) .split(path.sep), testFile: testResult, - }; - - await this._callPlugins('beforeTestFileContext', beforeTestFileContext); - - const testFileContext: TestFileExtractorContext = { - ...beforeTestFileContext, testFileMetadata: squasher.testFile( JestAllure2Reporter.query.testResult(testResult), ), }; await this._callPlugins('testFileContext', testFileContext); - this._processMarkdown = testFileContext.processMarkdown; - if (!config.testFile.ignored(testFileContext)) { + if (!config.testFile.hidden(testFileContext)) { + // pseudo-test entity, used for reporting file-level errors and other obscure purposes + const allureFileTest: AllurePayloadTest = { + name: config.testFile.name(testFileContext), + start: config.testFile.start(testFileContext), + stop: config.testFile.stop(testFileContext), + historyId: config.testFile.historyId(testFileContext), + fullName: config.testFile.fullName(testFileContext), + description: config.testFile.description(testFileContext), + descriptionHtml: config.testFile.descriptionHtml(testFileContext), + // TODO: merge @noomorph/allure-js-commons into this package and remove casting + stage: config.testFile.stage(testFileContext) as string as Stage, + status: config.testFile.status(testFileContext) as string as Status, + statusDetails: config.testFile.statusDetails(testFileContext), + links: config.testFile.links(testFileContext), + labels: config.testFile.labels(testFileContext), + parameters: config.testFile.parameters(testFileContext), + attachments: config.testFile + .attachments(testFileContext) + ?.map(this._relativizeAttachment), + }; + + await this._renderHtmlDescription(testFileContext, allureFileTest); await this._createTest({ containerName: `${testResult.testFilePath}`, - test: { - name: config.testFile.name(testFileContext), - start: config.testFile.start(testFileContext), - stop: config.testFile.stop(testFileContext), - historyId: config.testFile.historyId(testFileContext), - fullName: config.testFile.fullName(testFileContext), - description: config.testFile.description(testFileContext), - descriptionHtml: config.testFile.descriptionHtml(testFileContext), - status: config.testFile.status(testFileContext) as string as Status, - statusDetails: config.testFile.statusDetails(testFileContext), - stage: config.testFile.stage(testFileContext) as string as Stage, - links: config.testFile.links(testFileContext), - labels: config.testFile.labels(testFileContext), - parameters: config.testFile.parameters(testFileContext), - attachments: config.testFile.attachments(testFileContext), - }, + test: allureFileTest, }); } @@ -207,7 +215,7 @@ export class JestAllure2Reporter extends JestMetadataReporter { const testCaseMetadata = squasher.testInvocation( testInvocationMetadata, ); - const testCaseContext: TestCaseExtractorContext = { + const testCaseContext: TestCaseExtractorContext = { ...testFileContext, testCase: testCaseResult, testCaseMetadata, @@ -215,134 +223,130 @@ export class JestAllure2Reporter extends JestMetadataReporter { await this._callPlugins('testCaseContext', testCaseContext); + if (config.testCase.hidden(testCaseContext)) { + continue; + } + + const testCaseSteps = testCaseMetadata.steps ?? []; + const visibleTestStepContexts = testCaseSteps + .map( + (testStepMetadata) => + ({ + ...testCaseContext, + testStepMetadata, + }) as TestStepExtractorContext, + ) + .filter((testStepMetadataContext) => { + return !config.testStep.hidden(testStepMetadataContext); + }); + + if (testCaseMetadata.steps) { + testCaseMetadata.steps = visibleTestStepContexts.map( + (c) => c.testStepMetadata, + ); + } + + let allureSteps: AllurePayloadStep[] = await Promise.all( + visibleTestStepContexts.map(async (testStepContext) => { + await this._callPlugins('testStepContext', testStepContext); + + const result: AllurePayloadStep = { + hookType: testStepContext.testStepMetadata.hookType, + name: config.testStep.name(testStepContext), + start: config.testStep.start(testStepContext), + stop: config.testStep.stop(testStepContext), + stage: config.testStep.stage( + testStepContext, + ) as string as Stage, + status: config.testStep.status( + testStepContext, + ) as string as Status, + statusDetails: config.testStep.statusDetails(testStepContext), + parameters: config.testStep.parameters(testStepContext), + attachments: config.testStep + .attachments(testStepContext) + ?.map(this._relativizeAttachment), + }; + + return result; + }), + ); + + const allureTest: AllurePayloadTest = { + name: config.testCase.name(testCaseContext), + start: config.testCase.start(testCaseContext), + stop: config.testCase.stop(testCaseContext), + historyId: config.testCase.historyId(testCaseContext), + fullName: config.testCase.fullName(testCaseContext), + description: config.testCase.description(testCaseContext), + descriptionHtml: config.testCase.descriptionHtml(testCaseContext), + // TODO: merge @noomorph/allure-js-commons into this package and remove casting + stage: config.testCase.stage(testCaseContext) as string as Stage, + status: config.testCase.status(testCaseContext) as string as Status, + statusDetails: config.testCase.statusDetails(testCaseContext), + links: config.testCase.links(testCaseContext), + labels: config.testCase.labels(testCaseContext), + parameters: config.testCase.parameters(testCaseContext), + attachments: config.testCase + .attachments(testCaseContext) + ?.map(this._relativizeAttachment), + steps: allureSteps.filter((step) => !step.hookType), + }; + + allureSteps = allureSteps.filter((step) => step.hookType); + + await this._renderHtmlDescription(testCaseContext, allureTest); + const invocationIndex = allInvocations.indexOf( testInvocationMetadata, ); await this._createTest({ containerName: `${testCaseResult.fullName} (${invocationIndex})`, - test: { - name: config.testCase.name(testCaseContext), - start: config.testCase.start(testCaseContext), - stop: config.testCase.stop(testCaseContext), - historyId: config.testCase.historyId(testCaseContext), - fullName: config.testCase.fullName(testCaseContext), - description: config.testCase.description(testCaseContext), - descriptionHtml: config.testCase.descriptionHtml(testCaseContext), - status: config.testCase.status( - testCaseContext, - ) as string as Status, - statusDetails: config.testCase.statusDetails(testCaseContext), - stage: config.testCase.stage(testCaseContext) as string as Stage, - links: config.testCase.links(testCaseContext), - labels: config.testCase.labels(testCaseContext), - parameters: config.testCase.parameters(testCaseContext), - attachments: config.testCase.attachments(testCaseContext), - }, - testCaseContext, - beforeAll: testInvocationMetadata.beforeAll.map((m) => - stepper.extractFromInvocation(m), - ), - beforeEach: testInvocationMetadata.beforeEach.map((m) => - stepper.extractFromInvocation(m), - ), - testFn: - testInvocationMetadata.fn && - stepper.extractFromInvocation(testInvocationMetadata.fn), - afterEach: testInvocationMetadata.afterEach.map((m) => - stepper.extractFromInvocation(m), - ), - afterAll: testInvocationMetadata.afterAll.map((m) => - stepper.extractFromInvocation(m), - ), + test: allureTest, + steps: allureSteps, }); } } } } - private async _createTest({ - test, - testCaseContext, - containerName, - beforeAll = [], - beforeEach = [], - testFn, - afterEach = [], - afterAll = [], - }: AllurePayload) { + private async _createTest({ test, containerName, steps }: AllurePayload) { const allure = this._allure; const allureGroup = allure.startGroup(containerName); - const allureTest = allureGroup.startTest(test.name, test.start); + const allureTest = allureGroup.startTest(); + + this._fillStep(allureTest, test); + if (test.historyId) { allureTest.historyId = md5(test.historyId); } if (test.fullName) { allureTest.fullName = test.fullName; } - - if (!test.descriptionHtml && test.description && this._processMarkdown) { - const newHTML = await this._processMarkdown(test.description); - allureTest.descriptionHtml = newHTML; - } else { + if (test.description) { allureTest.description = test.description; - allureTest.descriptionHtml = test.descriptionHtml; - } - - if (test.status) { - allureTest.status = test.status; - } - - if (test.statusDetails) { - allureTest.statusDetails = test.statusDetails; } - - if (test.stage) { - allureTest.stage = test.stage; + if (test.descriptionHtml) { + allureTest.descriptionHtml = test.descriptionHtml; } - if (test.links) { for (const link of test.links) { allureTest.addLink(link.url, link.name, link.type); } } - if (test.labels) { for (const label of test.labels) { allureTest.addLabel(label.name, label.value); } } - - allureTest.wrappedItem.parameters = test.parameters ?? []; - allureTest.wrappedItem.attachments = (test.attachments ?? []).map( - this._relativizeAttachment, - ); - - if (testCaseContext) { - const befores = [...beforeAll, ...beforeEach].filter( - Boolean, - ) as AllureTestStepMetadata[]; - for (const testStepMetadata of befores) { - await this._createStep( - testCaseContext, - allureGroup.addBefore(), - testStepMetadata, - ); - } - - if (testFn) { - await this._createStep(testCaseContext, allureTest, testFn, true); - } - - const afters = [...afterEach, ...afterAll].filter( - Boolean, - ) as AllureTestStepMetadata[]; - for (const testStepMetadata of afters) { - await this._createStep( - testCaseContext, - allureGroup.addAfter(), - testStepMetadata, + if (steps) { + for (const step of steps) { + const executable = this._createStepExecutable( + allureGroup, + step.hookType, ); + await this._fillStep(executable, step); } } @@ -350,47 +354,67 @@ export class JestAllure2Reporter extends JestMetadataReporter { allureGroup.endGroup(); } - private async _createStep( - testCaseContext: TestCaseExtractorContext, - executable: ExecutableItemWrapper, - testStepMetadata: AllureTestStepMetadata, - isTest = false, + private async _renderHtmlDescription( + context: GlobalExtractorContext, + test: AllurePayloadTest, ) { - const config = this._config; - const customize: ResolvedTestStepCustomizer = config.testStep; - const testStepContext = { - ...testCaseContext, - testStepMetadata, - } as TestStepExtractorContext; - - await this._callPlugins('testStepContext', testStepContext); - - if (!isTest) { - executable.name = customize.name(testStepContext) ?? executable.name; - executable.wrappedItem.start = customize.start(testStepContext); - executable.wrappedItem.stop = customize.stop(testStepContext); - executable.stage = - (customize.stage(testStepContext) as string as Stage) ?? - executable.stage; - executable.status = - (customize.status(testStepContext) as string as Status) ?? - executable.status; - executable.statusDetails = customize.statusDetails(testStepContext) ?? {}; - - executable.wrappedItem.attachments = customize - .attachments(testStepContext)! - .map(this._relativizeAttachment); - executable.wrappedItem.parameters = - customize.parameters(testStepContext)!; + if (test.description && !test.descriptionHtml && context.processMarkdown) { + test.descriptionHtml = await context.processMarkdown(test.description); } + } - if (testStepMetadata.steps) { - for (const innerStep of testStepMetadata.steps) { - await this._createStep( - testCaseContext, - executable.startStep('', 0), + private _createStepExecutable( + parent: AllureGroup, + hookType: AllureTestStepMetadata['hookType'], + ) { + switch (hookType) { + case 'beforeAll': + case 'beforeEach': { + return parent.addBefore(); + } + case 'afterEach': + case 'afterAll': { + return parent.addAfter(); + } + default: { + throw new Error(`Cannot create step executable for ${hookType}`); + } + } + } + + private _fillStep( + executable: ExecutableItemWrapper, + step: AllurePayloadStep, + ) { + if (step.name !== undefined) { + executable.name = step.name; + } + if (step.start !== undefined) { + executable.wrappedItem.start = step.start; + } + if (step.stop !== undefined) { + executable.wrappedItem.stop = step.stop; + } + if (step.stage !== undefined) { + executable.stage = step.stage; + } + if (step.status !== undefined) { + executable.status = step.status; + } + if (step.statusDetails !== undefined) { + executable.statusDetails = step.statusDetails; + } + if (step.attachments !== undefined) { + executable.wrappedItem.attachments = step.attachments; + } + if (step.parameters) { + executable.wrappedItem.parameters = step.parameters; + } + if (step.steps) { + for (const innerStep of step.steps) { + this._fillStep( + executable.startStep(innerStep.name ?? '', innerStep.start), innerStep, - false, ); } } @@ -405,9 +429,14 @@ export class JestAllure2Reporter extends JestMetadataReporter { } _relativizeAttachment = (attachment: Attachment) => { + const source = path.relative(this._config.resultsDir, attachment.source); + if (source.startsWith('..')) { + return attachment; + } + return { ...attachment, - source: path.relative(this._config.resultsDir, attachment.source), + source, }; }; } @@ -415,15 +444,12 @@ export class JestAllure2Reporter extends JestMetadataReporter { type AllurePayload = { containerName: string; test: AllurePayloadTest; - testCaseContext?: TestCaseExtractorContext; - testFn?: AllureTestStepMetadata | null; - beforeAll?: (AllureTestStepMetadata | null)[]; - beforeEach?: (AllureTestStepMetadata | null)[]; - afterEach?: (AllureTestStepMetadata | null)[]; - afterAll?: (AllureTestStepMetadata | null)[]; + steps?: AllurePayloadStep[]; }; type AllurePayloadStep = Partial<{ + hookType?: 'beforeAll' | 'beforeEach' | 'afterEach' | 'afterAll'; + name: string; start: number; stop: number; @@ -436,6 +462,7 @@ type AllurePayloadStep = Partial<{ }>; type AllurePayloadTest = Partial<{ + hookType?: never; historyId: string; fullName: string; description: string; diff --git a/src/utils/ThreadService.ts b/src/reporter/ThreadService.ts similarity index 100% rename from src/utils/ThreadService.ts rename to src/reporter/ThreadService.ts diff --git a/src/runtime/AllureRuntime.test.ts b/src/runtime/AllureRuntime.test.ts index 02192ba..10a1a1e 100644 --- a/src/runtime/AllureRuntime.test.ts +++ b/src/runtime/AllureRuntime.test.ts @@ -1,32 +1,41 @@ +import path from 'node:path'; + import { state } from 'jest-metadata'; -import { PREFIX } from '../constants'; +import { AllureMetadataProxy } from '../metadata'; import { AllureRuntime } from './AllureRuntime'; -import type { IAttachmentsHandler } from './AttachmentsHandler'; +import type { SharedReporterConfig } from './types'; +import { AllureRuntimeContext } from './AllureRuntimeContext'; describe('AllureRuntime', () => { it('should add attachments within the steps', async () => { let now = 0; - const attachmentsHandler: IAttachmentsHandler = { - placeAttachment: (_name, content) => { - return `/attachments/${content}`; - }, - secureAttachment(filePath) { - return { destinationPath: filePath }; + const context = new AllureRuntimeContext({ + contentAttachmentHandlers: { + write: (context) => + path.join(context.outDir, context.content.toString()), }, - writeAttachment: async () => { - /* noop */ + getCurrentMetadata: () => state.currentMetadata, + getFileMetadata: () => state.lastTestFile!, + getGlobalMetadata: () => state, + getNow: () => now++, + getReporterConfig(): SharedReporterConfig { + return { + overwrite: true, + resultsDir: '/tmp', + injectGlobals: false, + attachments: { + subDir: '../attachments', + contentHandler: 'write', + fileHandler: 'ref', + }, + }; }, - }; - - const runtime = new AllureRuntime({ - metadataProvider: () => state, - nowProvider: () => now++, - attachmentsHandler, }); + const runtime = new AllureRuntime(context); runtime.attachment('attachment1', Buffer.from('first'), 'text/plain'); const innerStep3 = runtime.createStep( @@ -60,6 +69,7 @@ describe('AllureRuntime', () => { }); }); runtime.attachment('attachment5', Buffer.from('fifth'), 'text/plain'); - expect(state.get(PREFIX)).toMatchSnapshot(); + await runtime.flush(); + expect(new AllureMetadataProxy(state).get()).toMatchSnapshot(); }); }); diff --git a/src/runtime/AllureRuntime.ts b/src/runtime/AllureRuntime.ts index 676bab0..0d9cc5d 100644 --- a/src/runtime/AllureRuntime.ts +++ b/src/runtime/AllureRuntime.ts @@ -1,336 +1,174 @@ -import path from 'node:path'; +import util from 'node:util'; -import type { Metadata } from 'jest-metadata'; -import type { - AllureTestStepMetadata, - LabelName, - Stage, - Status, - StatusDetails, -} from 'jest-allure2-reporter'; +import type { Parameter } from 'jest-allure2-reporter'; -import type { - AttachmentContent, - AttachmentOptions, - ParameterOrString, -} from '../runtime'; -import { - CURRENT_STEP, - DESCRIPTION, - DESCRIPTION_HTML, - LABELS, - LINKS, - PREFIX, -} from '../constants'; -import { isPromiseLike } from '../utils/isPromiseLike'; -import { inferMimeType } from '../utils/inferMimeType'; -import { hijackFunction } from '../utils/hijackFunction'; -import type { Function_, MaybePromise } from '../utils/types'; -import { processMaybePromise } from '../utils/processMaybePromise'; -import { wrapFunction } from '../utils/wrapFunction'; -import { formatString } from '../utils/formatString'; +import { constant, isObject } from '../utils'; import type { AllureRuntimeBindOptions, + AllureRuntimePluginCallback, IAllureRuntime, - ParameterOptions, -} from './IAllureRuntime'; -import type { IAttachmentsHandler } from './AttachmentsHandler'; - -export type AllureRuntimeConfig = { - attachmentsHandler: IAttachmentsHandler; - metadataProvider: () => Metadata; - nowProvider: () => number; -}; - -const constant = - (value: T) => - () => - value; -const noop = (..._arguments: unknown[]) => void 0; +} from './types'; +import * as runtimeModules from './modules'; +import type { AllureRuntimeContext } from './AllureRuntimeContext'; export class AllureRuntime implements IAllureRuntime { - readonly #attachmentsHandler: AllureRuntimeConfig['attachmentsHandler']; - readonly #metadataProvider: AllureRuntimeConfig['metadataProvider']; - readonly #now: AllureRuntimeConfig['nowProvider']; - #idle = Promise.resolve(); - - get #metadata(): Metadata { - return this.#metadataProvider(); - } - - constructor(config: AllureRuntimeConfig) { - this.#attachmentsHandler = config.attachmentsHandler; - this.#metadataProvider = config.metadataProvider; - this.#now = config.nowProvider; - } - - $bind(options?: AllureRuntimeBindOptions): AllureRuntime { + readonly #context: AllureRuntimeContext; + readonly #coreModule: runtimeModules.CoreModule; + readonly #basicStepsModule: runtimeModules.StepsModule; + readonly #contentAttachmentsModule: runtimeModules.ContentAttachmentsModule; + readonly #fileAttachmentsModule: runtimeModules.FileAttachmentsModule; + readonly #stepsDecorator: runtimeModules.StepsDecorator; + + constructor(context: AllureRuntimeContext) { + this.#context = context; + this.#coreModule = runtimeModules.CoreModule.create(context); + this.#basicStepsModule = runtimeModules.StepsModule.create(context); + this.#contentAttachmentsModule = + runtimeModules.ContentAttachmentsModule.create(context); + this.#fileAttachmentsModule = + runtimeModules.FileAttachmentsModule.create(context); + this.#stepsDecorator = new runtimeModules.StepsDecorator({ runtime: this }); + } + + $bind = (options?: AllureRuntimeBindOptions): AllureRuntime => { const { metadata = true, time = false } = options ?? {}; return new AllureRuntime({ - attachmentsHandler: this.#attachmentsHandler, - metadataProvider: metadata - ? constant(this.#metadata) - : this.#metadataProvider, - nowProvider: time ? constant(this.#now()) : this.#now, + ...this.#context, + getCurrentMetadata: metadata + ? constant(this.#context.getCurrentMetadata()) + : this.#context.getCurrentMetadata, + getNow: time ? constant(this.#context.getNow()) : this.#context.getNow, }); - } - - async flush(): Promise { - await this.#idle; - } - - description(value: string) { - this.#metadata.push(DESCRIPTION, [value]); - } - - descriptionHtml(value: string) { - this.#metadata.push(DESCRIPTION_HTML, [value]); - } - - label(name: LabelName | string, value: string) { - this.#metadata.push(LABELS, [{ name, value }]); - } - - link(url: string, name = url, type?: string) { - this.#metadata.push(LINKS, [{ name, url, type }]); - } - - parameter(name: string, value: unknown, options?: ParameterOptions) { - this.#metadata.push(this.#localPath('parameters'), [ - { - name, - value: `${value}`, - ...options, - }, - ]); - } - - parameters(parameters: Record) { - for (const [name, value] of Object.entries(parameters)) { - this.parameter(name, value); - } - } - - status(status?: Status | StatusDetails, statusDetails?: StatusDetails) { - this.#metadata.assign(this.#localPath(), { status, statusDetails }); - } + }; - statusDetails(statusDetails: StatusDetails) { - this.#metadata.assign(this.#localPath(), { statusDetails }); - } + $plug = (callback: AllureRuntimePluginCallback): this => { + callback({ + runtime: this, + contentAttachmentHandlers: this.#context.contentAttachmentHandlers, + fileAttachmentHandlers: this.#context.fileAttachmentHandlers, + inferMimeType: this.#context.inferMimeType, + }); - attachment( - name: string, - content: MaybePromise, - mimeType?: string, - ): typeof content { - return processMaybePromise( - content, - this.#handleDynamicAttachment({ name, mimeType }), - ); - } + return this; + }; - createAttachment( - function_: Function_>, - rawOptions: string | AttachmentOptions, - ): typeof function_ { - const options = this.#resolveAttachmentOptions(rawOptions); - return hijackFunction(function_, this.#handleDynamicAttachment(options)); - } + flush = () => this.#context.flush(); - fileAttachment( - filePathOrPromise: MaybePromise, - rawOptions?: string | AttachmentOptions, - ): any { - const options = this.#resolveAttachmentOptions(rawOptions); - return processMaybePromise( - filePathOrPromise, - this.#handleStaticAttachment(options), - ); - } + description: IAllureRuntime['description'] = (value) => { + this.#coreModule.description(value); + }; - createFileAttachment( - function_: Function_>, - rawOptions?: string | AttachmentOptions, - ): typeof function_ { - const options = this.#resolveAttachmentOptions(rawOptions); - return hijackFunction( - function_, - this.#handleStaticAttachment(options), - ); - } + descriptionHtml: IAllureRuntime['descriptionHtml'] = (value) => { + this.#coreModule.descriptionHtml(value); + }; - step(name: string, function_: () => T): T { - this.#startStep(name, function_); - const end = this.#stopStep; + label: IAllureRuntime['label'] = (name, value) => { + this.#coreModule.label(name, value); + }; - let result: T; - try { - result = function_(); + link: IAllureRuntime['link'] = (url, name, type) => { + this.#coreModule.link({ name, url, type }); + }; - if (isPromiseLike(result)) { - this.#updateStep('running'); + parameter: IAllureRuntime['parameter'] = (name, value, options) => { + this.#coreModule.parameter({ + name, + value: String(value), + ...options, + }); + }; - result.then( - () => end('passed'), - (error) => - end('failed', { message: error.message, trace: error.stack }), - ); + parameters: IAllureRuntime['parameters'] = (parameters) => { + for (const [name, value] of Object.entries(parameters)) { + if (value && typeof value === 'object') { + const raw = value as Parameter; + this.#coreModule.parameter({ ...raw, name }); } else { - end('passed'); + this.parameter(name, value); } + } + }; - return result; - } catch (error: unknown) { - end('failed', { - message: (error as Error).message, - trace: (error as Error).stack, - }); - throw error; + status: IAllureRuntime['status'] = (status, statusDetails) => { + this.#coreModule.status(status); + if (isObject(statusDetails)) { + this.#coreModule.statusDetails(statusDetails); } - } + }; + + statusDetails: IAllureRuntime['statusDetails'] = (statusDetails) => { + this.#coreModule.statusDetails(statusDetails || {}); + }; + + step: IAllureRuntime['step'] = (name, function_) => + this.#basicStepsModule.step(name, function_); - createStep( - nameFormat: string, - maybeParameters: F | ParameterOrString[], - maybeFunction?: F, - ): F { - const function_: any = maybeFunction ?? (maybeParameters as F); + // @ts-expect-error TS2322: too few arguments + createStep: IAllureRuntime['createStep'] = ( + nameFormat, + maybeParameters, + maybeFunction, + ) => { + const function_: any = maybeFunction ?? maybeParameters; if (typeof function_ !== 'function') { throw new TypeError( - `Expected a function, got ${typeof function_} instead`, + `Expected a function, got instead: ${util.inspect(function_)}`, ); } - const runtime = this; const userParameters = Array.isArray(maybeParameters) ? maybeParameters - : null; - - const handleArguments = (arguments_: IArguments) => { - const parameters = userParameters ?? Array.from(arguments_, noop); - const allureParameters = parameters.map(resolveParameter, arguments_); - for (const [name, value, options] of allureParameters) { - runtime.parameter(name, value, options); - } - }; - - return wrapFunction(function_, function (this: unknown) { - const arguments_ = arguments; - const name = formatString(nameFormat, ...arguments_); - return runtime.step( - name, - wrapFunction( - function_, - function step(this: unknown) { - handleArguments(arguments_); - return Reflect.apply(function_, this, arguments_); - }.bind(this), - ), - ); - }); - } + : undefined; - #startStep = (name: string, function_: Function) => { - const count = this.#metadata.get(this.#localPath('steps', 'length'), 0); - this.#metadata.push(this.#localPath('steps'), [ - { - name, - stage: 'scheduled', - start: this.#now(), - code: function_.toString(), - }, - ]); - // eslint-disable-next-line unicorn/no-array-push-push - this.#metadata.push(CURRENT_STEP, ['steps', `${count}`]); + return this.#stepsDecorator.createStep( + nameFormat, + function_, + userParameters, + ); }; - #stopStep = (status: Status, statusDetails?: StatusDetails) => { - const existing = this.#metadata.get(this.#localPath(), {} as any); - - this.#metadata.assign(this.#localPath(), { - stage: 'finished', - status: existing.status ?? status, - statusDetails: existing.statusDetails ?? statusDetails, - stop: this.#now(), + attachment: IAllureRuntime['attachment'] = (name, content, mimeType) => + this.#contentAttachmentsModule.attachment(content, { + name, + mimeType, }); - const currentStep = this.#metadata.get(CURRENT_STEP, []) as string[]; - this.#metadata.set(CURRENT_STEP, currentStep.slice(0, -2)); + // @ts-expect-error TS2322: is not assignable to type 'string' + fileAttachment: IAllureRuntime['fileAttachment'] = ( + filePath, + nameOrOptions, + ) => { + const options = + typeof nameOrOptions === 'string' + ? { name: nameOrOptions } + : { ...nameOrOptions }; + + return this.#fileAttachmentsModule.attachment(filePath, options); }; - #updateStep = (stage: Stage) => { - this.#metadata.set(this.#localPath('stage'), stage); - }; - - #localPath(key?: keyof AllureTestStepMetadata, ...innerKeys: string[]) { - const stepPath = this.#metadata.get(CURRENT_STEP, []) as string[]; - const allKeys = key ? [key, ...innerKeys] : innerKeys; - return [PREFIX, ...stepPath, ...allKeys]; - } - - #resolveAttachmentOptions(rawOptions?: string | AttachmentOptions) { - return !rawOptions || typeof rawOptions === 'string' - ? { name: rawOptions, mimeType: undefined } - : rawOptions; - } + createAttachment: IAllureRuntime['createAttachment'] = ( + function_, + nameOrOptions, + ) => { + const options = + typeof nameOrOptions === 'string' + ? { name: nameOrOptions } + : { ...nameOrOptions }; - #handleDynamicAttachment = - ({ name: nameFormat, mimeType }: AttachmentOptions) => - (content: AttachmentContent, arguments_?: unknown[]) => { - const name = this.#formatName(nameFormat, arguments_); - const source = this.#attachmentsHandler.placeAttachment(name, content); - this.#metadata.push(this.#localPath('attachments'), [ - { - name, - source: path.resolve(source), - type: mimeType ?? inferMimeType(name), - }, - ]); - const promise = this.#attachmentsHandler.writeAttachment(source, content); - this.#idle = this.#idle.then(() => promise); - }; - - #handleStaticAttachment = - ({ name: rawName, mimeType }: AttachmentOptions) => - (filePath: string, arguments_?: unknown[]) => { - const name = this.#formatName( - rawName ?? path.basename(filePath), - arguments_, - ); - const securedPath = this.#attachmentsHandler.placeAttachment(name); - const result = this.#attachmentsHandler.secureAttachment( - filePath, - securedPath, - ); - this.#metadata.push(this.#localPath('attachments'), [ - { - name, - source: path.resolve(result.destinationPath), - type: mimeType ?? inferMimeType(name, filePath), - }, - ]); - if (result.promise) { - this.#idle = this.#idle.then(() => result.promise); - } - }; - - #formatName(nameFormat?: string, arguments_?: unknown[]) { - return arguments_ - ? formatString(nameFormat ?? 'untitled', ...arguments_) - : nameFormat ?? 'untitled'; - } -} + return this.#contentAttachmentsModule.createAttachment(function_, options); + }; -function resolveParameter( - this: unknown[], - parameter: ParameterOrString | undefined, - index: number, -) { - const { name = `${index}`, ...options } = - typeof parameter === 'string' ? { name: parameter } : parameter ?? {}; + createFileAttachment: IAllureRuntime['createFileAttachment'] = ( + function_, + nameOrOptions, + ) => { + const options = + typeof nameOrOptions === 'string' + ? { name: nameOrOptions } + : { ...nameOrOptions }; - return [name, this[index], options] as const; + return this.#fileAttachmentsModule.createAttachment(function_, options); + }; } diff --git a/src/runtime/AllureRuntimeConfig.ts b/src/runtime/AllureRuntimeConfig.ts new file mode 100644 index 0000000..54298e3 --- /dev/null +++ b/src/runtime/AllureRuntimeConfig.ts @@ -0,0 +1,19 @@ +import type { GlobalMetadata, Metadata, TestFileMetadata } from 'jest-metadata'; + +import type { + ContentAttachmentHandler, + FileAttachmentHandler, + MIMEInferer, + SharedReporterConfig, +} from './types'; + +export interface AllureRuntimeConfig { + readonly contentAttachmentHandlers?: Record; + readonly fileAttachmentHandlers?: Record; + readonly inferMimeType?: MIMEInferer; + getReporterConfig(): SharedReporterConfig; + getCurrentMetadata(): Metadata; + getFileMetadata(): TestFileMetadata; + getGlobalMetadata(): GlobalMetadata; + getNow(): number; +} diff --git a/src/runtime/AllureRuntimeContext.ts b/src/runtime/AllureRuntimeContext.ts new file mode 100644 index 0000000..26ee6a0 --- /dev/null +++ b/src/runtime/AllureRuntimeContext.ts @@ -0,0 +1,72 @@ +import type { + AllureGlobalMetadata, + AllureTestFileMetadata, +} from 'jest-allure2-reporter'; + +import { type MaybeFunction, once } from '../utils'; +import { AllureMetadataProxy, AllureTestItemMetadataProxy } from '../metadata'; + +import type { AllureRuntimeConfig } from './AllureRuntimeConfig'; +import * as attachmentHandlers from './attachment-handlers'; +import type { + ContentAttachmentHandler, + FileAttachmentHandler, + MIMEInferer, + SharedReporterConfig, +} from './types'; + +export class AllureRuntimeContext { + readonly contentAttachmentHandlers: Record; + readonly fileAttachmentHandlers: Record; + readonly inferMimeType: MIMEInferer; + readonly getReporterConfig: () => SharedReporterConfig; + readonly getFileMetadata: () => AllureMetadataProxy; + readonly getGlobalMetadata: () => AllureMetadataProxy; + readonly getCurrentMetadata: () => AllureTestItemMetadataProxy; + readonly getNow: () => number; + + readonly flush: () => Promise; + readonly enqueueTask: (task: MaybeFunction>) => void; + + constructor(config: AllureRuntimeConfig) { + this.contentAttachmentHandlers = config.contentAttachmentHandlers ?? { + write: attachmentHandlers.writeHandler, + }; + this.fileAttachmentHandlers = config.fileAttachmentHandlers ?? { + copy: attachmentHandlers.copyHandler, + move: attachmentHandlers.moveHandler, + ref: attachmentHandlers.referenceHandler, + }; + this.inferMimeType = + config.inferMimeType ?? attachmentHandlers.inferMimeType; + this.getNow = config.getNow; + this.getReporterConfig = once(config.getReporterConfig); + this.getCurrentMetadata = () => + new AllureTestItemMetadataProxy(config.getCurrentMetadata()); + this.getFileMetadata = () => + new AllureMetadataProxy(config.getFileMetadata()); + this.getGlobalMetadata = () => + new AllureMetadataProxy(config.getGlobalMetadata()); + + let idle: Promise = Promise.resolve(); + this.flush = () => idle; + this.enqueueTask = (task) => { + idle = + typeof task === 'function' ? idle.then(task) : idle.then(() => task); + }; + + Object.defineProperty(this.contentAttachmentHandlers, 'default', { + get: () => { + const defaultName = this.getReporterConfig().attachments.contentHandler; + return this.contentAttachmentHandlers[defaultName]; + }, + }); + + Object.defineProperty(this.fileAttachmentHandlers, 'default', { + get: () => { + const defaultName = this.getReporterConfig().attachments.fileHandler; + return this.fileAttachmentHandlers[defaultName]; + }, + }); + } +} diff --git a/src/runtime/AttachmentsHandler.ts b/src/runtime/AttachmentsHandler.ts deleted file mode 100644 index 7c54b47..0000000 --- a/src/runtime/AttachmentsHandler.ts +++ /dev/null @@ -1,115 +0,0 @@ -import path from 'node:path'; -import { randomUUID } from 'node:crypto'; -import fs from 'node:fs'; -import os from 'node:os'; - -import type { SharedReporterConfig } from './SharedReporterConfig'; - -export interface IAttachmentsHandler { - placeAttachment(name: string, content?: Buffer | string): string; - - secureAttachment( - filePath: string, - content?: Buffer | string, - ): { - destinationPath: string; - promise?: Promise; - }; - - writeAttachment(filePath: string, content: Buffer | string): Promise; -} - -export class AttachmentsHandler implements IAttachmentsHandler { - #getSharedConfig: () => SharedReporterConfig; - - constructor(getSharedConfig: () => SharedReporterConfig | undefined) { - this.#getSharedConfig = () => - getSharedConfig() ?? { - resultsDir: path.join(os.tmpdir(), 'jest_allure2_reporter'), - overwrite: false, - injectGlobals: true, - attachments: { - subDir: 'attachments', - fileHandler: 'ref', - }, - }; - } - - placeAttachment(name: string): string { - const { resultsDir, attachments } = this.#getSharedConfig(); - const fileName = randomUUID() + path.extname(name); - return path.isAbsolute(attachments.subDir) - ? path.join(attachments.subDir, fileName) - : path.join(resultsDir, attachments.subDir, fileName); - } - - secureAttachment(sourcePath: string) { - const config = this.#getSharedConfig(); - const strategy = config.attachments.fileHandler; - const destinationPath = - strategy === 'ref' ? sourcePath : this.placeAttachment(sourcePath); - - switch (strategy) { - case 'copy': { - return { - destinationPath, - promise: this.#copyFile(sourcePath, destinationPath), - }; - } - case 'move': { - return { - destinationPath, - promise: this.#moveFile(sourcePath, destinationPath), - }; - } - default: { - return { - destinationPath: sourcePath, - }; - } - } - } - - async writeAttachment(filePath: string, content: Buffer | string) { - try { - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await fs.promises.writeFile(filePath, content); - } catch (error: any) { - console.warn( - `Failed to write attachment at: ${filePath}\nReason: ${ - error?.message ?? error - }`, - ); - } - } - - async #copyFile(sourcePath: string, destinationPath: string) { - try { - await fs.promises.mkdir(path.dirname(destinationPath), { - recursive: true, - }); - await fs.promises.copyFile(sourcePath, destinationPath); - } catch (error: any) { - console.warn( - `Failed to copy attachment from: ${sourcePath} to: ${destinationPath}\nReason: ${ - error?.message ?? error - }`, - ); - } - } - - async #moveFile(sourcePath: string, destinationPath: string) { - try { - await fs.promises.mkdir(path.dirname(destinationPath), { - recursive: true, - }); - await fs.promises.rename(sourcePath, destinationPath); - } catch (error: any) { - console.warn( - `Failed to move attachment from: ${sourcePath} to: ${destinationPath}\nReason: ${ - error?.message ?? error - }`, - ); - } - } -} diff --git a/src/runtime/SharedReporterConfig.ts b/src/runtime/SharedReporterConfig.ts deleted file mode 100644 index a96bbf1..0000000 --- a/src/runtime/SharedReporterConfig.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReporterConfig } from 'jest-allure2-reporter'; - -export type SharedReporterConfig = Pick< - ReporterConfig, - 'resultsDir' | 'overwrite' | 'attachments' | 'injectGlobals' ->; diff --git a/src/runtime/__snapshots__/AllureRuntime.test.ts.snap b/src/runtime/__snapshots__/AllureRuntime.test.ts.snap index 1451412..b668dfd 100644 --- a/src/runtime/__snapshots__/AllureRuntime.test.ts.snap +++ b/src/runtime/__snapshots__/AllureRuntime.test.ts.snap @@ -24,27 +24,9 @@ exports[`AllureRuntime should add attachments within the steps 1`] = ` "type": "text/plain", }, ], - "code": "async () => { - try { - runtime.step('inner step 1', () => { - runtime.attachment('attachment2', 'second', 'text/plain'); - const error = new Error('Sync error'); - error.stack = 'Test stack'; - throw error; - }); - } - catch { - /* empty */ - } - runtime.step('inner step 2', () => { - /* empty */ - }); - runtime.attachment('attachment3', 'third', 'text/plain'); - await innerStep3('fourth').catch(() => { - /* empty */ - }); - }", - "name": "outer step", + "description": [ + "outer step", + ], "stage": "finished", "start": 0, "status": "passed", @@ -58,13 +40,9 @@ exports[`AllureRuntime should add attachments within the steps 1`] = ` "type": "text/plain", }, ], - "code": "() => { - runtime.attachment('attachment2', 'second', 'text/plain'); - const error = new Error('Sync error'); - error.stack = 'Test stack'; - throw error; - }", - "name": "inner step 1", + "description": [ + "inner step 1", + ], "stage": "finished", "start": 1, "status": "failed", @@ -75,10 +53,9 @@ exports[`AllureRuntime should add attachments within the steps 1`] = ` "stop": 2, }, { - "code": "() => { - /* empty */ - }", - "name": "inner step 2", + "description": [ + "inner step 2", + ], "stage": "finished", "start": 3, "status": "passed", @@ -93,13 +70,9 @@ exports[`AllureRuntime should add attachments within the steps 1`] = ` "type": "text/plain", }, ], - "code": "async (message) => { - runtime.attachment('attachment4', message, 'text/plain'); - const error = new Error('Async error'); - error.stack = 'Test stack'; - throw error; - }", - "name": "inner step 3", + "description": [ + "inner step 3", + ], "parameters": [ { "name": "0", diff --git a/src/runtime/attachment-handlers/copyHandler.ts b/src/runtime/attachment-handlers/copyHandler.ts new file mode 100644 index 0000000..593b1a8 --- /dev/null +++ b/src/runtime/attachment-handlers/copyHandler.ts @@ -0,0 +1,13 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; + +import type { FileAttachmentHandler } from '../types'; + +import { placeAttachment } from './placeAttachment'; + +export const copyHandler: FileAttachmentHandler = async (context) => { + const destination = placeAttachment(context); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.copyFile(context.sourcePath, destination); + return destination; +}; diff --git a/src/runtime/attachment-handlers/index.ts b/src/runtime/attachment-handlers/index.ts new file mode 100644 index 0000000..aa7b8f3 --- /dev/null +++ b/src/runtime/attachment-handlers/index.ts @@ -0,0 +1,6 @@ +export * from './copyHandler'; +export * from './moveHandler'; +export * from './placeAttachment'; +export * from './referenceHandler'; +export * from './writeHandler'; +export * from './inferMimeType'; diff --git a/src/runtime/attachment-handlers/inferMimeType.ts b/src/runtime/attachment-handlers/inferMimeType.ts new file mode 100644 index 0000000..be8c26b --- /dev/null +++ b/src/runtime/attachment-handlers/inferMimeType.ts @@ -0,0 +1,35 @@ +import { extname } from 'node:path'; + +import type { MIMEInferer } from '../types'; + +export const inferMimeType: MIMEInferer = (context) => { + return context.sourcePath + ? mimeTypes[extname(context.sourcePath)] + : undefined; +}; + +const mimeTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.ogg': 'video/ogg', + + '.json': 'application/json', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.js': 'application/javascript', + + '.css': 'text/css', + '.html': 'text/html', + '.txt': 'text/plain', + '.csv': 'text/csv', + '.xml': 'text/xml', +}; diff --git a/src/runtime/attachment-handlers/moveHandler.ts b/src/runtime/attachment-handlers/moveHandler.ts new file mode 100644 index 0000000..ed2cb43 --- /dev/null +++ b/src/runtime/attachment-handlers/moveHandler.ts @@ -0,0 +1,13 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; + +import type { FileAttachmentHandler } from '../types'; + +import { placeAttachment } from './placeAttachment'; + +export const moveHandler: FileAttachmentHandler = async (context) => { + const destination = placeAttachment(context); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.rename(context.sourcePath, destination); + return destination; +}; diff --git a/src/runtime/attachment-handlers/placeAttachment.ts b/src/runtime/attachment-handlers/placeAttachment.ts new file mode 100644 index 0000000..7dbded9 --- /dev/null +++ b/src/runtime/attachment-handlers/placeAttachment.ts @@ -0,0 +1,15 @@ +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import type { + AttachmentContext, + ContentAttachmentContext, + FileAttachmentContext, +} from '../types'; + +export function placeAttachment(context: AttachmentContext): string { + const { outDir, name, sourcePath } = context as FileAttachmentContext & + ContentAttachmentContext; + const fileName = name || sourcePath || ''; + return path.join(outDir, randomUUID() + path.extname(fileName)); +} diff --git a/src/runtime/attachment-handlers/referenceHandler.ts b/src/runtime/attachment-handlers/referenceHandler.ts new file mode 100644 index 0000000..a2c3407 --- /dev/null +++ b/src/runtime/attachment-handlers/referenceHandler.ts @@ -0,0 +1,6 @@ +import path from 'node:path'; + +import type { FileAttachmentHandler } from '../types'; + +export const referenceHandler: FileAttachmentHandler = ({ sourcePath }) => + path.resolve(sourcePath); diff --git a/src/runtime/attachment-handlers/writeHandler.ts b/src/runtime/attachment-handlers/writeHandler.ts new file mode 100644 index 0000000..676c175 --- /dev/null +++ b/src/runtime/attachment-handlers/writeHandler.ts @@ -0,0 +1,13 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; + +import type { ContentAttachmentHandler } from '../types'; + +import { placeAttachment } from './placeAttachment'; + +export const writeHandler: ContentAttachmentHandler = async (context) => { + const destination = placeAttachment(context); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, context.content); + return destination; +}; diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 68b3165..0a691b7 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,10 +1,3 @@ -export { - IAllureRuntime, - ParameterOrString, - AttachmentOptions, - AttachmentContent, -} from './IAllureRuntime'; - +export * from './types'; export * from './AllureRuntime'; -export * from './AttachmentsHandler'; -export * from './SharedReporterConfig'; +export * from './AllureRuntimeContext'; diff --git a/src/runtime/modules/AttachmentsModule.ts b/src/runtime/modules/AttachmentsModule.ts new file mode 100644 index 0000000..39eb323 --- /dev/null +++ b/src/runtime/modules/AttachmentsModule.ts @@ -0,0 +1,192 @@ +import path from 'node:path'; + +import { formatString, hijackFunction, processMaybePromise } from '../../utils'; +import type { Function_, MaybePromise } from '../../utils'; +import type { + AttachmentContent, + AttachmentContext, + AttachmentHandler, + AttachmentOptions, + ContentAttachmentContext, + ContentAttachmentHandler, + ContentAttachmentOptions, + FileAttachmentContext, + FileAttachmentHandler, + FileAttachmentOptions, + MIMEInfererContext, +} from '../types'; +import type { AllureTestItemMetadataProxy } from '../../metadata'; +import type { AllureRuntimeContext } from '../AllureRuntimeContext'; + +export type AttachmentsModuleContext< + Context extends AttachmentContext, + Handler extends AttachmentHandler, +> = { + readonly handlers: Record; + readonly inferMimeType: (context: MIMEInfererContext) => string | undefined; + readonly metadata: AllureTestItemMetadataProxy; + readonly outDir: string; + readonly waitFor: (promise: Promise) => void; +}; + +abstract class AttachmentsModule< + Context extends AttachmentContext, + Content extends AttachmentContent, + Handler extends AttachmentHandler, + Options extends AttachmentOptions, +> { + constructor( + protected readonly context: AttachmentsModuleContext, + ) {} + + attachment( + content: MaybePromise, + options: Options, + ): typeof content { + if ( + typeof options.handler === 'string' && + !this.context.handlers[options.handler] + ) { + throw new Error(`Unknown attachment handler: ${options.handler}`); + } + + return processMaybePromise(content, this.#handleAttachment(options)); + } + + createAttachment( + function_: Function_>, + options: Options, + ): typeof function_ { + return hijackFunction(function_, this.#handleAttachment(options)); + } + + protected abstract _createMimeContext( + name: string, + content: Content, + ): MIMEInfererContext; + + protected abstract _createAttachmentContext( + context: AttachmentContext, + ): Context; + + #handleAttachment(userOptions: Options) { + return (userContent: Content, arguments_?: unknown[]) => { + const handler = this.#resolveHandler(userOptions); + const name = this.#formatName(userOptions.name, arguments_); + const mimeContext = this._createMimeContext(name, userContent); + const mimeType = + userOptions.mimeType ?? + this.context.inferMimeType(mimeContext) ?? + 'application/octet-stream'; + const context = this._createAttachmentContext({ + name, + mimeType, + outDir: this.context.outDir, + sourcePath: mimeContext.sourcePath, + content: mimeContext.content, + }); + const pushAttachment = this.#schedulePushAttachment(context); + this.context.waitFor( + Promise.resolve() + .then(() => handler(context)) + .then(pushAttachment), + ); + }; + } + + #resolveHandler(options: Options): Handler { + const handler = (options.handler ?? 'default') as string | Handler; + return typeof handler === 'function' + ? handler + : this.context.handlers[handler]; + } + + #schedulePushAttachment(context: Context) { + const metadata = this.context.metadata.$bind(); + return (destinationPath: string | undefined) => { + if (destinationPath) { + metadata.push('attachments', [ + { + name: context.name, + source: destinationPath, + type: context.mimeType, + }, + ]); + } + }; + } + + #formatName(nameFormat = 'untitled', arguments_?: unknown[]) { + return arguments_ ? formatString(nameFormat, ...arguments_) : nameFormat; + } +} + +export class FileAttachmentsModule extends AttachmentsModule< + FileAttachmentContext, + string, + FileAttachmentHandler, + FileAttachmentOptions +> { + static create(context: AllureRuntimeContext): FileAttachmentsModule { + return new FileAttachmentsModule({ + get handlers() { + return context.fileAttachmentHandlers; + }, + get inferMimeType() { + return context.inferMimeType; + }, + get metadata() { + return context.getCurrentMetadata(); + }, + get outDir() { + const config = context.getReporterConfig(); + return path.join(config.resultsDir, config.attachments.subDir); + }, + waitFor: context.enqueueTask, + }); + } + + protected _createMimeContext(_name: string, sourcePath: string) { + return { sourcePath }; + } + + protected _createAttachmentContext(context: AttachmentContext) { + // somewhat fragile - relying here on _createMimeContext output + return { sourcePath: context.sourcePath!, ...context }; + } +} + +export class ContentAttachmentsModule extends AttachmentsModule< + ContentAttachmentContext, + AttachmentContent, + ContentAttachmentHandler, + ContentAttachmentOptions +> { + static create(context: AllureRuntimeContext): ContentAttachmentsModule { + return new ContentAttachmentsModule({ + get handlers() { + return context.contentAttachmentHandlers; + }, + get inferMimeType() { + return context.inferMimeType; + }, + get metadata() { + return context.getCurrentMetadata(); + }, + get outDir() { + const config = context.getReporterConfig(); + return path.join(config.resultsDir, config.attachments.subDir); + }, + waitFor: context.enqueueTask, + }); + } + + protected _createMimeContext(name: string, content: AttachmentContent) { + return { sourcePath: name, content }; + } + + protected _createAttachmentContext(context: AttachmentContext) { + // somewhat fragile - relying here on _createMimeContext output + return { content: context.content!, ...context }; + } +} diff --git a/src/runtime/modules/CoreModule.ts b/src/runtime/modules/CoreModule.ts new file mode 100644 index 0000000..0158672 --- /dev/null +++ b/src/runtime/modules/CoreModule.ts @@ -0,0 +1,54 @@ +import type { + LabelName, + Link, + Parameter, + Status, + StatusDetails, +} from 'jest-allure2-reporter'; + +import type { AllureTestItemMetadataProxy } from '../../metadata'; +import type { AllureRuntimeContext } from '../AllureRuntimeContext'; + +export type CoreModuleContext = { + readonly metadata: AllureTestItemMetadataProxy; +}; + +export class CoreModule { + constructor(protected readonly context: CoreModuleContext) {} + + static create(context: AllureRuntimeContext): CoreModule { + return new CoreModule({ + get metadata() { + return context.getCurrentMetadata(); + }, + }); + } + + description(value: string) { + this.context.metadata.push('description', [value]); + } + + descriptionHtml(value: string) { + this.context.metadata.push('descriptionHtml', [value]); + } + + label(name: LabelName | string, value: string) { + this.context.metadata.push('labels', [{ name, value }]); + } + + link(link: Link) { + this.context.metadata.push('links', [link]); + } + + parameter(parameter: Parameter) { + this.context.metadata.push('parameters', [parameter]); + } + + status(status: Status) { + this.context.metadata.set('status', status); + } + + statusDetails(statusDetails: StatusDetails) { + this.context.metadata.set('statusDetails', statusDetails); + } +} diff --git a/src/runtime/modules/StepsDecorator.ts b/src/runtime/modules/StepsDecorator.ts new file mode 100644 index 0000000..57dff5a --- /dev/null +++ b/src/runtime/modules/StepsDecorator.ts @@ -0,0 +1,60 @@ +import type { Function_ } from '../../utils'; +import { formatString, wrapFunction } from '../../utils'; +import type { IAllureRuntime, ParameterOrString } from '../types'; + +export type FunctionalStepsModuleContext = { + runtime: Pick; +}; + +export class StepsDecorator { + constructor(protected readonly context: FunctionalStepsModuleContext) {} + + createStep>( + nameFormat: string, + function_: F, + userParameters?: ParameterOrString[], + ): F { + const runtime = this.context.runtime; + const handleArguments = (arguments_: IArguments) => { + const parameters = + userParameters ?? + (Array.from(arguments_, noop) as (ParameterOrString | undefined)[]); + + const allureParameters = parameters.map(resolveParameter, arguments_); + + for (const [name, value, options] of allureParameters) { + runtime.parameter(name, value, options); + } + }; + + return wrapFunction(function_, function (this: unknown): T { + const arguments_ = arguments; + const name = formatString(nameFormat, ...arguments_); + return runtime.step( + name, + wrapFunction( + function_, + function step(this: unknown) { + handleArguments(arguments_); + return Reflect.apply(function_, this, arguments_); + }.bind(this), + ), + ); + } as F); + } +} + +function resolveParameter( + this: unknown[], + parameter: ParameterOrString | undefined, + index: number, +) { + const { name = `${index}`, ...options } = + typeof parameter === 'string' ? { name: parameter } : parameter ?? {}; + + return [name, this[index], options] as const; +} + +function noop() { + /* no-op */ +} diff --git a/src/runtime/modules/StepsModule.ts b/src/runtime/modules/StepsModule.ts new file mode 100644 index 0000000..e436c7d --- /dev/null +++ b/src/runtime/modules/StepsModule.ts @@ -0,0 +1,84 @@ +import type { + AllureTestCaseMetadata, + Status, + StatusDetails, +} from 'jest-allure2-reporter'; + +import { isPromiseLike } from '../../utils'; +import type { AllureTestItemMetadataProxy } from '../../metadata'; +import type { AllureRuntimeContext } from '../AllureRuntimeContext'; + +export type BasicStepsModuleContext = { + readonly metadata: AllureTestItemMetadataProxy; + readonly now: number; +}; + +export class StepsModule { + constructor(protected readonly context: BasicStepsModuleContext) {} + + static create(context: AllureRuntimeContext): StepsModule { + return new StepsModule({ + get metadata() { + return context.getCurrentMetadata(); + }, + get now() { + return context.getNow(); + }, + }); + } + + step(name: string, function_: () => T): T { + this.#startStep(name); + const end = this.#stopStep; + + let result: T; + try { + result = function_(); + + if (isPromiseLike(result)) { + this.context.metadata.set('stage', 'running'); + + result.then( + () => end('passed'), + (error) => + end('failed', { message: error.message, trace: error.stack }), + ); + } else { + end('passed'); + } + + return result; + } catch (error: unknown) { + end('failed', { + message: (error as Error).message, + trace: (error as Error).stack, + }); + throw error; + } + } + + #startStep = (name: string) => { + this.context.metadata.$startStep().assign({ + stage: 'scheduled', + start: this.context.now, + description: [name], + }); + }; + + #stopStep = (status: Status, statusDetails?: StatusDetails) => { + const metadata = this.context.metadata; + const existing = metadata.get>( + undefined, + {}, + ); + + metadata + .assign({ + stage: 'finished', + status: existing.status ?? status, + statusDetails: existing.statusDetails ?? statusDetails, + stop: this.context.now, + }) + .$stopStep(); + }; +} diff --git a/src/runtime/modules/index.ts b/src/runtime/modules/index.ts new file mode 100644 index 0000000..7756612 --- /dev/null +++ b/src/runtime/modules/index.ts @@ -0,0 +1,4 @@ +export * from './AttachmentsModule'; +export * from './CoreModule'; +export * from './StepsDecorator'; +export * from './StepsModule'; diff --git a/src/runtime/IAllureRuntime.ts b/src/runtime/types.ts similarity index 50% rename from src/runtime/IAllureRuntime.ts rename to src/runtime/types.ts index fe43ba3..74ffb70 100644 --- a/src/runtime/IAllureRuntime.ts +++ b/src/runtime/types.ts @@ -1,4 +1,7 @@ +import type { AllureGlobalMetadata } from 'jest-allure2-reporter'; import type { + BuiltinFileAttachmentHandler, + BuiltinContentAttachmentHandler, LabelName, LinkType, Parameter, @@ -6,7 +9,7 @@ import type { StatusDetails, } from 'jest-allure2-reporter'; -import type { Function_, MaybePromise } from '../utils/types'; +import type { Function_, MaybePromise } from '../utils'; export interface IAllureRuntime { /** @@ -15,6 +18,12 @@ export interface IAllureRuntime { */ $bind(options?: AllureRuntimeBindOptions): IAllureRuntime; + /** + * Attach a runtime plugin using a callback. + * The callback will be called with the runtime plugin context. + */ + $plug(callback: AllureRuntimePluginCallback): void; + description(value: string): void; descriptionHtml(value: string): void; @@ -49,30 +58,27 @@ export interface IAllureRuntime { F extends Function_>, >( function_: F, - options: AttachmentOptions, + options: ContentAttachmentOptions, ): typeof function_; fileAttachment(filePath: string, name?: string): string; - fileAttachment(filePath: string, options?: AttachmentOptions): string; + fileAttachment(filePath: string, options?: FileAttachmentOptions): string; fileAttachment( filePathPromise: Promise, name?: string, ): Promise; fileAttachment( filePathPromise: Promise, - options?: AttachmentOptions, + options?: FileAttachmentOptions, ): Promise; createFileAttachment>>( function_: F, + name?: string, ): F; createFileAttachment>>( function_: F, - name: string, - ): F; - createFileAttachment>>( - function_: F, - options: AttachmentOptions, + options?: FileAttachmentOptions, ): F; createStep(name: string, function_: F): F; @@ -85,13 +91,35 @@ export interface IAllureRuntime { step(name: string, function_: () => T): T; } -export type AttachmentContent = Buffer | string; +export type SharedReporterConfig = AllureGlobalMetadata['config']; + +export type AllureRuntimePluginCallback = ( + context: AllureRuntimePluginContext, +) => void; + +export interface AllureRuntimePluginContext { + readonly runtime: IAllureRuntime; + readonly contentAttachmentHandlers: Record< + BuiltinContentAttachmentHandler | 'default' | string, + ContentAttachmentHandler + >; + readonly fileAttachmentHandlers: Record< + BuiltinFileAttachmentHandler | 'default' | string, + FileAttachmentHandler + >; + inferMimeType: MIMEInferer; +} -export type AttachmentOptions = { +export type AttachmentOptions = { name?: string; mimeType?: string; + handler?: string | AttachmentHandler; }; +export type FileAttachmentOptions = AttachmentOptions; +export type ContentAttachmentOptions = + AttachmentOptions & { name: string }; + export type ParameterOrString = string | Omit; export type ParameterOptions = Pick; @@ -102,3 +130,37 @@ export type AllureRuntimeBindOptions = { /** @default false */ time?: boolean; }; + +export interface AttachmentContext { + name: string; + mimeType: string; + outDir: string; + sourcePath?: string; + content?: AttachmentContent; +} + +export interface FileAttachmentContext extends AttachmentContext { + sourcePath: string; +} + +export interface ContentAttachmentContext extends AttachmentContext { + content: AttachmentContent; +} + +export type AttachmentContent = Buffer | string; + +export type AttachmentHandler = ( + context: Readonly, +) => MaybePromise; + +export type FileAttachmentHandler = AttachmentHandler; + +export type ContentAttachmentHandler = + AttachmentHandler; + +export type MIMEInferer = (context: MIMEInfererContext) => string | undefined; + +export interface MIMEInfererContext { + sourcePath?: string; + content?: AttachmentContent; +} diff --git a/src/utils/constant.ts b/src/utils/constant.ts new file mode 100644 index 0000000..63382d2 --- /dev/null +++ b/src/utils/constant.ts @@ -0,0 +1,4 @@ +export const constant = + (value: T) => + () => + value; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..cbc041b --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,14 @@ +export * from './attempt'; +export * from './constant'; +export * from './formatString'; +export * from './hijackFunction'; +export * from './isObject'; +export * from './isError'; +export * from './isPromiseLike'; +export * from './once'; +export * from './md5'; +export * from './processMaybePromise'; +export * from './shallowEqualArrays'; +export * from './splitDocblock'; +export * from './wrapFunction'; +export * from './types'; diff --git a/src/utils/inferMimeType.ts b/src/utils/inferMimeType.ts deleted file mode 100644 index 10e5ff8..0000000 --- a/src/utils/inferMimeType.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Infers the mime type of a file based on its extension. - * If file has multiple paths, the first one with a known mime type is used. - * @param filePath Path to the file. - * @returns The mime type of the file or `application/octet-stream` if the mime type could not be inferred. - */ -export function inferMimeType(...filePaths: string[]): string { - for (const filePath of filePaths) { - const mimeType = inferMimeType1(filePath); - if (mimeType) return mimeType; - } - - return 'application/octet-stream'; -} - -function inferMimeType1(filePath: string): string { - const extension = filePath.split('.').pop()!; - return mimeTypes[extension]; -} - -const mimeTypes: Record = { - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - svg: 'image/svg+xml', - webp: 'image/webp', - - mp4: 'video/mp4', - webm: 'video/webm', - ogg: 'video/ogg', - - json: 'application/json', - pdf: 'application/pdf', - zip: 'application/zip', - tar: 'application/x-tar', - gz: 'application/gzip', - js: 'application/javascript', - - css: 'text/css', - html: 'text/html', - txt: 'text/plain', - csv: 'text/csv', - xml: 'text/xml', -}; diff --git a/src/utils/isEmptyObject.ts b/src/utils/isEmptyObject.ts deleted file mode 100644 index af7d112..0000000 --- a/src/utils/isEmptyObject.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function isEmptyObject(value: unknown) { - return value && typeof value === 'object' && Object.keys(value).length === 0; -} diff --git a/src/utils/isObject.ts b/src/utils/isObject.ts new file mode 100644 index 0000000..9039f14 --- /dev/null +++ b/src/utils/isObject.ts @@ -0,0 +1,3 @@ +export function isObject(value: unknown): value is Record { + return Boolean(value && typeof value === 'object'); +} diff --git a/src/utils/md5.ts b/src/utils/md5.ts index a1fc4ae..f66ce73 100644 --- a/src/utils/md5.ts +++ b/src/utils/md5.ts @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -export default function md5(value: string): string { +export function md5(value: string): string { return crypto.createHash('md5').update(value).digest('hex'); } diff --git a/src/utils/once.ts b/src/utils/once.ts new file mode 100644 index 0000000..cf8398b --- /dev/null +++ b/src/utils/once.ts @@ -0,0 +1,15 @@ +export function once T>( + function_: F, +): F { + let result: T; + let called = false; + + return function (this: unknown, ...arguments_: unknown[]) { + if (!called) { + called = true; + result = function_.apply(this, arguments_); + } + + return result; + } as F; +} diff --git a/src/utils/types.ts b/src/utils/types.ts index 577afbd..ca1a5b1 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,3 +1,4 @@ export type MaybePromise = T | Promise; +export type MaybeFunction = T | Function_; -export type Function_ = (...arguments_: any[]) => T; +export type Function_ = (...arguments_: any[]) => T; diff --git a/tsconfig.json b/tsconfig.json index 2f897d6..c990930 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, + "experimentalDecorators": true, "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, diff --git a/website/scripts/screenshots.mjs b/website/scripts/screenshots.mjs index a22e7a8..52377dc 100644 --- a/website/scripts/screenshots.mjs +++ b/website/scripts/screenshots.mjs @@ -1,4 +1,4 @@ -import path from 'path'; +import path from 'node:path'; import puppeteer from 'puppeteer'; const browser = await puppeteer.launch();