playwright-testmo-reporter
Version:
A Playwright Reporter for the Testmo SaaS.
527 lines (524 loc) • 17.3 kB
JavaScript
// 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
};