diff --git a/index.d.ts b/index.d.ts index 32aa576..c65b152 100644 --- a/index.d.ts +++ b/index.d.ts @@ -86,6 +86,12 @@ declare module 'jest-allure2-reporter' { * Local runs won't have any executor information unless you customize this. */ executor?: ExecutorInfo | ExecutorCustomizer; + /** + * Customize how to report test files as pseudo-test cases. + * This is normally used to report broken test files, so that you can be aware of them, + * but advanced users may find other use cases. + */ + testFile?: Partial; /** * Customize how test cases are reported: names, descriptions, labels, status, etc. */ @@ -108,6 +114,7 @@ declare module 'jest-allure2-reporter' { categories: CategoriesCustomizer; environment: EnvironmentCustomizer; executor: ExecutorCustomizer; + testFile: ResolvedTestFileCustomizer; testCase: ResolvedTestCaseCustomizer; testStep: ResolvedTestStepCustomizer; plugins: Promise; @@ -118,6 +125,21 @@ declare module 'jest-allure2-reporter' { 'resultsDir' | 'overwrite' | 'attachments' >; + export type _LabelName = + | 'package' + | 'testClass' + | 'testMethod' + | 'parentSuite' + | 'suite' + | 'subSuite' + | 'epic' + | 'feature' + | 'story' + | 'thread' + | 'severity' + | 'tag' + | 'owner'; + export type AttachmentsOptions = { /** * Defines a subdirectory within the {@link ReporterOptions#resultsDir} where attachments will be stored. @@ -200,7 +222,12 @@ declare module 'jest-allure2-reporter' { * subSuite: ({ test }) => test.ancestorTitles[0], * } */ - labels: LabelsCustomizer; + labels: + | TestCaseExtractor + | Record< + _LabelName | string, + TestCaseExtractor + >; /** * Resolve issue links for the test case. * @@ -213,7 +240,9 @@ declare module 'jest-allure2-reporter' { * }), * } */ - links: LinksCustomizer; + links: + | TestCaseExtractor + | Record>; /** * Customize step or test case attachments. */ @@ -224,6 +253,112 @@ declare module 'jest-allure2-reporter' { parameters: TestCaseExtractor; } + /** + * Global customizations for how test files are reported (as pseudo-test cases). + */ + export interface TestFileCustomizer { + /** + * Extractor to omit test cases from the report. + */ + ignored: TestFileExtractor; + /** + * Test file ID extractor to fine-tune Allure's history feature. + * @default ({ filePath }) => filePath.join('/') + * @example ({ package, filePath }) => `${package.name}:${filePath.join('/')}` + * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/history/#test-case-id + */ + historyId: TestFileExtractor; + /** + * Extractor for test file name + * @default ({ filePath }) => filePath.at(-1) + */ + name: TestFileExtractor; + /** + * Extractor for the full test file name + * @default ({ testFile }) => testFile.testFilePath + */ + fullName: TestFileExtractor; + /** + * Extractor for the test file start timestamp. + */ + start: TestFileExtractor; + /** + * Extractor for the test file stop timestamp. + */ + stop: TestFileExtractor; + /** + * Extractor for the test file description. + */ + description: TestFileExtractor; + /** + * Extractor for the test file description in HTML format. + */ + descriptionHtml: TestFileExtractor; + /** + * Extractor for the test file stage. + */ + stage: TestFileExtractor; + /** + * Extractor for the test file status. + * @see https://wix-incubator.github.io/jest-allure2-reporter/docs/config/statuses/ + * @example ({ value }) => value === 'broken' ? 'failed' : value + */ + status: TestFileExtractor; + /** + * Extractor for the test file status details. + */ + statusDetails: TestFileExtractor; + /** + * Customize Allure labels for the test file. + * + * @example + * { + * suite: ({ file }) => file.path, + * subSuite: ({ test }) => test.ancestorTitles[0], + * } + */ + labels: + | TestFileExtractor + | Record< + _LabelName | string, + TestFileExtractor + >; + /** + * Resolve issue links for the test file. + * + * @example + * { + * issue: ({ value }) => ({ + * type: 'issue', + * name: value.name ?? `Open ${value.url} in JIRA`, + * url: `https://jira.company.com/${value.url}`, + * }), + * } + */ + links: + | TestFileExtractor + | Record>; + /** + * Customize test file attachments. + */ + attachments: TestFileExtractor; + /** + * Customize test case parameters. + */ + parameters: TestFileExtractor; + } + + /** + * Global customizations for how test cases are reported + * @inheritDoc + */ + export type TestCaseCustomizer = GenericTestCaseCustomizer; + + export type ResolvedTestFileCustomizer = Required & { + labels: TestFileExtractor; + links: TestFileExtractor; + }; + export type ResolvedTestCaseCustomizer = Required & { labels: TestCaseExtractor; links: TestCaseExtractor; @@ -277,39 +412,11 @@ declare module 'jest-allure2-reporter' { export type CategoriesCustomizer = GlobalExtractor; - export type LinksCustomizer = - | TestCaseExtractor - | Record>; - - export type LabelsCustomizer = - | TestCaseExtractor - | Partial<{ - readonly package: LabelConfig; - readonly testClass: LabelConfig; - readonly testMethod: LabelConfig; - readonly parentSuite: LabelConfig; - readonly suite: LabelConfig; - readonly subSuite: LabelConfig; - readonly epic: LabelConfig; - readonly feature: LabelConfig; - readonly story: LabelConfig; - readonly thread: LabelConfig; - readonly severity: LabelConfig; - readonly tag: LabelConfig; - readonly owner: LabelConfig; - - readonly [key: string]: LabelConfig; - }>; - - export type LabelConfig = LabelValue | LabelExtractor; - - export type LabelValue = string | string[]; - - export type LabelExtractor = TestCaseExtractor; - - export type Extractor, R = T> = ( - context: Readonly, - ) => R | undefined; + export type Extractor< + T = unknown, + C extends ExtractorContext = ExtractorContext, + R = T, + > = (context: Readonly) => R | undefined; export type GlobalExtractor = Extractor< T, @@ -317,6 +424,12 @@ declare module 'jest-allure2-reporter' { R >; + export type TestFileExtractor = Extractor< + T, + TestFileExtractorContext, + R + >; + export type TestCaseExtractor = Extractor< T, TestCaseExtractorContext, @@ -333,28 +446,29 @@ 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; } - export interface TestCaseExtractorContext + export interface TestCaseExtractorContext extends TestFileExtractorContext, TestCaseExtractorContextAugmentation { testCase: TestCaseResult; testCaseMetadata: AllureTestCaseMetadata; } - export interface TestStepExtractorContext + export interface TestStepExtractorContext extends TestCaseExtractorContext, TestStepExtractorContextAugmentation { testStepMetadata: AllureTestStepMetadata; @@ -401,7 +515,12 @@ declare module 'jest-allure2-reporter' { links?: Link[]; } + export interface AllureTestFileMetadata extends AllureTestCaseMetadata { + currentStep?: never; + } + export interface GlobalExtractorContextAugmentation { + detectLanguage?(filePath: string, contents: string): string | undefined; processMarkdown?(markdown: string): MaybePromise; // This should be extended by plugins @@ -442,26 +561,26 @@ declare module 'jest-allure2-reporter' { extend?(previous: Plugin): Plugin; /** Method to extend global context. */ - globalContext?(context: GlobalExtractorContext): void | Promise; + globalContext?(context: GlobalExtractorContext): void | Promise; - /** Method to extend test file context. */ - testFileContext?( - context: TestFileExtractorContext, + /** 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; + /** Method to extend test entry context. */ - testCaseContext?( - context: TestCaseExtractorContext, - ): void | Promise; + testCaseContext?(context: TestCaseExtractorContext): void | Promise; /** Method to extend test step context. */ - testStepContext?( - context: TestStepExtractorContext, - ): void | Promise; + testStepContext?(context: TestStepExtractorContext): void | Promise; } export type PluginHookName = | 'globalContext' + | 'beforeTestFileContext' | 'testFileContext' | 'testCaseContext' | 'testStepContext'; diff --git a/src/builtin-plugins/detect.ts b/src/builtin-plugins/detect.ts new file mode 100644 index 0000000..0c9d7a7 --- /dev/null +++ b/src/builtin-plugins/detect.ts @@ -0,0 +1,34 @@ +/// + +import path from 'node:path'; + +import type { Plugin, PluginConstructor } from 'jest-allure2-reporter'; + +export const detectPlugin: PluginConstructor = () => { + const plugin: Plugin = { + name: 'jest-allure2-reporter/plugins/detect', + async globalContext(context) { + context.detectLanguage = (filePath) => { + switch (path.extname(filePath)) { + case '.js': + case '.jsx': + case '.cjs': + case '.mjs': { + return 'javascript'; + } + case '.ts': + case '.tsx': + case '.cts': + case '.mts': { + return 'typescript'; + } + default: { + return ''; + } + } + }; + }, + }; + + return plugin; +}; diff --git a/src/builtin-plugins/docblock.ts b/src/builtin-plugins/docblock.ts new file mode 100644 index 0000000..d84376d --- /dev/null +++ b/src/builtin-plugins/docblock.ts @@ -0,0 +1,65 @@ +/* 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 '@noomorph/allure-js-commons'; + +import { CODE, DESCRIPTION, LABELS } from '../constants'; +import { splitDocblock } from '../utils/splitDocblock'; + +type ParseWithComments = typeof import('jest-docblock').parseWithComments; + +function mergeDocumentBlocks( + parseWithComments: ParseWithComments, + metadata: Metadata, + codeDefault = '', +) { + 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 (pragmas) { + const labels = Object.entries(pragmas).flatMap(createLabel); + metadata.unshift(LABELS, labels); + } + } +} + +function createLabel(entry: [string, string | string[]]): Label | Label[] { + const [name, value] = entry; + return Array.isArray(value) + ? value.map((v) => ({ name, value: v })) + : { name, value }; +} + +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; + } + } + }, + }; + + return plugin; +}; diff --git a/src/builtin-plugins/index.ts b/src/builtin-plugins/index.ts index 613b0da..c5a6c74 100644 --- a/src/builtin-plugins/index.ts +++ b/src/builtin-plugins/index.ts @@ -1,4 +1,5 @@ -export { jsdocPlugin as jsdoc } from './jsdoc'; +export { detectPlugin as detect } from './detect'; +export { docblockPlugin as docblock } from './docblock'; export { manifestPlugin as manifest } from './manifest'; export { prettierPlugin as prettier } from './prettier'; export { remarkPlugin as remark } from './remark'; diff --git a/src/builtin-plugins/jsdoc.ts b/src/builtin-plugins/jsdoc.ts deleted file mode 100644 index a3c0c32..0000000 --- a/src/builtin-plugins/jsdoc.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-imports,import/no-extraneous-dependencies */ -import type { Plugin, PluginConstructor } from 'jest-allure2-reporter'; -import { state } from 'jest-metadata'; -import type { Metadata } from 'jest-metadata'; -import type { Label } from '@noomorph/allure-js-commons'; - -import { DOCBLOCK, DESCRIPTION, LABELS } from '../constants'; - -type ParseWithComments = typeof import('jest-docblock').parseWithComments; - -function mergeJsDocument( - parseWithComments: ParseWithComments, - metadata: Metadata, -) { - const jsdoc = metadata.get(DOCBLOCK, ''); - if (jsdoc) { - const { comments, pragmas } = parseWithComments(jsdoc); - if (comments) { - metadata.unshift(DESCRIPTION, [comments]); - } - - if (pragmas) { - const labels = Object.entries(pragmas).map(createLabel); - metadata.unshift(LABELS, labels); - } - } -} - -function createLabel(entry: [string, string]): Label { - const [name, value] = entry; - return { name, value }; -} - -export const jsdocPlugin: PluginConstructor = () => { - const plugin: Plugin = { - name: 'jest-allure2-reporter/plugins/jsdoc', - async globalContext() { - try { - const { parseWithComments } = await import('jest-docblock'); - for (const testFileMetadata of state.testFiles) { - for (const testEntryMetadata of testFileMetadata.allTestEntries()) { - mergeJsDocument(parseWithComments, testEntryMetadata); - } - } - } catch (error: any) { - if (error?.code !== 'MODULE_NOT_FOUND') { - throw error; - } - } - }, - }; - - return plugin; -}; diff --git a/src/environment/decorator.ts b/src/environment/decorator.ts index 252c0d3..76de3b2 100644 --- a/src/environment/decorator.ts +++ b/src/environment/decorator.ts @@ -10,9 +10,8 @@ import type { AllureTestStepMetadata, } from 'jest-allure2-reporter'; -import { PREFIX, WORKER_ID } from '../constants'; +import { CODE, PREFIX, WORKER_ID } from '../constants'; import realm from '../realms'; -import { splitDocblock } from '../utils/splitDocblock'; export function WithAllure2( JestEnvironmentClass: new (...arguments_: any[]) => E, @@ -22,9 +21,6 @@ export function WithAllure2( return { // @ts-expect-error TS2415: Class '[`${compositeName}`]' incorrectly extends base class 'E'. [`${compositeName}`]: class extends JestEnvironmentClass { - // @ts-expect-error TS2564 - private readonly allure: AllureRuntime; - constructor(...arguments_: any[]) { super(...arguments_); @@ -70,13 +66,7 @@ export function WithAllure2( #addTest({ event, }: ForwardedCircusEvent) { - const [docblock, code] = splitDocblock(event.fn.toString()); - const metadata: Record = { - code, - docblock, - }; - - state.currentMetadata.assign(PREFIX, metadata); + state.currentMetadata.set(CODE, event.fn.toString()); } // eslint-disable-next-line no-empty-pattern diff --git a/src/metadata/MetadataSquasher.ts b/src/metadata/MetadataSquasher.ts index 48ae886..0ff4e77 100644 --- a/src/metadata/MetadataSquasher.ts +++ b/src/metadata/MetadataSquasher.ts @@ -1,54 +1,50 @@ import type { - GlobalMetadata, DescribeBlockMetadata, - TestFileMetadata, + GlobalMetadata, + HookInvocationMetadata, TestEntryMetadata, + TestFileMetadata, TestFnInvocationMetadata, TestInvocationMetadata, - HookInvocationMetadata, } from 'jest-metadata'; -import type { AllureTestCaseMetadata } from 'jest-allure2-reporter'; - -import { WORKER_ID } from '../constants'; +import type { + AllureTestFileMetadata, + AllureTestCaseMetadata, +} from 'jest-allure2-reporter'; import { chain, chainLast, extractCode, getStart, getStop } from './utils'; export class MetadataSquasher { - protected readonly testInvocationConfig: MetadataSquasherConfig; - - constructor() { - this.testInvocationConfig = MetadataSquasher.flatConfig(); - } - - 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()], + 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']), }; - for (const key of keys) { - result[key] = config[key](context, key); - } - - return result as AllureTestCaseMetadata; - } - - private static flatConfig(): MetadataSquasherConfig { - return { + protected readonly testInvocationConfig: MetadataSquasherConfig = + { code: extractCode, - workerId: ({ testFile }) => { - return testFile?.get(WORKER_ID) as string; - }, - description: chain(['testEntry', 'testInvocation', 'testFnInvocation']), + workerId: chainLast(['testFile']), + description: chain([ + 'globalMetadata', + 'testFile', + 'testEntry', + 'testInvocation', + 'testFnInvocation', + ]), descriptionHtml: chain([ + 'globalMetadata', + 'testFile', 'testEntry', 'testInvocation', 'testFnInvocation', @@ -76,6 +72,42 @@ export class MetadataSquasher { 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; + } + + 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()], + }; + + for (const key of keys) { + result[key] = config[key](context, key); + } + + return result as AllureTestCaseMetadata; } } @@ -88,12 +120,12 @@ export type MetadataSquasherMapping = ( key: K, ) => T[K]; -export type MetadataSquasherContext = Partial<{ +export type MetadataSquasherContext = { globalMetadata: GlobalMetadata; testFile: TestFileMetadata; - describeBlock: DescribeBlockMetadata[]; - testEntry: TestEntryMetadata; - testInvocation: TestInvocationMetadata; - testFnInvocation: (HookInvocationMetadata | TestFnInvocationMetadata)[]; - anyInvocation: (HookInvocationMetadata | TestFnInvocationMetadata)[]; -}>; + describeBlock?: DescribeBlockMetadata[]; + testEntry?: TestEntryMetadata; + testInvocation?: TestInvocationMetadata; + testFnInvocation?: (HookInvocationMetadata | TestFnInvocationMetadata)[]; + anyInvocation?: (HookInvocationMetadata | TestFnInvocationMetadata)[]; +}; diff --git a/src/options/aggregateLinkCustomizers.ts b/src/options/aggregateLinkCustomizers.ts deleted file mode 100644 index bc65bc7..0000000 --- a/src/options/aggregateLinkCustomizers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Link } from '@noomorph/allure-js-commons'; -import type { - LinksCustomizer, - TestCaseExtractor, - TestCaseExtractorContext, -} from 'jest-allure2-reporter'; - -export function aggregateLinkCustomizers( - links: LinksCustomizer | undefined, -): TestCaseExtractor | undefined { - if (!links || typeof links === 'function') { - return links; - } - - return (context: TestCaseExtractorContext) => { - return context.value - ?.map((link) => { - const extractor = links[link.type ?? '']; - return extractor ? extractor({ ...context, value: link }) : link; - }) - ?.filter(Boolean) as Link[] | undefined; - }; -} diff --git a/src/options/compose-options/attachments.ts b/src/options/compose-options/attachments.ts new file mode 100644 index 0000000..b4c7323 --- /dev/null +++ b/src/options/compose-options/attachments.ts @@ -0,0 +1,15 @@ +import type { AttachmentsOptions } from 'jest-allure2-reporter'; + +export function composeAttachments( + base: Required, + custom: AttachmentsOptions | undefined, +): Required { + if (!custom) { + return base; + } + + return { + subDir: custom?.subDir ?? base.subDir, + fileHandler: custom?.fileHandler ?? base.fileHandler, + }; +} diff --git a/src/options/compose-options/index.ts b/src/options/compose-options/index.ts new file mode 100644 index 0000000..88471ca --- /dev/null +++ b/src/options/compose-options/index.ts @@ -0,0 +1,48 @@ +import type { PluginContext } from 'jest-allure2-reporter'; +import type { + ReporterOptions, + ReporterConfig, + TestStepCustomizer, +} from 'jest-allure2-reporter'; + +import { asExtractor, composeExtractors } from '../utils'; + +import { composeAttachments } from './attachments'; +import { composePlugins } from './plugins'; +import { composeTestCaseCustomizers } from './testCase'; +import { composeTestFileCustomizers } from './testFile'; +import { composeTestStepCustomizers } from './testStep'; + +export function composeOptions( + context: PluginContext, + base: ReporterConfig, + custom: ReporterOptions | undefined, +): ReporterConfig { + if (!custom) { + return base; + } + + return { + ...custom, + + overwrite: custom.overwrite ?? base.overwrite, + resultsDir: custom.resultsDir ?? base.resultsDir, + attachments: composeAttachments(base.attachments, custom.attachments), + testCase: composeTestCaseCustomizers(base.testCase, custom.testCase), + testFile: composeTestFileCustomizers(base.testFile, custom.testFile), + testStep: composeTestStepCustomizers( + base.testStep as TestStepCustomizer, + custom.testStep, + ), + environment: composeExtractors( + asExtractor(custom.environment), + base.environment, + ), + executor: composeExtractors(asExtractor(custom.executor), base.executor), + categories: composeExtractors( + asExtractor(custom.categories), + base.categories, + ), + plugins: composePlugins(context, base.plugins, custom.plugins), + }; +} diff --git a/src/options/composePlugins.ts b/src/options/compose-options/plugins.ts similarity index 56% rename from src/options/composePlugins.ts rename to src/options/compose-options/plugins.ts index b30d053..b8150c0 100644 --- a/src/options/composePlugins.ts +++ b/src/options/compose-options/plugins.ts @@ -1,10 +1,24 @@ -import type { Plugin } from 'jest-allure2-reporter'; +import type { + Plugin, + PluginContext, + PluginDeclaration, +} from 'jest-allure2-reporter'; + +import { resolvePlugins } from '../utils'; export async function composePlugins( - basePromise: Promise, - customPromise: Promise, + context: PluginContext, + basePlugins: Promise, + customPlugins: PluginDeclaration[] | undefined, ): Promise { - const [base, custom] = await Promise.all([basePromise, customPromise]); + if (!customPlugins) { + return basePlugins; + } + + const [base, custom] = await Promise.all([ + basePlugins, + resolvePlugins(context, customPlugins), + ]); const result: Plugin[] = []; const indices: Record = {}; diff --git a/src/options/compose-options/testCase.ts b/src/options/compose-options/testCase.ts new file mode 100644 index 0000000..07db183 --- /dev/null +++ b/src/options/compose-options/testCase.ts @@ -0,0 +1,45 @@ +import type { + ResolvedTestCaseCustomizer, + TestCaseCustomizer, +} from 'jest-allure2-reporter'; + +import { + aggregateLabelCustomizers, + aggregateLinkCustomizers, + composeExtractors, +} from '../utils'; + +export function composeTestCaseCustomizers( + base: ResolvedTestCaseCustomizer, + custom: Partial | undefined, +): ResolvedTestCaseCustomizer { + if (!custom) { + return base; + } + + return { + historyId: composeExtractors(custom.historyId, base.historyId), + fullName: composeExtractors(custom.fullName, base.fullName), + name: composeExtractors(custom.name, base.name), + description: composeExtractors(custom.description, base.description), + descriptionHtml: composeExtractors( + custom.descriptionHtml, + base.descriptionHtml, + ), + start: composeExtractors(custom.start, base.start), + stop: composeExtractors(custom.stop, base.stop), + stage: composeExtractors(custom.stage, base.stage), + status: composeExtractors(custom.status, base.status), + statusDetails: composeExtractors(custom.statusDetails, base.statusDetails), + attachments: composeExtractors(custom.attachments, base.attachments), + parameters: composeExtractors(custom.parameters, base.parameters), + labels: composeExtractors( + aggregateLabelCustomizers(custom.labels), + base.labels, + ), + links: composeExtractors( + aggregateLinkCustomizers(custom.links), + base.links, + ), + }; +} diff --git a/src/options/compose-options/testFile.ts b/src/options/compose-options/testFile.ts new file mode 100644 index 0000000..539eb3f --- /dev/null +++ b/src/options/compose-options/testFile.ts @@ -0,0 +1,22 @@ +import type { + ResolvedTestFileCustomizer, + TestFileCustomizer, +} from 'jest-allure2-reporter'; + +import { composeExtractors } from '../utils'; + +import { composeTestCaseCustomizers } from './testCase'; + +export function composeTestFileCustomizers( + base: ResolvedTestFileCustomizer, + custom: Partial | undefined, +): ResolvedTestFileCustomizer { + if (!custom) { + return base; + } + + return { + ...(composeTestCaseCustomizers(base, custom) as any), + ignored: composeExtractors(custom.ignored, base.ignored), + }; +} diff --git a/src/options/compose-options/testStep.ts b/src/options/compose-options/testStep.ts new file mode 100644 index 0000000..6bf3e4f --- /dev/null +++ b/src/options/compose-options/testStep.ts @@ -0,0 +1,23 @@ +import type { TestStepCustomizer } from 'jest-allure2-reporter'; + +import { composeExtractors } from '../utils'; + +export function composeTestStepCustomizers( + base: TestStepCustomizer, + custom: Partial | undefined, +): TestStepCustomizer { + if (!custom) { + return base; + } + + return { + name: composeExtractors(custom.name, base.name), + stage: composeExtractors(custom.stage, base.stage), + start: composeExtractors(custom.start, base.start), + stop: composeExtractors(custom.stop, base.stop), + status: composeExtractors(custom.status, base.status), + statusDetails: composeExtractors(custom.statusDetails, base.statusDetails), + attachments: composeExtractors(custom.attachments, base.attachments), + parameters: composeExtractors(custom.parameters, base.parameters), + }; +} diff --git a/src/options/composeOptions.ts b/src/options/composeOptions.ts deleted file mode 100644 index 74e9435..0000000 --- a/src/options/composeOptions.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { PluginContext } from 'jest-allure2-reporter'; -import type { - ReporterOptions, - ReporterConfig, - ResolvedTestCaseCustomizer, - TestCaseCustomizer, - TestStepCustomizer, - AttachmentsOptions, -} from 'jest-allure2-reporter'; - -import { aggregateLabelCustomizers } from './aggregateLabelCustomizers'; -import { aggregateLinkCustomizers } from './aggregateLinkCustomizers'; -import { composeExtractors } from './composeExtractors'; -import { asExtractor } from './asExtractor'; -import { resolvePlugins } from './resolvePlugins'; -import { composePlugins } from './composePlugins'; - -export function composeOptions( - context: PluginContext, - base: ReporterConfig, - custom: ReporterOptions | undefined, -): ReporterConfig { - if (!custom) { - return base; - } - - return { - ...custom, - - overwrite: custom.overwrite ?? base.overwrite, - resultsDir: custom.resultsDir ?? base.resultsDir, - attachments: composeAttachments(base.attachments, custom.attachments), - testCase: composeTestCaseCustomizers(base.testCase, custom.testCase), - testStep: composeTestStepCustomizers( - base.testStep as TestStepCustomizer, - custom.testStep, - ), - environment: composeExtractors( - asExtractor(custom.environment), - base.environment, - ), - executor: composeExtractors(asExtractor(custom.executor), base.executor), - categories: composeExtractors( - asExtractor(custom.categories), - base.categories, - ), - plugins: composePlugins( - base.plugins, - resolvePlugins(context, custom.plugins), - ), - }; -} - -function composeAttachments( - base: Required, - custom: AttachmentsOptions | undefined, -): Required { - if (!custom) { - return base; - } - - return { - subDir: custom?.subDir ?? base.subDir, - fileHandler: custom?.fileHandler ?? base.fileHandler, - }; -} - -function composeTestCaseCustomizers( - base: ResolvedTestCaseCustomizer, - custom: Partial | undefined, -): ResolvedTestCaseCustomizer { - if (!custom) { - return base; - } - - return { - historyId: composeExtractors(custom.historyId, base.historyId), - fullName: composeExtractors(custom.fullName, base.fullName), - name: composeExtractors(custom.name, base.name), - description: composeExtractors(custom.description, base.description), - descriptionHtml: composeExtractors( - custom.descriptionHtml, - base.descriptionHtml, - ), - start: composeExtractors(custom.start, base.start), - stop: composeExtractors(custom.stop, base.stop), - stage: composeExtractors(custom.stage, base.stage), - status: composeExtractors(custom.status, base.status), - statusDetails: composeExtractors(custom.statusDetails, base.statusDetails), - attachments: composeExtractors(custom.attachments, base.attachments), - parameters: composeExtractors(custom.parameters, base.parameters), - labels: composeExtractors( - aggregateLabelCustomizers(custom.labels), - base.labels, - ), - links: composeExtractors( - aggregateLinkCustomizers(custom.links), - base.links, - ), - }; -} - -function composeTestStepCustomizers( - base: TestStepCustomizer, - custom: Partial | undefined, -): TestStepCustomizer { - if (!custom) { - return base; - } - - return { - name: composeExtractors(custom.name, base.name), - stage: composeExtractors(custom.stage, base.stage), - start: composeExtractors(custom.start, base.start), - stop: composeExtractors(custom.stop, base.stop), - status: composeExtractors(custom.status, base.status), - statusDetails: composeExtractors(custom.statusDetails, base.statusDetails), - attachments: composeExtractors(custom.attachments, base.attachments), - parameters: composeExtractors(custom.parameters, base.parameters), - }; -} diff --git a/src/options/defaultCategories.ts b/src/options/default-options/categories.ts similarity index 82% rename from src/options/defaultCategories.ts rename to src/options/default-options/categories.ts index db2a799..f845f45 100644 --- a/src/options/defaultCategories.ts +++ b/src/options/default-options/categories.ts @@ -1,7 +1,7 @@ import type { Category } from '@noomorph/allure-js-commons'; import { Status } from '@noomorph/allure-js-commons'; -export const DEFAULT_CATEGORIES: Category[] = [ +export const categories: () => Category[] = () => [ { name: 'Product defects', matchedStatuses: [Status.FAILED] }, { name: 'Test defects', matchedStatuses: [Status.BROKEN] }, ]; diff --git a/src/options/default-options/index.ts b/src/options/default-options/index.ts new file mode 100644 index 0000000..90d465b --- /dev/null +++ b/src/options/default-options/index.ts @@ -0,0 +1,31 @@ +import type { + ExtractorContext, + PluginContext, + ReporterConfig, +} from 'jest-allure2-reporter'; + +import { categories } from './categories'; +import { defaultPlugins } from './plugins'; +import { testCase } from './testCase'; +import { testFile } from './testFile'; +import { testStep } from './testStep'; + +const identity = (context: ExtractorContext) => context.value; + +export function defaultOptions(context: PluginContext): ReporterConfig { + return { + overwrite: true, + resultsDir: 'allure-results', + attachments: { + subDir: 'attachments', + fileHandler: 'ref', + }, + testFile, + testCase, + testStep, + categories, + environment: identity, + executor: identity, + plugins: defaultPlugins(context), + }; +} diff --git a/src/options/default-options/plugins.ts b/src/options/default-options/plugins.ts new file mode 100644 index 0000000..35b6131 --- /dev/null +++ b/src/options/default-options/plugins.ts @@ -0,0 +1,13 @@ +import type { PluginContext } from 'jest-allure2-reporter'; + +import * as plugins from '../../builtin-plugins'; + +export async function defaultPlugins(context: PluginContext) { + return [ + plugins.detect({}, context), + plugins.docblock({}, context), + plugins.manifest({}, context), + plugins.prettier({}, context), + plugins.remark({}, context), + ]; +} diff --git a/src/options/default-options/testCase.ts b/src/options/default-options/testCase.ts new file mode 100644 index 0000000..30048b3 --- /dev/null +++ b/src/options/default-options/testCase.ts @@ -0,0 +1,125 @@ +import path from 'node:path'; + +import type { TestCaseResult } from '@jest/reporters'; +import type { Label, StatusDetails } from '@noomorph/allure-js-commons'; +import { Stage, Status } from '@noomorph/allure-js-commons'; +import type { + ExtractorContext, + ResolvedTestCaseCustomizer, + TestCaseCustomizer, + TestCaseExtractorContext, +} from 'jest-allure2-reporter'; + +import { + aggregateLabelCustomizers, + composeExtractors, + stripStatusDetails, +} from '../utils'; + +const identity = (context: ExtractorContext) => context.value; +const last = (context: ExtractorContext) => context.value?.at(-1); +const all = identity; + +export const testCase: ResolvedTestCaseCustomizer = { + 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'); + }, + descriptionHtml: () => void 0, + start: ({ testCase, testCaseMetadata }) => + testCaseMetadata.start ?? + (testCaseMetadata.stop ?? Date.now()) - (testCase.duration ?? 0), + stop: ({ testCaseMetadata }) => testCaseMetadata.stop ?? Date.now(), + stage: ({ testCase }) => getTestCaseStage(testCase), + status: ({ testCase, testCaseMetadata }) => + testCaseMetadata.status ?? getTestCaseStatus(testCase), + statusDetails: ({ testCase, testCaseMetadata }) => + stripStatusDetails( + testCaseMetadata.statusDetails ?? getTestCaseStatusDetails(testCase), + ), + attachments: ({ testCaseMetadata }) => testCaseMetadata.attachments ?? [], + parameters: ({ testCaseMetadata }) => testCaseMetadata.parameters ?? [], + labels: composeExtractors>( + aggregateLabelCustomizers({ + package: last, + testClass: last, + testMethod: last, + parentSuite: last, + suite: ({ testCase, testFile }) => + testCase.ancestorTitles[0] ?? path.basename(testFile.testFilePath), + subSuite: ({ testCase }) => testCase.ancestorTitles.slice(1).join(' '), + epic: all, + feature: all, + story: all, + thread: ({ testCaseMetadata }) => testCaseMetadata.workerId, + severity: last, + tag: all, + owner: last, + } as TestCaseCustomizer['labels']), + ({ testCaseMetadata }) => testCaseMetadata.labels ?? [], + ), + links: ({ testCaseMetadata }) => testCaseMetadata.links ?? [], +}; + +function getTestCaseStatus(testCase: TestCaseResult): Status { + const hasErrors = testCase.failureMessages?.length > 0; + switch (testCase.status) { + case 'passed': { + return Status.PASSED; + } + case 'failed': { + return Status.FAILED; + } + case 'skipped': { + return Status.SKIPPED; + } + case 'pending': + case 'todo': + case 'disabled': { + return Status.SKIPPED; + } + case 'focused': { + return hasErrors ? Status.FAILED : Status.PASSED; + } + default: { + return 'unknown' as Status; + } + } +} + +function getTestCaseStage(testCase: TestCaseResult): Stage { + switch (testCase.status) { + case 'passed': + case 'focused': + case 'failed': { + return Stage.FINISHED; + } + case 'todo': + case 'disabled': + case 'pending': + case 'skipped': { + return Stage.PENDING; + } + default: { + return Stage.INTERRUPTED; + } + } +} + +function getTestCaseStatusDetails( + testCase: TestCaseResult, +): StatusDetails | undefined { + const message = (testCase.failureMessages ?? []).join('\n'); + return message + ? stripStatusDetails({ + message: message.split('\n', 1)[0], + trace: message, + }) + : undefined; +} diff --git a/src/options/default-options/testFile.ts b/src/options/default-options/testFile.ts new file mode 100644 index 0000000..d1ff918 --- /dev/null +++ b/src/options/default-options/testFile.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { TestResult } from '@jest/reporters'; +import type { Label, Link, StatusDetails } from '@noomorph/allure-js-commons'; +import { Stage, Status } from '@noomorph/allure-js-commons'; +import type { + ExtractorContext, + TestFileExtractorContext, + ResolvedTestFileCustomizer, + TestCaseCustomizer, +} from 'jest-allure2-reporter'; + +import { + aggregateLabelCustomizers, + composeExtractors, + stripStatusDetails, +} from '../utils'; + +const identity = (context: ExtractorContext) => context.value; +const last = (context: ExtractorContext) => context.value?.at(-1); +const all = identity; + +export const testFile: ResolvedTestFileCustomizer = { + ignored: ({ testFile }) => !testFile.testExecError, + historyId: ({ filePath }) => filePath.join('/'), + name: ({ filePath }) => filePath.join(path.sep), + fullName: ({ globalConfig, testFile }) => + path.relative(globalConfig.rootDir, testFile.testFilePath), + description: ({ detectLanguage, testFile, testFileMetadata }) => { + const text = testFileMetadata.description?.join('\n') ?? ''; + const contents = fs.readFileSync(testFile.testFilePath, 'utf8'); + const lang = detectLanguage?.(testFile.testFilePath, contents) ?? ''; + const fence = '```'; + const code = `${fence}${lang}\n${contents}\n${fence}`; + return [text, code].filter(Boolean).join('\n\n'); + }, + descriptionHtml: () => void 0, + start: ({ testFileMetadata }) => testFileMetadata.start, + stop: ({ testFileMetadata }) => testFileMetadata.stop, + stage: () => Stage.FINISHED, + status: ({ testFile }: TestFileExtractorContext) => + testFile.testExecError ? Status.BROKEN : Status.PASSED, + statusDetails: ({ testFile }) => + stripStatusDetails(getTestFileStatusDetails(testFile)), + attachments: ({ testFileMetadata }) => testFileMetadata.attachments ?? [], + parameters: ({ testFileMetadata }) => testFileMetadata.parameters ?? [], + labels: composeExtractors>( + aggregateLabelCustomizers({ + package: last, + testClass: last, + testMethod: last, + parentSuite: last, + subSuite: last, + suite: () => '(test file execution)', + epic: all, + feature: all, + story: all, + thread: ({ testFileMetadata }) => testFileMetadata.workerId, + severity: last, + tag: all, + owner: last, + } as TestCaseCustomizer['labels']), + ({ testFileMetadata }) => testFileMetadata.labels ?? [], + ), + links: ({ testFileMetadata }: TestFileExtractorContext) => + testFileMetadata.links ?? [], +}; + +function getTestFileStatusDetails( + testFile: TestResult, +): StatusDetails | undefined { + const message = + testFile.testExecError?.stack || `${testFile.testExecError || ''}`; + + return message + ? stripStatusDetails({ + message: message.split('\n', 2).join('\n'), + trace: message, + }) + : undefined; +} diff --git a/src/options/default-options/testStep.ts b/src/options/default-options/testStep.ts new file mode 100644 index 0000000..18ee0a0 --- /dev/null +++ b/src/options/default-options/testStep.ts @@ -0,0 +1,15 @@ +import type { ResolvedTestStepCustomizer } from 'jest-allure2-reporter'; + +import { stripStatusDetails } from '../utils'; + +export const testStep: ResolvedTestStepCustomizer = { + name: ({ testStepMetadata }) => testStepMetadata.name, + start: ({ testStepMetadata }) => testStepMetadata.start, + stop: ({ testStepMetadata }) => testStepMetadata.stop, + stage: ({ testStepMetadata }) => testStepMetadata.stage, + status: ({ testStepMetadata }) => testStepMetadata.status, + statusDetails: ({ testStepMetadata }) => + stripStatusDetails(testStepMetadata.statusDetails), + attachments: ({ testStepMetadata }) => testStepMetadata.attachments ?? [], + parameters: ({ testStepMetadata }) => testStepMetadata.parameters ?? [], +}; diff --git a/src/options/defaultOptions.ts b/src/options/defaultOptions.ts deleted file mode 100644 index 4e804bf..0000000 --- a/src/options/defaultOptions.ts +++ /dev/null @@ -1,175 +0,0 @@ -import path from 'node:path'; - -import type { TestCaseResult } from '@jest/reporters'; -import type { Attachment, StatusDetails } from '@noomorph/allure-js-commons'; -import { Stage, Status } from '@noomorph/allure-js-commons'; -import type { PluginContext } from 'jest-allure2-reporter'; -import type { - ExtractorContext, - ReporterConfig, - ResolvedTestCaseCustomizer, - ResolvedTestStepCustomizer, -} from 'jest-allure2-reporter'; - -import * as plugins from '../builtin-plugins'; - -import { stripStatusDetails } from './stripStatusDetails'; -import { aggregateLabelCustomizers } from './aggregateLabelCustomizers'; -import { resolvePlugins } from './resolvePlugins'; -import { composeExtractors } from './composeExtractors'; -import { DEFAULT_CATEGORIES } from './defaultCategories'; - -const identity = (context: ExtractorContext) => context.value; -const last = (context: ExtractorContext) => context.value?.at(-1); -const all = identity; - -export function defaultOptions(context: PluginContext): ReporterConfig { - const testCase: ResolvedTestCaseCustomizer = { - 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'); - }, - descriptionHtml: () => void 0, - start: ({ testCase, testCaseMetadata }) => - testCaseMetadata.start ?? - (testCaseMetadata.stop ?? Date.now()) - (testCase.duration ?? 0), - stop: ({ testCaseMetadata }) => testCaseMetadata.stop ?? Date.now(), - stage: ({ testCase }) => getTestCaseStage(testCase), - status: ({ testCase, testCaseMetadata }) => - testCaseMetadata.status ?? getTestCaseStatus(testCase), - statusDetails: ({ testCase, testCaseMetadata }) => - stripStatusDetails( - testCaseMetadata.statusDetails ?? getTestCaseStatusDetails(testCase), - ), - attachments: ({ config, testCaseMetadata }) => - (testCaseMetadata.attachments ?? []).map(relativizeAttachment, config), - parameters: ({ testCaseMetadata }) => testCaseMetadata.parameters ?? [], - labels: composeExtractors( - aggregateLabelCustomizers({ - package: last, - testClass: last, - testMethod: last, - parentSuite: last, - suite: ({ testCase, testFile }) => - testCase.ancestorTitles[0] ?? path.basename(testFile.testFilePath), - subSuite: ({ testCase }) => testCase.ancestorTitles.slice(1).join(' '), - epic: all, - feature: all, - story: all, - thread: ({ testCaseMetadata }) => testCaseMetadata.workerId, - severity: last, - tag: all, - owner: last, - }), - ({ testCaseMetadata }) => testCaseMetadata.labels ?? [], - ), - links: ({ testCaseMetadata }) => testCaseMetadata.links ?? [], - }; - - const testStep: ResolvedTestStepCustomizer = { - name: ({ testStepMetadata }) => testStepMetadata.name, - start: ({ testStepMetadata }) => testStepMetadata.start, - stop: ({ testStepMetadata }) => testStepMetadata.stop, - stage: ({ testStepMetadata }) => testStepMetadata.stage, - status: ({ testStepMetadata }) => testStepMetadata.status, - statusDetails: ({ testStepMetadata }) => - stripStatusDetails(testStepMetadata.statusDetails), - attachments: ({ testStepMetadata }) => testStepMetadata.attachments ?? [], - parameters: ({ testStepMetadata }) => testStepMetadata.parameters ?? [], - }; - - const config: ReporterConfig = { - overwrite: true, - resultsDir: 'allure-results', - attachments: { - subDir: 'attachments', - fileHandler: 'ref', - }, - testCase, - testStep, - environment: identity, - executor: identity, - categories: () => DEFAULT_CATEGORIES, - plugins: resolvePlugins(context, [ - plugins.jsdoc, - plugins.manifest, - plugins.prettier, - plugins.remark, - ]), - }; - - return config; -} - -function getTestCaseStatus(testCase: TestCaseResult): Status { - const hasErrors = testCase.failureMessages?.length > 0; - switch (testCase.status) { - case 'passed': { - return Status.PASSED; - } - case 'failed': { - return Status.FAILED; - } - case 'skipped': { - return Status.SKIPPED; - } - case 'pending': - case 'todo': - case 'disabled': { - return Status.SKIPPED; - } - case 'focused': { - return hasErrors ? Status.FAILED : Status.PASSED; - } - default: { - return 'unknown' as Status; - } - } -} - -function getTestCaseStage(testCase: TestCaseResult): Stage { - switch (testCase.status) { - case 'passed': - case 'focused': - case 'failed': { - return Stage.FINISHED; - } - case 'todo': - case 'disabled': - case 'pending': - case 'skipped': { - return Stage.PENDING; - } - default: { - return Stage.INTERRUPTED; - } - } -} - -function getTestCaseStatusDetails( - testCase: TestCaseResult, -): StatusDetails | undefined { - const message = (testCase.failureMessages ?? []).join('\n'); - return message - ? stripStatusDetails({ - message: message.split('\n', 1)[0], - trace: message, - }) - : undefined; -} - -function relativizeAttachment( - this: ReporterConfig, - attachment: Attachment, -): Attachment { - return { - ...attachment, - source: path.relative(this.resultsDir, attachment.source), - }; -} diff --git a/src/options/index.ts b/src/options/index.ts index 4fce690..eaa99f1 100644 --- a/src/options/index.ts +++ b/src/options/index.ts @@ -1 +1,15 @@ -export * from './resolveOptions'; +import type { + PluginContext, + ReporterOptions, + ReporterConfig, +} from 'jest-allure2-reporter'; + +import { composeOptions } from './compose-options'; +import { defaultOptions } from './default-options'; + +export function resolveOptions( + context: PluginContext, + options?: ReporterOptions | undefined, +): ReporterConfig { + return composeOptions(context, defaultOptions(context), options); +} diff --git a/src/options/resolveOptions.ts b/src/options/resolveOptions.ts deleted file mode 100644 index 98dc09c..0000000 --- a/src/options/resolveOptions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { - PluginContext, - ReporterOptions, - ReporterConfig, -} from 'jest-allure2-reporter'; - -import { composeOptions } from './composeOptions'; -import { defaultOptions } from './defaultOptions'; - -export function resolveOptions( - context: PluginContext, - options?: ReporterOptions | undefined, -): ReporterConfig { - return composeOptions(context, defaultOptions(context), options); -} diff --git a/src/options/aggregateLabelCustomizers.ts b/src/options/utils/aggregateLabelCustomizers.ts similarity index 63% rename from src/options/aggregateLabelCustomizers.ts rename to src/options/utils/aggregateLabelCustomizers.ts index b55e4b8..35d776f 100644 --- a/src/options/aggregateLabelCustomizers.ts +++ b/src/options/utils/aggregateLabelCustomizers.ts @@ -1,35 +1,36 @@ /* eslint-disable unicorn/no-array-reduce */ import type { Label } from '@noomorph/allure-js-commons'; import type { - LabelExtractor, - LabelsCustomizer, - TestCaseExtractor, - TestCaseExtractorContext, + Extractor, + ExtractorContext, + TestFileCustomizer, + TestCaseCustomizer, } from 'jest-allure2-reporter'; import { asExtractor } from './asExtractor'; -export function aggregateLabelCustomizers( - labels: LabelsCustomizer | undefined, -): TestCaseExtractor | undefined { +type Customizer = TestFileCustomizer | TestCaseCustomizer; +export function aggregateLabelCustomizers( + labels: C['labels'] | undefined, +): Extractor | undefined { if (!labels || typeof labels === 'function') { - return labels; + return labels as Extractor | undefined; } const extractors = Object.keys(labels).reduce( (accumulator, key) => { - const extractor = asExtractor(labels[key]) as LabelExtractor; + const extractor = asExtractor(labels[key]) as Extractor; if (extractor) { accumulator[key] = extractor; } return accumulator; }, - {} as Record, + {} as Record>, ); const names = Object.keys(extractors); - return (context: TestCaseExtractorContext) => { + return (context: ExtractorContext): Label[] | undefined => { const other: Label[] = []; const found = names.reduce( (found, key) => { @@ -52,10 +53,12 @@ export function aggregateLabelCustomizers( const result = [ ...other, ...names.flatMap((name) => { - const extractor = extractors[name]!; - const value = asArray( - extractor({ ...context, value: asArray(found[name]) }), - ); + const extractor = extractors[name]; + const aContext: ExtractorContext = { + ...context, + value: asArray(found[name]), + }; + const value = asArray(extractor(aContext)); return value ? value.map((value) => ({ name, value }) as Label) : []; }), ]; diff --git a/src/options/utils/aggregateLinkCustomizers.ts b/src/options/utils/aggregateLinkCustomizers.ts new file mode 100644 index 0000000..db0a883 --- /dev/null +++ b/src/options/utils/aggregateLinkCustomizers.ts @@ -0,0 +1,26 @@ +import type { Link } from '@noomorph/allure-js-commons'; +import type { + Extractor, + ExtractorContext, + TestFileCustomizer, + TestCaseCustomizer, +} from 'jest-allure2-reporter'; + +type Customizer = TestFileCustomizer | TestCaseCustomizer; + +export function aggregateLinkCustomizers( + links: C['links'] | undefined, +): Extractor | undefined { + if (!links || typeof links === 'function') { + return links as Extractor | undefined; + } + + return (context: ExtractorContext) => { + return context.value + ?.map((link) => { + const extractor = links[link.type ?? '']; + return extractor ? extractor({ ...context, value: link } as any) : link; + }) + ?.filter(Boolean) as Link[] | undefined; + }; +} diff --git a/src/options/asExtractor.ts b/src/options/utils/asExtractor.ts similarity index 100% rename from src/options/asExtractor.ts rename to src/options/utils/asExtractor.ts diff --git a/src/options/composeExtractors.ts b/src/options/utils/composeExtractors.ts similarity index 100% rename from src/options/composeExtractors.ts rename to src/options/utils/composeExtractors.ts diff --git a/src/options/utils/index.ts b/src/options/utils/index.ts new file mode 100644 index 0000000..6b0f812 --- /dev/null +++ b/src/options/utils/index.ts @@ -0,0 +1,6 @@ +export * from './aggregateLabelCustomizers'; +export * from './aggregateLinkCustomizers'; +export * from './asExtractor'; +export * from './composeExtractors'; +export * from './resolvePlugins'; +export * from './stripStatusDetails'; diff --git a/src/options/resolvePlugins.ts b/src/options/utils/resolvePlugins.ts similarity index 100% rename from src/options/resolvePlugins.ts rename to src/options/utils/resolvePlugins.ts diff --git a/src/options/stripStatusDetails.ts b/src/options/utils/stripStatusDetails.ts similarity index 100% rename from src/options/stripStatusDetails.ts rename to src/options/utils/stripStatusDetails.ts diff --git a/src/reporter/JestAllure2Reporter.ts b/src/reporter/JestAllure2Reporter.ts index 99a9021..7917529 100644 --- a/src/reporter/JestAllure2Reporter.ts +++ b/src/reporter/JestAllure2Reporter.ts @@ -12,7 +12,16 @@ import type { import { state } from 'jest-metadata'; import { JestMetadataReporter, query } from 'jest-metadata/reporter'; import rimraf from 'rimraf'; -import type { ExecutableItemWrapper } from '@noomorph/allure-js-commons'; +import type { + Attachment, + ExecutableItemWrapper, + Label, + Link, + Parameter, + Stage, + Status, + StatusDetails, +} from '@noomorph/allure-js-commons'; import { AllureRuntime } from '@noomorph/allure-js-commons'; import type { AllureTestStepMetadata, @@ -30,14 +39,16 @@ import type { import { resolveOptions } from '../options'; import { MetadataSquasher, StepExtractor } from '../metadata'; -import { SHARED_CONFIG, STOP, WORKER_ID } from '../constants'; +import { SHARED_CONFIG, START, STOP, WORKER_ID } from '../constants'; import { ThreadService } from '../utils/ThreadService'; import md5 from '../utils/md5'; export class JestAllure2Reporter extends JestMetadataReporter { private _plugins: readonly Plugin[] = []; - private readonly _globalConfig: Config.GlobalConfig; + private _processMarkdown?: (markdown: string) => Promise; + private readonly _allure: AllureRuntime; private readonly _config: ReporterConfig; + private readonly _globalConfig: Config.GlobalConfig; private readonly _threadService = new ThreadService(); constructor(globalConfig: Config.GlobalConfig, options: ReporterOptions) { @@ -46,6 +57,9 @@ export class JestAllure2Reporter extends JestMetadataReporter { this._globalConfig = globalConfig; const pluginContext = { globalConfig }; this._config = resolveOptions(pluginContext, options); + this._allure = new AllureRuntime({ + resultsDir: this._config.resultsDir, + }); state.set(SHARED_CONFIG, { resultsDir: this._config.resultsDir, @@ -73,6 +87,7 @@ export class JestAllure2Reporter extends JestMetadataReporter { const testFileMetadata = query.test(test); const threadId = this._threadService.allocateThread(test.path); testFileMetadata.set(WORKER_ID, String(1 + threadId)); + testFileMetadata.set(START, Date.now()); } onTestCaseResult(test: Test, testCaseResult: TestCaseResult) { @@ -91,6 +106,10 @@ export class JestAllure2Reporter extends JestMetadataReporter { aggregatedResult: AggregatedResult, ) { this._threadService.freeThread(test.path); + + const testFileMetadata = query.test(test); + testFileMetadata.set(STOP, Date.now()); + return super.onTestFileResult(test, testResult, aggregatedResult); } @@ -101,9 +120,6 @@ export class JestAllure2Reporter extends JestMetadataReporter { await super.onRunComplete(testContexts, results); const config = this._config; - const allure = new AllureRuntime({ - resultsDir: config.resultsDir, - }); const globalContext: GlobalExtractorContext = { globalConfig: this._globalConfig, @@ -115,24 +131,27 @@ export class JestAllure2Reporter extends JestMetadataReporter { const environment = config.environment(globalContext); if (environment) { - allure.writeEnvironmentInfo(environment); + this._allure.writeEnvironmentInfo(environment); } const executor = config.executor(globalContext); if (executor) { - allure.writeExecutorInfo(executor); + this._allure.writeExecutorInfo(executor); } const categories = config.categories(globalContext); if (categories) { - allure.writeCategoriesDefinitions(categories); + this._allure.writeCategoriesDefinitions(categories); } const squasher = new MetadataSquasher(); const stepper = new StepExtractor(); for (const testResult of results.testResults) { - const testFileContext: TestFileExtractorContext = { + const beforeTestFileContext: Omit< + TestFileExtractorContext, + 'testFileMetadata' + > = { ...globalContext, filePath: path .relative(globalContext.globalConfig.rootDir, testResult.testFilePath) @@ -140,7 +159,37 @@ export class JestAllure2Reporter extends JestMetadataReporter { testFile: testResult, }; + await this._callPlugins('beforeTestFileContext', beforeTestFileContext); + + const testFileContext: TestFileExtractorContext = { + ...beforeTestFileContext, + testFileMetadata: squasher.testFile(query.testResult(testResult)), + }; + await this._callPlugins('testFileContext', testFileContext); + this._processMarkdown = testFileContext.processMarkdown; + + if (!config.testFile.ignored(testFileContext)) { + 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), + statusDetails: config.testFile.statusDetails(testFileContext), + stage: config.testFile.stage(testFileContext), + links: config.testFile.links(testFileContext), + labels: config.testFile.labels(testFileContext), + parameters: config.testFile.parameters(testFileContext), + attachments: config.testFile.attachments(testFileContext), + }, + }); + } for (const testCaseResult of testResult.testResults) { const allInvocations = @@ -161,92 +210,144 @@ export class JestAllure2Reporter extends JestMetadataReporter { const invocationIndex = allInvocations.indexOf( testInvocationMetadata, ); - const allureContainerName = `${testCaseResult.fullName} (${invocationIndex})`; - const allureGroup = allure.startGroup(allureContainerName); - const allureTest = allureGroup.startTest( - config.testCase.name(testCaseContext), - config.testCase.start(testCaseContext), - ); - allureTest.historyId = md5( - config.testCase.historyId(testCaseContext)!, - ); - allureTest.fullName = config.testCase.fullName(testCaseContext)!; - - const description = config.testCase.description(testCaseContext); - const descriptionHtml = - config.testCase.descriptionHtml(testCaseContext); - - if ( - !descriptionHtml && - description && - testCaseContext.processMarkdown - ) { - const newHTML = await testCaseContext.processMarkdown(description); - allureTest.descriptionHtml = newHTML; - } else { - allureTest.description = description; - allureTest.descriptionHtml = descriptionHtml; - } - - allureTest.status = config.testCase.status(testCaseContext)!; - allureTest.statusDetails = - config.testCase.statusDetails(testCaseContext)!; - allureTest.stage = config.testCase.stage(testCaseContext)!; - - for (const link of config.testCase.links(testCaseContext) ?? []) { - allureTest.addLink(link.url, link.name, link.type); - } - - for (const label of config.testCase.labels(testCaseContext) ?? []) { - allureTest.addLabel(label.name, label.value); - } - - allureTest.wrappedItem.parameters = - config.testCase.parameters(testCaseContext)!; - allureTest.wrappedItem.attachments = - config.testCase.attachments(testCaseContext)!; - - const batches = [ - [() => allureGroup.addBefore(), testInvocationMetadata.beforeAll], - [() => allureGroup.addBefore(), testInvocationMetadata.beforeEach], - [ - () => allureTest, - testInvocationMetadata.fn ? [testInvocationMetadata.fn] : [], - ], - [() => allureGroup.addAfter(), testInvocationMetadata.afterEach], - [() => allureGroup.addAfter(), testInvocationMetadata.afterAll], - ] as const; - - for (const [createExecutable, invocationMetadatas] of batches) { - for (const invocationMetadata of invocationMetadatas) { - const testStepMetadata = - stepper.extractFromInvocation(invocationMetadata); - if (testStepMetadata) { - const executable = createExecutable(); - await this._createStep( - testCaseContext, - executable, - testStepMetadata, - executable === allureTest, - ); - } - } - } - - allureTest.endTest(config.testCase.stop(testCaseContext)); - allureGroup.endGroup(); + + 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), + statusDetails: config.testCase.statusDetails(testCaseContext), + stage: config.testCase.stage(testCaseContext), + 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), + ), + }); } } } } + private async _createTest({ + test, + testCaseContext, + containerName, + beforeAll = [], + beforeEach = [], + testFn, + afterEach = [], + afterAll = [], + }: AllurePayload) { + const allure = this._allure; + const allureGroup = allure.startGroup(containerName); + const allureTest = allureGroup.startTest(test.name, test.start); + 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 { + 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.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, + ); + } + } + + allureTest.endTest(test.stop); + allureGroup.endGroup(); + } + private async _createStep( testCaseContext: TestCaseExtractorContext, executable: ExecutableItemWrapper, testStepMetadata: AllureTestStepMetadata, - isTest: boolean, + isTest = false, ) { - const customize: ResolvedTestStepCustomizer = this._config!.testStep; + const config = this._config; + const customize: ResolvedTestStepCustomizer = config.testStep; const testStepContext = { ...testCaseContext, testStepMetadata, @@ -263,8 +364,9 @@ export class JestAllure2Reporter extends JestMetadataReporter { customize.status(testStepContext) ?? executable.status; executable.statusDetails = customize.statusDetails(testStepContext) ?? {}; - executable.wrappedItem.attachments = - customize.attachments(testStepContext)!; + executable.wrappedItem.attachments = customize + .attachments(testStepContext)! + .map(this._relativizeAttachment); executable.wrappedItem.parameters = customize.parameters(testStepContext)!; } @@ -288,4 +390,44 @@ export class JestAllure2Reporter extends JestMetadataReporter { }), ); } + + _relativizeAttachment = (attachment: Attachment) => { + return { + ...attachment, + source: path.relative(this._config.resultsDir, attachment.source), + }; + }; } + +type AllurePayload = { + containerName: string; + test: AllurePayloadTest; + testCaseContext?: TestCaseExtractorContext; + testFn?: AllureTestStepMetadata | null; + beforeAll?: (AllureTestStepMetadata | null)[]; + beforeEach?: (AllureTestStepMetadata | null)[]; + afterEach?: (AllureTestStepMetadata | null)[]; + afterAll?: (AllureTestStepMetadata | null)[]; +}; + +type AllurePayloadStep = Partial<{ + name: string; + start: number; + stop: number; + status: Status; + statusDetails: StatusDetails; + stage: Stage; + steps: AllurePayloadStep[]; + attachments: Attachment[]; + parameters: Parameter[]; +}>; + +type AllurePayloadTest = Partial<{ + historyId: string; + fullName: string; + description: string; + descriptionHtml: string; + labels: Label[]; + links: Link[]; +}> & + AllurePayloadStep;