creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
270 lines • 10.9 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JUnitReporter = void 0;
const path_1 = require("path");
const fs_1 = require("fs");
const os_1 = __importDefault(require("os"));
const types_js_1 = require("../../types.js");
const logger_js_1 = require("../logger.js");
const creevey_js_1 = require("./creevey.js");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class IndentedLogger {
baseLog;
currentIndent = '';
constructor(baseLog) {
this.baseLog = baseLog;
}
indent() {
this.currentIndent += ' ';
}
unindent() {
this.currentIndent = this.currentIndent.substring(0, this.currentIndent.length - 4);
}
log(text) {
return this.baseLog(this.currentIndent + text);
}
}
// NOTE: This is a reworked copy of the JUnitReporter class from Vitest.
class JUnitReporter {
reportFile;
fileFd;
logger;
// @ts-expect-error Ignore unused
creeveyReporter;
suites = {};
runStartTime = new Date();
suiteStartTimes = {};
// TODO classnameTemplate
// TODO Output console logs
constructor(runner, options) {
const { reportDir, reporterOptions } = options;
this.reportFile = reporterOptions?.outputFile ?? (0, path_1.resolve)(reportDir, 'junit.xml');
this.logger = new IndentedLogger((text) => {
this.fileFd ??= (0, fs_1.openSync)(this.reportFile, 'w+');
(0, fs_1.writeFileSync)(this.fileFd, `${text}\n`);
});
this.creeveyReporter = new creevey_js_1.CreeveyReporter(runner);
runner.on(types_js_1.TEST_EVENTS.RUN_BEGIN, () => {
this.suites = {};
this.runStartTime = new Date();
this.suiteStartTimes = {};
const outputDirectory = (0, path_1.dirname)(this.reportFile);
if (!(0, fs_1.existsSync)(outputDirectory)) {
(0, fs_1.mkdirSync)(outputDirectory, { recursive: true });
}
this.fileFd = (0, fs_1.openSync)(this.reportFile, 'w+');
});
runner.on(types_js_1.TEST_EVENTS.TEST_BEGIN, (test) => {
this.suiteStartTimes[this.suiteKey(test)] ??= new Date();
});
runner.on(types_js_1.TEST_EVENTS.TEST_PASS, (test) => {
this.getOrCreateSuite(test).tests.set(test.creevey.testId, test);
});
runner.on(types_js_1.TEST_EVENTS.TEST_FAIL, (test) => {
this.getOrCreateSuite(test).tests.set(test.creevey.testId, test);
});
runner.on(types_js_1.TEST_EVENTS.RUN_END, () => {
this.onFinished();
});
}
suiteKey(test) {
return `${test.parent.title}\0${test.creevey.browserName}`;
}
getOrCreateSuite(test) {
const key = this.suiteKey(test);
this.suites[key] ??= { suiteName: test.parent.title, browserName: test.creevey.browserName, tests: new Map() };
return this.suites[key];
}
isImageMismatch(test) {
return Object.values(test.creevey.images).some((img) => img !== undefined);
}
writeElement(name, attrs, children, textContent) {
if (children !== undefined && textContent !== undefined) {
throw new Error('writeElement: pass either children or textContent, not both');
}
const pairs = [];
for (const key in attrs) {
const attr = attrs[key];
if (attr === undefined) {
continue;
}
pairs.push(`${key}="${escapeXML(attr)}"`);
}
this.logger.log(`<${name}${pairs.length ? ` ${pairs.join(' ')}` : ''}>`);
this.logger.indent();
if (textContent !== undefined) {
for (const line of escapeXML(textContent).split('\n')) {
this.logger.log(line);
}
}
else {
children?.call(this);
}
this.logger.unindent();
this.logger.log(`</${name}>`);
}
writeTasks(tests) {
for (const [, test] of tests) {
const classname = test.parent.title;
const attachments = test.attachments ?? [];
this.writeElement('testcase', {
classname,
name: test.title,
time: getDuration(test),
}, () => {
if (test.state === 'failed') {
this.writeFailureOrError(test);
}
this.writeAttachments(attachments);
});
}
}
normalizePath(filePath) {
return filePath.split(path_1.sep).join('/');
}
relativeAttachmentPath(absPath) {
return (0, path_1.relative)((0, path_1.dirname)(this.reportFile), absPath);
}
gitlabAttachmentPath(absPath) {
return this.normalizePath((0, path_1.relative)(process.env.CI_PROJECT_DIR ?? process.cwd(), absPath));
}
preferredGitlabAttachment(attachments) {
return attachments.find((absPath) => /-diff(?:-\d+)?\.png$/i.test(this.normalizePath(absPath))) ?? attachments[0];
}
writeAttachments(attachments) {
if (attachments.length === 0) {
return;
}
this.writeElement('properties', {}, () => {
for (const absPath of attachments) {
this.writeElement('property', {
name: 'attachment',
value: this.relativeAttachmentPath(absPath),
});
}
});
const gitlabAttachment = this.preferredGitlabAttachment(attachments);
if (gitlabAttachment) {
this.writeElement('system-out', {}, undefined, `[[ATTACHMENT|${this.gitlabAttachmentPath(gitlabAttachment)}]]`);
}
}
writeFailureOrError(test) {
if (this.isImageMismatch(test)) {
const bodyLines = Object.entries(test.creevey.images).map(([step, img]) => `${step}: ${img?.error ?? 'expected and actual images differ'}`);
this.writeElement('failure', { message: 'Images do not match' }, undefined, bodyLines.join('\n'));
}
else if (test.err) {
const firstLine = test.err.split('\n')[0];
const type = /^(\w+Error):/.exec(test.err)?.[1] ?? 'Error';
this.writeElement('error', { message: firstLine, type }, undefined, test.err);
}
else {
this.writeElement('failure', { message: 'Test failed' });
}
}
onFinished() {
this.logger.log('<?xml version="1.0" encoding="UTF-8" ?>');
const suites = Object.entries(this.suites).map(([key, { suiteName, browserName, tests }]) => {
let failures = 0;
let errors = 0;
let time = 0;
for (const [, test] of tests) {
if (test.state === 'failed') {
if (this.isImageMismatch(test))
failures++;
else if (test.err)
errors++;
else
failures++;
}
time += test.duration ?? 0;
}
return {
suiteName,
browserName,
tests,
failures,
errors,
time,
timestamp: (this.suiteStartTimes[key] ?? this.runStartTime).toISOString(),
};
});
const stats = suites.reduce((s, { tests, failures, errors, time }) => {
s.tests += tests.size;
s.failures += failures;
s.errors += errors;
s.time += time;
return s;
}, { name: 'creevey tests', tests: 0, failures: 0, errors: 0, time: 0 });
this.writeElement('testsuites', { ...stats, time: executionTime(stats.time), timestamp: this.runStartTime.toISOString() }, () => {
const hostname = os_1.default.hostname();
suites.forEach(({ suiteName, browserName, tests, failures, errors, time, timestamp }, index) => {
this.writeElement('testsuite', {
name: suiteName,
tests: tests.size,
failures,
errors,
time: executionTime(time),
hostname,
id: index,
timestamp,
}, () => {
this.writeElement('properties', {}, () => {
this.writeElement('property', { name: 'browser', value: browserName });
});
this.writeTasks(tests);
});
});
});
if (this.reportFile) {
(0, logger_js_1.logger)().info(`JUNIT report written to ${this.reportFile}`);
}
if (this.fileFd) {
(0, fs_1.closeSync)(this.fileFd);
this.fileFd = undefined;
}
}
}
exports.JUnitReporter = JUnitReporter;
// https://gist.github.com/john-doherty/b9195065884cdbfd2017a4756e6409cc
function removeInvalidXMLCharacters(value, removeDiscouragedChars) {
let regex =
// eslint-disable-next-line no-control-regex
/([\0-\x08\v\f\x0E-\x1F\uFFFD\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])/g;
value = String(value).replace(regex, '');
if (removeDiscouragedChars) {
// remove everything discouraged by XML 1.0 specifications
regex = new RegExp('([\\x7F-\\x84]|[\\x86-\\x9F]|[\\uFDD0-\\uFDEF]|\\uD83F[\\uDFFE\\uDFFF]|(?:\\uD87F[\\uDF' +
'FE\\uDFFF])|\\uD8BF[\\uDFFE\\uDFFF]|\\uD8FF[\\uDFFE\\uDFFF]|(?:\\uD93F[\\uDFFE\\uD' +
'FFF])|\\uD97F[\\uDFFE\\uDFFF]|\\uD9BF[\\uDFFE\\uDFFF]|\\uD9FF[\\uDFFE\\uDFFF]' +
'|\\uDA3F[\\uDFFE\\uDFFF]|\\uDA7F[\\uDFFE\\uDFFF]|\\uDABF[\\uDFFE\\uDFFF]|(?:\\' +
'uDAFF[\\uDFFE\\uDFFF])|\\uDB3F[\\uDFFE\\uDFFF]|\\uDB7F[\\uDFFE\\uDFFF]|(?:\\uDBBF' +
'[\\uDFFE\\uDFFF])|\\uDBFF[\\uDFFE\\uDFFF](?:[\\0-\\t\\v\\f\\x0E-\\u2027\\u202A-\\uD7FF\\' +
'uE000-\\uFFFF]|[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|' +
'(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF]))', 'g');
value = value.replace(regex, '');
}
return value;
}
function escapeXML(value) {
return removeInvalidXMLCharacters(String(value)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>'), true);
}
function executionTime(durationMS) {
return (durationMS / 1000).toLocaleString('en-US', {
useGrouping: false,
maximumFractionDigits: 10,
});
}
function getDuration(task) {
const duration = task.duration ?? 0;
return executionTime(duration);
}
//# sourceMappingURL=junit.js.map