playwright-bdd
Version:
BDD Testing with Playwright runner
300 lines (269 loc) • 9.55 kB
text/typescript
/**
* 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<string, messages.Step>;
pickleStepMap: Record<string, messages.PickleStep>;
testStep: messages.TestStep;
testStepAttachments: messages.Attachment[];
testStepResult: messages.TestStepResult;
}
export default class JunitReporter extends BaseReporter {
private readonly names: Record<string, string[]> = {};
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<string, messages.Step>,
pickleStepMap: Record<string, messages.PickleStep>,
) {
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<IJUnitTestStep> = {};
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<IJUnitTestCase>((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 });
}
}