UNPKG

playwright-testmo-reporter

Version:
527 lines (524 loc) 17.3 kB
// src/reporter.ts import path2 from "path"; import fs from "fs"; import { XMLBuilder } from "fast-xml-parser"; // src/util.ts import path from "path"; import StackUtils from "stack-utils"; import url from "url"; import colors from "colors"; var kOutputSymbol = /* @__PURE__ */ Symbol("output"); function formatFailure(config, test, options = {}) { const { index, includeStdio, includeAttachments = true } = options; const lines = []; const title = formatTestTitle(config, test); const annotations = []; const header = formatTestHeader(config, test, { indent: " ", index, mode: "error" }); lines.push(colors.red(header)); for (const result of test.results) { const resultLines = []; const errors = formatResultFailure(test, result, " ", colors.enabled); if (!errors.length) continue; const retryLines = []; if (result.retry) { retryLines.push(""); retryLines.push(colors.gray(separator(` Retry #${result.retry}`))); } resultLines.push(...retryLines); resultLines.push(...errors.map((error) => "\n" + error.message)); if (includeAttachments) { for (let i = 0; i < result.attachments.length; ++i) { const attachment = result.attachments[i]; const hasPrintableContent = attachment.contentType?.startsWith("text/") && attachment.body; if (!attachment.path && !hasPrintableContent) continue; resultLines.push(""); resultLines.push( colors.cyan( separator( ` attachment #${i + 1}: ${attachment.name} (${attachment.contentType})` ) ) ); if (attachment.path) { const relativePath = path.relative(process.cwd(), attachment.path); resultLines.push(colors.cyan(` ${relativePath}`)); if (attachment.name === "trace") { resultLines.push(colors.cyan(` Usage:`)); resultLines.push(""); resultLines.push( colors.cyan(` npx playwright show-trace ${relativePath}`) ); resultLines.push(""); } } else { if (attachment.contentType.startsWith("text/") && attachment.body) { let text = attachment.body.toString(); if (text.length > 300) text = text.slice(0, 300) + "..."; for (const line of text.split("\n")) resultLines.push(colors.cyan(` ${line}`)); } } resultLines.push(colors.cyan(separator(" "))); } } const output = result[kOutputSymbol] || []; if (includeStdio && output.length) { const outputText = output.map(({ chunk, type }) => { const text = chunk.toString("utf8"); if (type === "stderr") return colors.red(stripAnsiEscapes(text)); return text; }).join(""); resultLines.push(""); resultLines.push( colors.gray(separator("--- Test output")) + "\n\n" + outputText + "\n" + separator() ); } for (const error of errors) { annotations.push({ location: error.location, title, message: [header, ...retryLines, error.message].join("\n") }); } lines.push(...resultLines); } lines.push(""); return { message: lines.join("\n"), annotations }; } function formatResultFailure(test, result, initialIndent, highlightCode) { const errorDetails = []; if (result.status === "passed" && test.expectedStatus === "failed") { errorDetails.push({ message: indent( colors.red(`Expected to fail, but passed.`), initialIndent ) }); } if (result.status === "interrupted") { errorDetails.push({ message: indent(colors.red(`Test was interrupted.`), initialIndent) }); } for (const error of result.errors) { const formattedError = formatError(error, highlightCode); errorDetails.push({ message: indent(formattedError.message, initialIndent), location: formattedError.location }); } return errorDetails; } function relativeFilePath(config, file) { return path.relative(config.rootDir, file) || path.basename(file); } function relativeTestPath(config, test) { return relativeFilePath(config, test.location.file); } function stepSuffix(step) { const stepTitles = step ? step.titlePath() : []; return stepTitles.map((t) => " \u203A " + t).join(""); } function formatTestTitle(config, test, step, omitLocation = false) { const [, projectName, , ...titles] = test.titlePath(); let location; if (omitLocation) location = `${relativeTestPath(config, test)}`; else location = `${relativeTestPath(config, test)}:${step?.location?.line ?? test.location.line}:${step?.location?.column ?? test.location.column}`; const projectTitle = projectName ? `[${projectName}] \u203A ` : ""; return `${projectTitle}${location} \u203A ${titles.join(" \u203A ")}${stepSuffix( step )}`; } function formatTestHeader(config, test, options = {}) { const title = formatTestTitle(config, test); const header = `${options.indent || ""}${options.index ? options.index + ") " : ""}${title}`; let fullHeader = header; if (options.mode === "error") { const stepPaths = /* @__PURE__ */ new Set(); for (const result of test.results.filter((r) => !!r.errors.length)) { const stepPath = []; const visit = (steps) => { const errors = steps.filter((s) => s.error); if (errors.length > 1) return; if (errors.length === 1 && errors[0].category === "test.step") { stepPath.push(errors[0].title); visit(errors[0].steps); } }; visit(result.steps); stepPaths.add(["", ...stepPath].join(" \u203A ")); } fullHeader = header + (stepPaths.size === 1 ? stepPaths.values().next().value : ""); } return separator(fullHeader); } function formatError(error, highlightCode) { const message = error.message || error.value || ""; const stack = error.stack; if (!stack && !error.location) return { message }; const tokens = []; const parsedStack = stack ? prepareErrorStack(stack) : void 0; tokens.push(parsedStack?.message || message); if (error.snippet) { let snippet = error.snippet; if (!highlightCode) snippet = stripAnsiEscapes(snippet); tokens.push(""); tokens.push(snippet); } if (parsedStack) { tokens.push(""); tokens.push(colors.dim(parsedStack.stackLines.join("\n"))); } let location = error.location; if (parsedStack && !location) location = parsedStack.location; return { location, message: tokens.join("\n") }; } function separator(text = "") { if (text) text += " "; const columns = Math.min(100, process.stdout?.columns || 100); return text + colors.dim("\u2500".repeat(Math.max(0, columns - text.length))); } function indent(lines, tab) { return lines.replace(/^(?=.+$)/gm, tab); } var stackUtils = new StackUtils({ internals: StackUtils.nodeInternals() }); function parseStackTraceLine(line) { const frame = stackUtils.parseLine(line); if (!frame) return null; if (!process.env.PWDEBUGIMPL && (frame.file?.startsWith("internal") || frame.file?.startsWith("node:"))) return null; if (!frame.file) return null; const file = frame.file.startsWith("file://") ? url.fileURLToPath(frame.file) : path.resolve(process.cwd(), frame.file); return { file, line: frame.line || 0, column: frame.column || 0, function: frame.function }; } function prepareErrorStack(stack) { const lines = stack.split("\n"); let firstStackLine = lines.findIndex((line) => line.startsWith(" at ")); if (firstStackLine === -1) firstStackLine = lines.length; const message = lines.slice(0, firstStackLine).join("\n"); const stackLines = lines.slice(firstStackLine); let location; for (const line of stackLines) { const frame = parseStackTraceLine(line); if (!frame || !frame.file) continue; if (belongsToNodeModules(frame.file)) continue; location = { file: frame.file, column: frame.column || 0, line: frame.line || 0 }; break; } return { message, stackLines, location }; } var ansiRegex = new RegExp( // eslint-disable-next-line no-control-regex "([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))", "g" ); function stripAnsiEscapes(str) { return str.replace(ansiRegex, ""); } function belongsToNodeModules(file) { return file.includes(`${path.sep}node_modules${path.sep}`); } // src/reporter.ts import assert from "assert"; var TestmoReporter = class { outputFile; embedBrowserType; embedTestSteps; testStepCategories; testTitleDepth; includeTestSubFields; attachmentBasePathCallback; config; suite; timestamp; startTime; totalTests = 0; totalFailures = 0; totalSkipped = 0; constructor({ outputFile, embedBrowserType, embedTestSteps, testStepCategories, testTitleDepth, includeTestSubFields, attachmentBasePathCallback } = {}) { this.outputFile = outputFile ?? "testmo.xml"; this.embedBrowserType = embedBrowserType ?? false; this.embedTestSteps = embedTestSteps ?? true; this.testStepCategories = testStepCategories ?? [ "hook", "expect", "pw:api", "test.step" ]; this.testTitleDepth = testTitleDepth ?? 1; this.includeTestSubFields = includeTestSubFields ?? false; this.attachmentBasePathCallback = attachmentBasePathCallback; } onBegin(config, suite) { this.config = config; this.suite = suite; this.timestamp = /* @__PURE__ */ new Date(); this.startTime = this.getExactTime(); } onEnd() { const duration = this.getExactTime() - this.startTime; const children = []; for (const projectSuite of this.suite.suites) { for (const fileSuite of projectSuite.suites) { children.push(this._buildTestSuite(projectSuite.title, fileSuite)); } } const root = { "@_name": process.env["PLAYWRIGHT_TESTMO_SUITE_NAME"] ?? void 0, "@_tests": this.totalTests, "@_failures": this.totalFailures, "@_skipped": this.totalSkipped, "@_errors": 0, "@_assertions": 0, "@_time": duration / 1e3, "@_timestamp": this.timestamp.toISOString(), testsuite: children }; const xmlBuilder = new XMLBuilder({ arrayNodeName: "testsuites", format: true, ignoreAttributes: false, cdataPropName: "cdata" }); const xml = xmlBuilder.build([root]); if (this.outputFile) { assert( this.config.configFile || path2.isAbsolute(this.outputFile), "Expected fully resolved path if not using config file." ); const outputFile = this.config.configFile ? path2.resolve(path2.dirname(this.config.configFile), this.outputFile) : this.outputFile; fs.mkdirSync(path2.dirname(outputFile), { recursive: true }); fs.writeFileSync(outputFile, xml); } } _buildTestSuite(projectName, suite) { let tests = 0; let skipped = 0; let failures = 0; let duration = 0; const testcases = []; suite.allTests().forEach((test) => { ++tests; if (test.outcome() === "skipped") ++skipped; if (!test.ok()) ++failures; for (const result of test.results) duration += result.duration; testcases.push(this._buildTestCase(suite, test)); }); this.totalTests += tests; this.totalFailures += failures; this.totalSkipped += skipped; return { "@_name": suite.title, "@_hostname": projectName, "@_tests": tests, "@_failures": failures, "@_skipped": skipped, "@_errors": 0, "@_assertions": 0, "@_time": duration / 1e3, "@_timestamp": this.timestamp.toISOString(), "@_file": suite.location?.file, testcase: testcases }; } _buildTestCase(suite, testCase) { const titlePathArray = testCase.titlePath(); const titlePath = titlePathArray.slice(titlePathArray.length - this.testTitleDepth).join(" \u203A "); const entry = { "@_name": titlePath, "@_classname": suite.title, "@_assertions": 0, "@_time": testCase.results.reduce((acc, result) => acc + result.duration, 0) / 1e3, "@_file": testCase.location.file, "@_line": testCase.location.line }; if (testCase.outcome() === "skipped") { entry["skipped"] = null; } if (!testCase.ok()) { entry["failure"] = { "@_message": `${path2.basename(testCase.location.file)}:${testCase.location.line}:${testCase.location.column} ${testCase.title}`, cdata: stripAnsiEscapes(formatFailure(this.config, testCase).message) }; } const properties = []; this.addAnnotationsToProperties(testCase, properties); this.addBrowserToProperties(suite, properties); this.addStepsToProperties(testCase, properties); const systemOut = []; const systemErr = []; for (const result of testCase.results) { systemOut.push(...result.stdout.map((item) => item.toString())); systemErr.push(...result.stderr.map((item) => item.toString())); this.addAttachmentsToProperties(result, properties); } if (properties.length > 0) { entry["properties"] = { property: properties }; } if (systemOut.length) entry["system-out"] = { cdata: systemOut.join("") }; if (systemErr.length) entry["system-err"] = { cdata: systemErr.join("") }; return entry; } addAttachmentsToProperties(result, properties) { for (const attachment of result.attachments) { if (!attachment.path) { continue; } try { if (attachment.path.startsWith("https://")) { properties.push({ "@_name": "url:attachment", "@_value": attachment.path }); continue; } let attachmentPath = path2.relative( this.config.rootDir, attachment.path ); if (this.attachmentBasePathCallback) { const basePath = path2.normalize(attachmentPath).replace(/^(\.\.(\/|\\|$))+/, ""); attachmentPath = this.attachmentBasePathCallback(basePath); } if (!fs.existsSync(attachment.path)) { continue; } properties.push({ "@_name": "attachment", "@_value": attachmentPath }); } catch (e) { console.error(e); } } } addAnnotationsToProperties(testCase, properties) { if (testCase.annotations.length > 0) { properties.push( ...testCase.annotations.map((annotation) => { if (annotation.type.startsWith("html:")) { return { "@_name": annotation.type, cdata: annotation.description }; } return { "@_name": annotation.type, "@_value": annotation.description }; }) ); } } addStepsToProperties(testCase, properties) { if (!this.embedTestSteps) return; const lastResult = testCase.results.at(-1); if (!lastResult) return; const stepProperties = lastResult.steps.map( (step) => this.createStepProperty(step) ); properties.push(...stepProperties); } createStepProperty(step) { let stepStatus = "passed"; if (step.error) stepStatus = "failure"; if (!this.testStepCategories.includes(step.category)) { return; } if (step.steps.length > 0 && this.includeTestSubFields) { return { "@_name": `step[${stepStatus}]`, cdata: this.createStepHtml(step) }; } return { "@_name": `step[${stepStatus}]`, "@_value": step.title }; } createStepHtml(step) { if (!this.testStepCategories.includes(step.category)) { return; } const nextSteps = step.steps.map((step2) => this.createStepHtml(step2)).join(""); const { stepTitle, stepBody } = this.getStepTitleAndBodyFromStep(step); return `<test-step-subfield name="${stepTitle}">${stepBody}</test-step-subfield>${nextSteps}`; } getStepTitleAndBodyFromStep(step) { let stepTitle = ""; let stepBody = ""; if (step.category === "hook") { stepTitle = `Hook`; stepBody = step.title; } if (step.category === "pw:api") { stepTitle = `Playwright API`; stepBody = step.title; } if (step.category === "fixture") { stepTitle = `Fixture`; stepBody = step.title; } if (step.category === "test.step") { stepTitle = `Step`; stepBody = step.title; } if (step.category === "expect") { stepTitle = `Expected`; stepBody = step.title; } if (stepTitle === "") stepTitle = step.title; return { stepTitle, stepBody }; } addBrowserToProperties(suite, properties) { if (this.embedBrowserType) { const browser = suite.project().use.defaultBrowserType; properties.push({ "@_name": `browser`, "@_value": browser }); } } printsToStdio() { return false; } getExactTime() { const [seconds, nanoseconds] = process.hrtime(); return seconds * 1e3 + (nanoseconds / 1e3 | 0) / 1e3; } }; var reporter_default = TestmoReporter; export { TestmoReporter, reporter_default as default };