diff --git a/packages/allure-mocha/src/AllureMochaReporter.ts b/packages/allure-mocha/src/AllureMochaReporter.ts index b3a93ddba..83b65fe4b 100644 --- a/packages/allure-mocha/src/AllureMochaReporter.ts +++ b/packages/allure-mocha/src/AllureMochaReporter.ts @@ -5,7 +5,6 @@ import { Stage, Status } from "allure-js-commons"; import type { Category, RuntimeMessage } from "allure-js-commons/sdk"; import { getMessageAndTraceFromError, getStatusFromError } from "allure-js-commons/sdk"; import { getHostLabel, getThreadLabel } from "allure-js-commons/sdk/reporter"; -import type { ReporterConfig } from "allure-js-commons/sdk/reporter"; import { ReporterRuntime, createDefaultWriter, @@ -17,8 +16,9 @@ import { } from "allure-js-commons/sdk/reporter"; import { setGlobalTestRuntime } from "allure-js-commons/sdk/runtime"; import { MochaTestRuntime } from "./MochaTestRuntime.js"; +import { doneAll, enableExtraReporters } from "./extraReporters.js"; import { setLegacyApiRuntime } from "./legacyUtils.js"; -import type { TestPlanIndices } from "./types.js"; +import type { AllureMochaReporterConfig, TestPlanIndices } from "./types.js"; import { applyTestPlan, createTestPlanIndices, @@ -55,11 +55,13 @@ export class AllureMochaReporter extends Mocha.reporters.Base { protected currentTest?: string; protected currentHook?: string; private readonly isInWorker: boolean; + readonly #extraReporters: Mocha.reporters.Base[] = []; constructor(runner: Mocha.Runner, opts: Mocha.MochaOptions, isInWorker: boolean = false) { super(runner, opts); - const { resultsDir, ...restOptions }: ReporterConfig = opts.reporterOptions || {}; + const allureConfig: AllureMochaReporterConfig = opts.reporterOptions ?? {}; + const { resultsDir, extraReporters, ...restOptions } = allureConfig; this.isInWorker = isInWorker; this.runtime = new ReporterRuntime({ @@ -78,6 +80,10 @@ export class AllureMochaReporter extends Mocha.reporters.Base { } else { this.applyListeners(); } + + if (!isInWorker && extraReporters) { + this.#extraReporters = enableExtraReporters(runner, opts, extraReporters); + } } applyRuntimeMessages = (...message: RuntimeMessage[]) => { @@ -123,10 +129,10 @@ export class AllureMochaReporter extends Mocha.reporters.Base { this.runtime.writeAttachment(root, null, name, buffer, { ...opts, wrapInStep: false }); }; - override done(failures: number, fn?: ((failures: number) => void) | undefined): void { + override done(failures: number, fn?: ((failures: number) => void) | undefined) { this.runtime.writeEnvironmentInfo(); this.runtime.writeCategoriesDefinitions(); - return fn?.(failures); + doneAll(this.#extraReporters, failures, fn); } private applyListeners = () => { diff --git a/packages/allure-mocha/src/extraReporters.ts b/packages/allure-mocha/src/extraReporters.ts new file mode 100644 index 000000000..93d0c24bf --- /dev/null +++ b/packages/allure-mocha/src/extraReporters.ts @@ -0,0 +1,150 @@ +import * as Mocha from "mocha"; +import path from "node:path"; +import type { ReporterDoneFn, ReporterEntry, ReporterModuleOrCtor, ReporterOptions } from "./types.js"; + +type CanonicalReporterEntry = readonly [ReporterModuleOrCtor, ReporterOptions]; +type ShortReporterEntry = readonly [ReporterModuleOrCtor]; +type LoadedReporterEntry = readonly [Mocha.ReporterConstructor, ReporterOptions]; + +export const enableExtraReporters = ( + runner: Mocha.Runner, + options: Mocha.MochaOptions, + extraReportersConfig: ReporterEntry | readonly ReporterEntry[], +) => { + const extraReporterEntries = Array.from(generateCanonicalizedReporterEntries(extraReportersConfig)); + const loadedReporterEntries = loadReporters(extraReporterEntries); + return instantiateReporters(runner, options, loadedReporterEntries); +}; + +export const doneAll = ( + reporters: readonly Mocha.reporters.Base[], + failures: number, + fn?: ((failures: number) => void) | undefined, +) => { + const doneCallbacks = collectDoneCallbacks(reporters); + let callbacksToWait = doneCallbacks.length + 1; + const onReporterIsDone = () => { + if (--callbacksToWait === 0) { + fn?.(failures); + } + }; + + for (const done of doneCallbacks) { + done(failures, onReporterIsDone); + } + + onReporterIsDone(); // handle the synchronous completion +}; + +const generateCanonicalizedReporterEntries = function* ( + reporters: ReporterEntry | readonly ReporterEntry[] | undefined, +): Generator { + if (reporters) { + if (!(reporters instanceof Array)) { + yield [reporters, {}]; + } else { + if (isReporterArrayEntry(reporters)) { + yield resolveReporterArrayEntry(reporters); + } else { + yield* reporters.map((e) => { + return resolveReporterEntry(e); + }); + } + } + } +}; + +const loadReporters = (reporterEntries: readonly CanonicalReporterEntry[]): LoadedReporterEntry[] => + reporterEntries.map(([moduleOrCtor, options]) => [loadReporterModule(moduleOrCtor), options]); + +const instantiateReporters = ( + runner: Mocha.Runner, + options: Mocha.MochaOptions, + entries: readonly LoadedReporterEntry[], +) => { + const reporters: Mocha.reporters.Base[] = []; + for (const [Reporter, reporterOptions] of entries) { + const optionsForReporter = { + ...options, + reporterOptions, + // eslint-disable-next-line quote-props + reporterOption: reporterOptions, + "reporter-option": reporterOptions, + }; + reporters.push(new Reporter(runner, optionsForReporter)); + } + return reporters; +}; + +const collectDoneCallbacks = (reporters: readonly Mocha.reporters.Base[]) => { + const doneCallbacks: ReporterDoneFn[] = []; + for (const reporter of reporters) { + if (reporter.done) { + doneCallbacks.push(reporter.done.bind(reporter)); + } + } + return doneCallbacks; +}; + +const isReporterArrayEntry = ( + reporters: ShortReporterEntry | CanonicalReporterEntry | readonly ReporterEntry[], +): reporters is ShortReporterEntry | CanonicalReporterEntry => { + const [maybeReporterModuleOrCtor, maybeReporterOptions = {}] = reporters; + return ( + !(maybeReporterModuleOrCtor instanceof Array) && + typeof maybeReporterOptions === "object" && + !(maybeReporterOptions instanceof Array) + ); +}; + +const loadReporterModule = (moduleOrCtor: ReporterModuleOrCtor) => { + if (typeof moduleOrCtor === "string") { + const builtInReporters = Mocha.reporters as Record; + const builtInReporterCtor = builtInReporters[moduleOrCtor]; + if (builtInReporterCtor) { + return builtInReporterCtor; + } + + const reporterModulePath = getReporterModulePath(moduleOrCtor); + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + return require(reporterModulePath) as Mocha.ReporterConstructor; + } catch (e: any) { + throw new Error(`Can't load the '${moduleOrCtor}' reporter from ${reporterModulePath}: ${e.message}`); + } + } + + if (typeof moduleOrCtor !== "function") { + throw new Error(`A reporter entry must be a module name or a constructor. Got ${typeof moduleOrCtor}`); + } + + return moduleOrCtor; +}; + +const getReporterModulePath = (module: string) => { + try { + return require.resolve(module); + } catch (e) {} + + try { + return path.resolve(module); + } catch (e: any) { + throw new Error(`Can't resolve the '${module}' reporter's path: ${e.message}`); + } +}; + +const resolveReporterEntry = (reporterEntry: ReporterEntry): CanonicalReporterEntry => { + return reporterEntry instanceof Array ? resolveReporterArrayEntry(reporterEntry) : [reporterEntry, {}]; +}; + +const resolveReporterArrayEntry = ( + reporterEntry: ShortReporterEntry | CanonicalReporterEntry, +): CanonicalReporterEntry => { + if (reporterEntry.length < 1 || reporterEntry.length > 2) { + throw new Error( + `If an extra reporter entry is an array, it must contain one or two elements. ${reporterEntry.length} found`, + ); + } + + return reporterEntry.length === 1 ? [...reporterEntry, {}] : [...reporterEntry]; +};