UNPKG

@wdio/spec-reporter

Version:
540 lines (537 loc) 20.5 kB
// 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 };