@wdio/spec-reporter
Version:
A WebdriverIO plugin to report in spec style
540 lines (537 loc) • 20.5 kB
JavaScript
// src/index.ts
import { format } from "node:util";
import prettyMs from "pretty-ms";
import { Chalk } from "chalk";
import WDIOReporter, { TestStats, getBrowserName } from "@wdio/reporter";
// src/utils.ts
import Table from "easy-table";
import { createHmac } from "node:crypto";
var SEPARATOR = "\u2502";
var buildTableData = (rows) => rows.map((row) => {
const tableRow = {};
[...row.cells, ""].forEach((cell, idx) => {
tableRow[idx] = (idx === 0 ? `${SEPARATOR} ` : "") + cell;
});
return tableRow;
});
var printTable = (data) => Table.print(data, void 0, (table) => {
table.separator = ` ${SEPARATOR} `;
return table.print();
});
var getFormattedRows = (table, testIndent) => table.split("\n").filter(Boolean).map((line) => `${testIndent} ${line}`.trimRight());
var sauceAuthenticationToken = (user, key, sessionId) => {
const secret = `${user}:${key}`;
const token = createHmac("md5", secret).update(sessionId).digest("hex");
return `?auth=${token}`;
};
// src/index.ts
var DEFAULT_INDENT = " ";
var SpecReporter = class extends WDIOReporter {
_suiteUids = /* @__PURE__ */ new Set();
_indents = 0;
_suiteIndents = {};
_orderedSuites = [];
_consoleOutput = "";
_suiteIndent = "";
_preface = "";
_consoleLogs = [];
_pendingReasons = [];
_originalStdoutWrite = process.stdout.write.bind(process.stdout);
_addConsoleLogs = false;
_realtimeReporting = false;
_showPreface = true;
_suiteName = "";
_isSuiteRetry = false;
_passingTestsSinceLastRetry = 0;
// Keep track of the order that suites were called
_stateCounts = {
passed: 0,
failed: 0,
skipped: 0,
pending: 0,
retried: 0
};
_symbols = {
passed: "\u2713",
skipped: "-",
pending: "?",
failed: "\u2716",
retried: "\u21BB"
};
_chalk;
_onlyFailures = false;
_sauceLabsSharableLinks = true;
constructor(options) {
super(Object.assign({ stdout: true }, options));
this._symbols = { ...this._symbols, ...this.options.symbols || {} };
this._onlyFailures = options.onlyFailures || false;
this._realtimeReporting = options.realtimeReporting || false;
this._showPreface = options.showPreface !== false;
this._sauceLabsSharableLinks = "sauceLabsSharableLinks" in options ? options.sauceLabsSharableLinks : this._sauceLabsSharableLinks;
const processObj = process;
if (options.addConsoleLogs || this._addConsoleLogs) {
processObj.stdout.write = (chunk, encoding, callback) => {
if (typeof chunk === "string" && !chunk.includes("mwebdriver")) {
this._consoleOutput += chunk;
}
return this._originalStdoutWrite(chunk, encoding, callback);
};
}
this._chalk = new Chalk(options.color === false ? { level: 0 } : {});
}
/**
* @param state state of test execution
* @param msg the message to print in terminal
* @returns colord value based on chalk to print in terminal
*/
setMessageColor(message, state) {
return this._chalk[this.getColor(state)](message);
}
onRunnerStart(runner) {
this._preface = this._showPreface ? `[${this.getEnviromentCombo(runner.capabilities, false, runner.isMultiremote).trim()} #${runner.cid}]` : "";
}
onSuiteStart(suite) {
this._suiteName = suite.file?.replace(process.cwd(), "");
this.printCurrentStats(suite);
this._suiteUids.add(suite.uid);
if (suite.type === "feature") {
this._indents = 0;
this._suiteIndents[suite.uid] = this._indents;
} else {
this._suiteIndents[suite.uid] = ++this._indents;
}
}
onSuiteEnd() {
this._indents--;
this._isSuiteRetry = false;
this._passingTestsSinceLastRetry = 0;
}
onSuiteRetry() {
this._stateCounts.failed--;
this._stateCounts.retried++;
this._stateCounts.passed -= this._passingTestsSinceLastRetry;
this._isSuiteRetry = true;
this._passingTestsSinceLastRetry = 0;
}
onHookEnd(hook) {
this.printCurrentStats(hook);
if (hook.error) {
this._stateCounts.failed++;
}
}
onTestStart() {
this._consoleOutput = "";
}
onTestPass(testStat) {
this.printCurrentStats(testStat);
this._consoleLogs.push(this._consoleOutput);
this._stateCounts.passed++;
if (!this._isSuiteRetry) {
this._passingTestsSinceLastRetry++;
}
}
onTestFail(testStat) {
this.printCurrentStats(testStat);
this._consoleLogs.push(this._consoleOutput);
this._stateCounts.failed++;
}
onTestSkip(testStat) {
this.printCurrentStats(testStat);
this._pendingReasons.push(testStat.pendingReason);
this._consoleLogs.push(this._consoleOutput);
this._stateCounts.skipped++;
}
onTestPending(testStat) {
this.printCurrentStats(testStat);
this._pendingReasons.push(testStat.pendingReason);
this._consoleLogs.push(this._consoleOutput);
this._stateCounts.pending++;
}
onRunnerEnd(runner) {
this.printReport(runner);
}
/**
* Print the report to the stdout realtime
*/
printCurrentStats(stat) {
if (!this._realtimeReporting) {
return;
}
const title = stat.title, state = stat.state;
const divider = "------------------------------------------------------------------";
const indent = stat.type === "test" ? `${DEFAULT_INDENT}${this._suiteIndent}` : this.indent(stat.uid);
const suiteStartBanner = stat.type === "feature" || stat.type === "suite" || stat.type === "suite:start" ? `${this._preface} ${divider}
${this._preface} Suite started:
${this._preface} \xBB ${this._suiteName}
` : "\n";
const content = stat.type === "test" ? `${this._preface} ${indent}${this.setMessageColor(this.getSymbol(state), state)} ${title} \xBB ${this.setMessageColor("[", state)} ${this._suiteName} ${this.setMessageColor("]", state)}` : stat.type !== "hook" ? `${suiteStartBanner}${this._preface} ${title}` : title ? `${this._preface} Hook executed: ${title}` : void 0;
if (process.send && content && !process.env.WDIO_UNIT_TESTS) {
process.send({ name: "reporterRealTime", content });
}
}
/**
* Print the report to the screen
*/
printReport(runner) {
if (runner.failures === 0 && this._onlyFailures === true) {
return;
}
const duration = `(${prettyMs(runner._duration)})`;
const preface = `[${this.getEnviromentCombo(runner.capabilities, false, runner.isMultiremote).trim()} #${runner.cid}]`;
const divider = "------------------------------------------------------------------";
const results = this.getResultDisplay(preface);
if (results.length === 0 && runner.error) {
results.push(
this.setMessageColor(`${this.getSymbol("failed" /* FAILED */)} Failed to create a session:`, "failed" /* FAILED */),
runner.error,
""
);
}
if (results.length === 0) {
return;
}
const testLinks = runner.isMultiremote ? Object.entries(runner.capabilities).map(([instanceName, capabilities]) => this.getTestLink({
capabilities,
sessionId: capabilities.sessionId,
isMultiremote: runner.isMultiremote,
instanceName
})).filter((links) => links.length) : this.getTestLink(runner);
const output = [
...this.getHeaderDisplay(runner),
"",
...results,
...this.getCountDisplay(duration),
...this.getFailureDisplay(),
...testLinks.length ? ["", ...testLinks] : []
];
const prefacedOutput = this._showPreface ? output.map((value) => {
return value ? `${preface} ${value}` : preface;
}) : output;
this.write(`${divider}
${prefacedOutput.join("\n")}
`);
}
/**
* get link to saucelabs job
*/
getTestLink({ sessionId, isMultiremote, instanceName, capabilities }) {
const config = this.runnerStat && this.runnerStat.instanceOptions[sessionId];
const isSauceJob = config && config.hostname && config.hostname.includes("saucelabs") || // only show if multiremote is not used
capabilities["sauce:options"];
if (isSauceJob && config && config.user && config.key && sessionId) {
const multiremoteNote = isMultiremote ? ` ${instanceName}` : "";
const note = "Check out%s job at %s";
if ("testobject_test_report_url" in capabilities) {
return [format(note, multiremoteNote, capabilities.testobject_test_report_url)];
}
const isUSEast4 = ["us-east-4"].includes(config?.region || "") || config.hostname?.includes("us-east-4");
const isEUCentral = ["eu", "eu-central-1"].includes(config?.region || "") || config.hostname?.includes("eu-central");
const dc = isUSEast4 ? ".us-east-4" : isEUCentral ? ".eu-central-1" : "";
const sauceLabsSharableLinks = this._sauceLabsSharableLinks ? sauceAuthenticationToken(config.user, config.key, sessionId) : "";
const sauceUrl = `https://app${dc}.saucelabs.com/tests/${sessionId}${sauceLabsSharableLinks}`;
return [format(note, multiremoteNote, sauceUrl)];
}
return [];
}
/**
* Get the header display for the report
* @param {Object} runner Runner data
* @return {Array} Header data
*/
getHeaderDisplay(runner) {
const combo = this.getEnviromentCombo(runner.capabilities, void 0, runner.isMultiremote).trim();
const output = [`Running: ${combo}`];
if (runner.capabilities.sessionId) {
output.push(`Session ID: ${runner.capabilities.sessionId}`);
}
return output;
}
/**
* returns everything worth reporting from a suite
* @param {Object} suite test suite containing tests and hooks
* @return {Object[]} list of events to report
*/
getEventsToReport(suite) {
return [
/**
* Generate a report that shows all tests except those that failed but passed on retry, and only display failed hooks.
*/
...suite.hooksAndTests.reduce((accumulator, currentItem) => {
if (currentItem instanceof TestStats) {
const existingTestIndex = accumulator.findIndex((test) => test instanceof TestStats && test.fullTitle === currentItem.fullTitle);
if (existingTestIndex === -1) {
accumulator.push(currentItem);
} else {
const existingTest = accumulator[existingTestIndex];
if (currentItem.retries !== void 0 && existingTest.retries !== void 0) {
if (currentItem.retries > existingTest.retries) {
accumulator.splice(existingTestIndex, 1, currentItem);
} else {
accumulator.push(currentItem);
}
}
}
} else {
accumulator.push(currentItem);
}
return accumulator;
}, []).filter((item) => Object.keys(item).length > 0).filter((item) => {
return item.type === "test" || Boolean(item.error);
})
];
}
/**
* Get the results from the tests
* @param {Array} suites Runner suites
* @return {Array} Display output list
*/
getResultDisplay(prefaceString) {
const output = [];
const preface = this._showPreface ? prefaceString : "";
const suites = this.getOrderedSuites();
const specFileReferences = [];
for (const suite of suites) {
if (suite.tests.length === 0 && suite.suites.length === 0 && suite.hooks.length === 0) {
continue;
}
const suiteIndent = this.indent(suite.uid);
if (suite.file && !specFileReferences.includes(suite.file)) {
output.push(`${suiteIndent}\xBB ${suite.file.replace(process.cwd(), "").slice(1)}`);
specFileReferences.push(suite.file);
}
let retryAnnotation = "";
if (suite.retries > 0) {
retryAnnotation = this._chalk.yellow(` (${suite.retries}x retries)`);
}
output.push(`${suiteIndent}${suite.title}${retryAnnotation}`);
if (suite.description) {
output.push(...suite.description.trim().split("\n").map((l) => `${suiteIndent}${this.setMessageColor(l.trim())}`));
output.push("");
}
if (suite.rule) {
output.push(...suite.rule.trim().split("\n").map((l) => `${suiteIndent}${this.setMessageColor(l.trim())}`));
}
const eventsToReport = this.getEventsToReport(suite);
for (const test of eventsToReport) {
const testRetryAnnotation = test instanceof TestStats && test.retries && test.retries > 0 ? this._chalk.yellow(`(${test.retries}x retries)`) : "";
const testTitle = `${test.title} ${testRetryAnnotation}`;
const state = test.state;
const testIndent = `${DEFAULT_INDENT}${suiteIndent}`;
output.push(`${testIndent}${this.setMessageColor(this.getSymbol(state), state)} ${testTitle.trim()}`);
const arg = test.argument;
if (typeof arg === "string") {
const docstringIndent = " ";
const docstringMark = `${testIndent}${docstringIndent}"""`;
const docstring = String(arg);
const formattedDocstringLines = docstring.split("\n").filter((line) => line).map((line) => `${testIndent}${docstringIndent}${line}`);
output.push(...[docstringMark, ...formattedDocstringLines, docstringMark]);
} else {
const dataTable = arg;
if (dataTable && dataTable.rows && dataTable.rows.length) {
const data = buildTableData(dataTable.rows);
const rawTable = printTable(data);
const table = getFormattedRows(rawTable, testIndent);
output.push(...table);
}
}
const pendingItem = this._pendingReasons.shift();
if (pendingItem) {
output.push("");
output.push(testIndent.repeat(2) + ".........Pending Reasons.........");
output.push(testIndent.repeat(3) + pendingItem?.replace(/\n/g, "\n".concat(preface + " ", testIndent.repeat(3))));
}
const logItem = this._consoleLogs.shift();
if (logItem) {
output.push("");
output.push(testIndent.repeat(2) + ".........Console Logs.........");
output.push(testIndent.repeat(3) + logItem?.replace(/\n/g, "\n".concat(preface + " ", testIndent.repeat(3))));
}
}
if (eventsToReport.length) {
output.push("");
}
}
return output;
}
/**
* Get the display for passing, failing and skipped
* @param {string} duration Duration string
* @return {Array} Count display
*/
getCountDisplay(duration) {
const output = [];
if (this._stateCounts.passed > 0) {
const text = `${this._stateCounts.passed} passing ${duration}`;
output.push(this.setMessageColor(text, "passed" /* PASSED */));
duration = "";
}
if (this._stateCounts.failed > 0) {
const text = `${this._stateCounts.failed} failing ${duration}`.trim();
output.push(this.setMessageColor(text, "failed" /* FAILED */));
duration = "";
}
if (this._stateCounts.skipped > 0) {
const text = `${this._stateCounts.skipped} skipped ${duration}`.trim();
output.push(this.setMessageColor(text, "skipped" /* SKIPPED */));
}
if (this._stateCounts.pending > 0) {
const text = `${this._stateCounts.pending} pending ${duration}`.trim();
output.push(this.setMessageColor(text, "pending" /* PENDING */));
duration = "";
}
if (this._stateCounts.retried > 0) {
const text = `${this._stateCounts.retried} retried ${duration}`.trim();
output.push(this.setMessageColor(text, "retried" /* RETRIED */));
}
return output;
}
/**
* Get display for failed tests, e.g. stack trace
* @return {Array} Stack trace output
*/
getFailureDisplay() {
let failureLength = 0;
const output = [];
const suites = this.getOrderedSuites();
for (const suite of suites) {
const suiteTitle = suite.title;
const eventsToReport = this.getEventsToReport(suite);
for (const test of eventsToReport) {
if (test.state !== "failed" /* FAILED */) {
continue;
}
const testTitle = test.title;
const errors = test.errors || (test.error ? [test.error] : []);
output.push(
"",
`${++failureLength}) ${suiteTitle} ${testTitle}`
);
for (const error of errors) {
if (!error?.stack?.includes("new AssertionError")) {
output.push(this.setMessageColor(error.message, "failed" /* FAILED */));
} else {
output.push(...error.message.split("\n"));
}
if (error.stack) {
output.push(...error.stack.split(/\n/g).map((value) => this.setMessageColor(value)));
}
}
}
}
return output;
}
/**
* Get suites in the order they were called
* @return {Array} Ordered suites
*/
getOrderedSuites() {
if (this._orderedSuites.length) {
return this._orderedSuites;
}
this._orderedSuites = [];
for (const uid of this._suiteUids) {
for (const [suiteUid, suite] of Object.entries(this.suites)) {
if (suiteUid !== uid) {
continue;
}
this._orderedSuites.push(suite);
}
}
const rootSuite = this.currentSuites[0];
if (rootSuite) {
const baseRootSuite = {
...rootSuite,
type: "suite",
title: "(root)",
fullTitle: "(root)",
suites: []
};
const beforeAllHooks = rootSuite.hooks.filter((hook) => hook.state && hook.title.startsWith('"before') && hook.title.endsWith('"{root}"'));
const afterAllHooks = rootSuite.hooks.filter((hook) => hook.state && hook.title.startsWith('"after') && hook.title.endsWith('"{root}"'));
this._orderedSuites.unshift(Object.assign({}, baseRootSuite, {
hooks: beforeAllHooks,
hooksAndTests: beforeAllHooks
}));
this._orderedSuites.push(Object.assign({}, baseRootSuite, {
hooks: afterAllHooks,
hooksAndTests: afterAllHooks
}));
}
return this._orderedSuites;
}
/**
* Indent a suite based on where how it's nested
* @param {string} uid Unique suite key
* @return {String} Spaces for indentation
*/
indent(uid) {
const indents = this._suiteIndents[uid];
return indents === 0 ? "" : Array(indents).join(" ");
}
/**
* Get a symbol based on state
* @param {string} state State of a test
* @return {String} Symbol to display
*/
getSymbol(state) {
return state && this._symbols[state] || "?";
}
/**
* Get a color based on a given state
* @param {string} state Test state
* @return {String} State color
*/
getColor(state) {
let color = "gray" /* GRAY */;
switch (state) {
case "passed" /* PASSED */:
color = "green" /* GREEN */;
break;
case "pending" /* PENDING */:
case "skipped" /* SKIPPED */:
color = "cyan" /* CYAN */;
break;
case "failed" /* FAILED */:
color = "red" /* RED */;
break;
case "retried" /* RETRIED */:
color = "yellow" /* YELLOW */;
break;
}
return color;
}
/**
* Get information about the enviroment
* @param capability
* @param {Boolean} verbose
* @param isMultiremote
* @return {String} Enviroment string
*/
getEnviromentCombo(capability, verbose = true, isMultiremote = false) {
if (isMultiremote) {
const browserNames = Object.values(capability).map((c) => c.browserName);
const browserName = browserNames.length > 1 ? `${browserNames.slice(0, -1).join(", ")} and ${browserNames.pop()}` : browserNames.pop();
return `MultiremoteBrowser on ${browserName}`;
}
const caps = "alwaysMatch" in capability ? capability.alwaysMatch : capability;
const device = caps["appium:deviceName"];
const browser = getBrowserName(caps);
const version = caps.browserVersion || caps.version || caps["appium:platformVersion"] || caps.browser_version;
const platform = caps.platformName || caps["appium:platformName"] || caps.platform || (caps.os ? caps.os + (caps.os_version ? ` ${caps.os_version}` : "") : "(unknown)");
if (device) {
const program = getBrowserName(caps);
const executing = program ? `executing ${program}` : "";
if (!verbose) {
return `${device} ${platform} ${version}`;
}
return `${device} on ${platform} ${version} ${executing}`.trim();
}
if (!verbose) {
return (browser + (version ? ` ${version} ` : " ") + platform).trim();
}
return browser + (version ? ` (v${version})` : "") + ` on ${platform}`;
}
};
export {
SpecReporter as default
};