creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
171 lines (149 loc) • 5.76 kB
text/typescript
import chalk from 'chalk';
import Logger from 'loglevel';
import prefix from 'loglevel-plugin-prefix';
import { Runner, reporters, MochaOptions } from 'mocha';
import { Images, isDefined, isImageError } from '../../types.js';
interface ReporterOptions {
reportDir: string;
sessionId: string;
topLevelSuite: string;
willRetry: boolean;
images: Partial<Record<string, Partial<Images>>>;
}
const testLevels: Record<string, string> = {
INFO: chalk.green('PASS'),
WARN: chalk.yellow('START'),
ERROR: chalk.red('FAIL'),
};
export class CreeveyReporter extends reporters.Base {
// TODO Output in better way, like vitest, maybe
constructor(runner: Runner, options: MochaOptions) {
super(runner);
const { sessionId, topLevelSuite } = options.reporterOptions as ReporterOptions;
const testLogger = Logger.getLogger(sessionId);
prefix.apply(testLogger, {
format(level) {
return `[${topLevelSuite}:${chalk.gray(process.pid)}] ${testLevels[level]} => ${chalk.gray(sessionId)}`;
},
});
runner.on('test', (test) => {
testLogger.warn(chalk.cyan(test.titlePath().join('/')));
});
runner.on('pass', (test) => {
testLogger.info(chalk.cyan(test.titlePath().join('/')));
});
runner.on('fail', (test, error) => {
testLogger.error(
chalk.cyan(test.titlePath().join('/')),
'\n ',
getErrors(
error,
(error, imageName) => `${chalk.bold(imageName ?? topLevelSuite)}:${error}`,
(error) => error.stack ?? error.message,
).join('\n '),
);
});
}
}
export class TeamcityReporter extends reporters.Base {
constructor(runner: Runner, options: MochaOptions) {
super(runner);
const topLevelSuite = this.escape((options.reporterOptions as ReporterOptions).topLevelSuite);
const reporterOptions = options.reporterOptions as ReporterOptions;
runner.on('suite', (suite) => {
if (suite.root) console.log(`##teamcity[testSuiteStarted name='${topLevelSuite}' flowId='${process.pid}']`);
else console.log(`##teamcity[testSuiteStarted name='${this.escape(suite.title)}' flowId='${process.pid}']`);
});
runner.on('test', (test) => {
console.log(`##teamcity[testStarted name='${this.escape(test.title)}' flowId='${process.pid}']`);
});
runner.on('fail', (test, error: Error) => {
Object.entries(reporterOptions.images).forEach(([name, image]) => {
if (!image) return;
const filePath = test
.titlePath()
.concat(name == topLevelSuite ? [] : [topLevelSuite])
.map(this.escape)
.join('/');
const { error: _, ...rest } = image;
Object.values(rest as Partial<Images>)
.filter(isDefined)
.forEach((fileName) => {
console.log(
`##teamcity[publishArtifacts '${reporterOptions.reportDir}/${filePath}/${fileName} => report/${filePath}']`,
);
console.log(
`##teamcity[testMetadata testName='${this.escape(
test.title,
)}' type='image' value='report/${filePath}/${fileName}' flowId='${process.pid}']`,
);
});
});
// Output failed test as passed due TC don't support retry mechanic
// https://teamcity-support.jetbrains.com/hc/en-us/community/posts/207216829-Count-test-as-successful-if-at-least-one-try-is-successful?page=1#community_comment_207394125
if (reporterOptions.willRetry)
console.log(`##teamcity[testFinished name='${this.escape(test.title)}' flowId='${process.pid}']`);
else
console.log(
`##teamcity[testFailed name='${this.escape(test.title)}' message='${this.escape(
error.message,
)}' details='${this.escape(error.stack ?? '')}' flowId='${process.pid}']`,
);
});
runner.on('pending', (test) => {
console.log(
`##teamcity[testIgnored name='${this.escape(test.title)}' message='${this.escape(
typeof test.skipReason == 'boolean' ? test.title : test.skipReason,
)}' flowId='${process.pid}']`,
);
});
runner.on('test end', (test) => {
console.log(`##teamcity[testFinished name='${this.escape(test.title)}' flowId='${process.pid}']`);
});
runner.on('suite end', (suite) => {
if (!suite.root)
console.log(`##teamcity[testSuiteFinished name='${this.escape(suite.title)}' flowId='${process.pid}']`);
});
runner.on('end', () => {
console.log(`##teamcity[testSuiteFinished name='${topLevelSuite}' flowId='${process.pid}']`);
});
}
private escape = (str: string): string => {
if (!str) return '';
return (
str
.toString()
// eslint-disable-next-line no-control-regex
.replace(/\x1B.*?m/g, '')
.replace(/\|/g, '||')
.replace(/\n/g, '|n')
.replace(/\r/g, '|r')
.replace(/\[/g, '|[')
.replace(/\]/g, '|]')
.replace(/\u0085/g, '|x')
.replace(/\u2028/g, '|l')
.replace(/\u2029/g, '|p')
.replace(/'/g, "|'")
);
};
}
function getErrors(
error: unknown,
imageErrorToString: (error: string, imageName?: string) => string,
errorToString: (error: Error) => string,
): string[] {
const errors = [];
if (!(error instanceof Error)) {
errors.push(error as string);
} else if (!isImageError(error)) {
errors.push(errorToString(error));
} else if (typeof error.images == 'string') {
errors.push(imageErrorToString(error.images));
} else {
const imageErrors = error.images;
Object.keys(imageErrors).forEach((imageName) => {
errors.push(imageErrorToString(imageErrors[imageName] ?? '', imageName));
});
}
return errors;
}