/** * Cucumber junit reporter. * Based on: https://github.com/cucumber/cucumber-js/blob/main/src/formatter/junit_formatter.ts * Junit spec(ish): https://github.com/testmoapp/junitxml * See also: https://github.com/cucumber/cucumber-junit-xml-formatter * See also: https://github.com/microsoft/playwright/blob/main/packages/playwright/src/reporters/junit.ts */ /* eslint-disable visual/complexity, max-lines, max-statements, max-lines-per-function */ import xmlbuilder from 'xmlbuilder'; import * as messages from '@cucumber/messages'; import { Attachment, Duration, getWorstTestStepResult, Pickle, Rule, TestStepResult, TestStepResultStatus, } from '@cucumber/messages'; import BaseReporter, { InternalOptions } from './base'; import { doesHaveValue, valueOrDefault } from '../../cucumber/valueChecker'; import { ITestCaseAttempt } from '../../cucumber/formatter/EventDataCollector'; import { getGherkinExampleRuleMap, getGherkinStepMap, } from '../../cucumber/formatter/GherkinDocumentParser'; import { getPickleStepMap, getStepKeyword } from '../../cucumber/formatter/PickleParser'; import { GherkinDocumentMessage } from './messagesBuilder/GherkinDocument'; import { getFeatureNameWithProject, TITLE_SEPARATOR } from './messagesBuilder/Projects'; type JunitReporterOptions = { outputFile?: string; suiteName?: string; }; interface IJUnitTestSuite { name: string; failures: number; skipped: number; time: number; tests: IJUnitTestCase[]; } interface IJUnitTestCase { classname: string; name: string; time: number; result: IJUnitTestCaseResult; systemOutput: string; steps: IJUnitTestStep[]; } interface IJUnitTestCaseFailure { type: string; message?: string; detail: string; } interface IJUnitTestCaseResult { status: TestStepResultStatus; failure?: IJUnitTestCaseFailure; } interface IJUnitTestStep { attachments: Attachment[]; hidden: boolean; keyword: string; line: number; name?: string; result: TestStepResult; time: number; } interface IBuildJUnitTestStepOptions { isBeforeHook: boolean; gherkinStepMap: Record; pickleStepMap: Record; testStep: messages.TestStep; testStepAttachments: messages.Attachment[]; testStepResult: messages.TestStepResult; } export default class JunitReporter extends BaseReporter { private readonly names: Record = {}; constructor( internalOptions: InternalOptions, protected userOptions: JunitReporterOptions = {}, ) { super(internalOptions); this.setOutputStream(this.userOptions.outputFile); this.eventBroadcaster.on('envelope', (envelope: messages.Envelope) => { if (doesHaveValue(envelope.testRunFinished)) { this.onTestRunFinished(); } }); } get suiteName() { return valueOrDefault(this.userOptions.suiteName, 'cucumber-js'); } private getTestCases() { return this.eventDataCollector .getTestCaseAttempts() .filter((attempt) => !attempt.willBeRetried); } private getTestSteps( testCaseAttempt: ITestCaseAttempt, gherkinStepMap: Record, pickleStepMap: Record, ) { return testCaseAttempt.testCase.testSteps.map((testStep) => { const isBeforeHook = !doesHaveValue(testStep.pickleStepId); return this.getTestStep({ isBeforeHook, gherkinStepMap, pickleStepMap, testStep, testStepAttachments: testCaseAttempt.stepAttachments[testStep.id], testStepResult: testCaseAttempt.stepResults[testStep.id], }); }); } private getTestStep({ isBeforeHook, gherkinStepMap, pickleStepMap, testStep, testStepAttachments, testStepResult, }: IBuildJUnitTestStepOptions): IJUnitTestStep { const data: Partial = {}; if (testStep.pickleStepId) { const pickleStep = pickleStepMap[testStep.pickleStepId]; data.keyword = getStepKeyword({ pickleStep, gherkinStepMap }); data.line = gherkinStepMap[pickleStep.astNodeIds[0]].location.line; data.name = pickleStep.text; } else { data.keyword = isBeforeHook ? 'Before' : 'After'; data.hidden = true; } data.result = testStepResult; data.time = testStepResult.duration ? this.durationToSeconds(testStepResult.duration) : 0; data.attachments = testStepAttachments; return data as IJUnitTestStep; } private getTestCaseResult(steps: IJUnitTestStep[]): IJUnitTestCaseResult { const { status, message, exception } = getWorstTestStepResult(steps.map((step) => step.result)); return { status, failure: message || exception ? { type: exception?.type || '', message: exception?.message, detail: message || '', } : undefined, }; } private durationToSeconds(duration: Duration): number { const NANOS_IN_SECOND = 1_000_000_000; return (duration.seconds * NANOS_IN_SECOND + duration.nanos) / NANOS_IN_SECOND; } private nameOrDefault(name: string | undefined, fallbackSuffix: string): string { if (!name) { return `(unnamed ${fallbackSuffix})`; } return name; } private getTestCaseName(featureName: string, rule: Rule | undefined, pickle: Pickle) { const pickleName = this.nameOrDefault(pickle.name, 'scenario'); const testCaseName = [ featureName, rule ? this.nameOrDefault(rule.name, 'rule') : '', pickleName, ] .filter(Boolean) .join(TITLE_SEPARATOR); if (!this.names[featureName]) { this.names[featureName] = []; } let index = 0; while ( this.names[featureName].includes(index > 0 ? `${testCaseName} [${index}]` : testCaseName) ) { index++; } const name = index > 0 ? `${testCaseName} [${index}]` : testCaseName; this.names[featureName].push(name); return name; } private formatTestSteps(steps: IJUnitTestStep[]): string { return steps .filter((step) => !step.hidden) .map((step) => { const statusText = step.result.status.toLowerCase(); const maxLength = 80 - statusText.length - 3; const stepText = `${step.keyword}${step.name}` .padEnd(maxLength, '.') .substring(0, maxLength); return `${stepText}...${statusText}`; }) .join('\n'); } private onTestRunFinished(): void { const testCases = this.getTestCases(); const tests = testCases.map((testCaseAttempt: ITestCaseAttempt) => { const { gherkinDocument, pickle } = testCaseAttempt; const { feature } = gherkinDocument; if (!feature) { throw new Error(`Gherkin document without feature: ${gherkinDocument.uri}`); } const meta = GherkinDocumentMessage.extractMeta(gherkinDocument); const gherkinExampleRuleMap = getGherkinExampleRuleMap(gherkinDocument); const rule = gherkinExampleRuleMap[pickle.astNodeIds[0]]; const gherkinStepMap = getGherkinStepMap(gherkinDocument); const pickleStepMap = getPickleStepMap(pickle); const steps = this.getTestSteps(testCaseAttempt, gherkinStepMap, pickleStepMap); const stepDuration = steps.reduce((total, step) => total + (step.time || 0), 0); const featureName = this.nameOrDefault(feature.name, 'feature'); const featureNameWithProject = getFeatureNameWithProject(meta.projectName, featureName); return { classname: featureName, // always add project to testcase name // see: https://github.com/microsoft/playwright/issues/23432 name: this.getTestCaseName(featureNameWithProject, rule, pickle), time: stepDuration, result: this.getTestCaseResult(steps), systemOutput: this.formatTestSteps(steps), steps, }; }); const passed = tests.filter( (item) => item.result.status === TestStepResultStatus.PASSED, ).length; const skipped = tests.filter( (item) => item.result.status === TestStepResultStatus.SKIPPED, ).length; const failures = tests.length - passed - skipped; const testSuite: IJUnitTestSuite = { name: this.suiteName, tests, failures, skipped, time: tests.reduce((total, test) => total + test.time, 0), }; this.outputStream.write(this.buildXmlReport(testSuite)); } private buildXmlReport(testSuite: IJUnitTestSuite): string { const xmlReport = xmlbuilder .create('testsuite', { encoding: 'UTF-8', invalidCharReplacement: '' }) .att('failures', testSuite.failures) .att('skipped', testSuite.skipped) .att('name', testSuite.name) .att('time', testSuite.time) .att('tests', testSuite.tests.length); testSuite.tests.forEach((test) => { const xmlTestCase = xmlReport.ele('testcase', { classname: test.classname, name: test.name, time: test.time, }); if (test.result.status === TestStepResultStatus.SKIPPED) { xmlTestCase.ele('skipped'); } else if (test.result.status !== TestStepResultStatus.PASSED) { const xmlFailure = xmlTestCase.ele('failure', { type: test.result.failure?.type, message: test.result.failure?.message, }); if (test.result?.failure) { xmlFailure.cdata(test.result.failure.detail); } } xmlTestCase.ele('system-out', {}).cdata(test.systemOutput); }); return xmlReport.end({ pretty: true }); } }