mattermost-mobile-upstream/detox/utils/maestro_report.js
yasser khan bcac248ec0
feat(e2e): Integrate TSIO reporting (#9925)
Retire legacy maestro per-platform contexts from cancel-e2e-runs and
document the canonical PR, master, and CMT status context mapping.
Wire Detox + Maestro (PR/main/CMT) into Test System IO with a single
commit status (e2e/mobile; CMT uses e2e/compatibility-matrix-testing)
and groups mobile-pr / mobile-main / cmt-mobile.

Upload shard results with correct framework labels, merge Jest shards
into TSIO Detox shape, stub missing/dead shards as failures, and post
channel summaries after status settles. Matterwick MASTER is treated as
MAIN for mobile-main routing.

Also harden report/fail paths, stage Maestro screenshots per flow for
TSIO galleries, fix a few Detox setup/testID issues, and skip known
platform flakes so the pipeline stays actionable while TSIO lands.
2026-07-23 06:43:28 +00:00

868 lines
34 KiB
JavaScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
const path = require('path');
const fse = require('fs-extra');
const xml2js = require('xml2js');
/**
* Parse a Maestro JUnit XML file and return a summary object
* matching the shape produced by report.js generateShortSummary().
*
* @param {string} xmlPath - Path to maestro-report.xml
* @returns {{stats: object, statsFieldValue: string, flows: Array} | null}
*/
function parseMaestroReport(xmlPath) {
if (!fse.existsSync(xmlPath)) {
console.log(`Maestro report not found at ${xmlPath}`);
return null;
}
const xml = fse.readFileSync(xmlPath, 'utf-8');
let parsed;
xml2js.parseString(xml, {mergeAttrs: true}, (err, result) => {
if (err) {
console.log('Failed to parse Maestro XML:', err.message);
return;
}
parsed = result;
});
if (!parsed || !parsed.testsuites) {
console.log('Maestro XML has unexpected structure');
return null;
}
const testsuites = parsed.testsuites;
let tests = 0;
let failures = 0;
let errors = 0;
let skipped = 0;
let duration = 0;
const flows = [];
const failedNames = [];
const suites = testsuites.testsuite || [];
for (const suite of suites) {
tests += parseInt(suite.tests?.[0] || '0', 10);
failures += parseInt(suite.failures?.[0] || '0', 10);
errors += parseInt(suite.errors?.[0] || '0', 10);
skipped += parseInt(suite.skipped?.[0] || '0', 10);
duration += parseFloat(suite.time?.[0] || '0') * 1000;
const testcases = suite.testcase || [];
for (const tc of testcases) {
const name = tc.name?.[0] || 'unknown';
const classname = tc.classname?.[0] || suite.name?.[0] || '';
// The XML's `file` attribute is the flow's full path
// (e.g. "detox/maestro/flows/calls/start_call.yml"). We extract the
// category (parent directory under flows/) so screenshots whose
// filename starts with the category prefix can be matched even
// when the flow filename token isn't a substring of the screenshot
// name (which is the common case for Maestro screenshots whose
// names are author-defined ad-hoc strings).
const file = (tc.$ && tc.$.file) || tc.file?.[0] || '';
const categoryMatch = String(file).match(/flows\/([^/]+)\//);
const category = categoryMatch ? categoryMatch[1].toLowerCase() : '';
const time = parseFloat(tc.time?.[0] || '0');
let status = 'passed';
let failureMessage = null;
if (tc.failure) {
status = 'failed';
const failure = tc.failure[0];
failureMessage = (typeof failure === 'string' ? failure : failure._ || failure.message?.[0] || '').toString();
failedNames.push(name);
} else if (tc.error) {
status = 'error';
const errVal = tc.error[0];
failureMessage = (typeof errVal === 'string' ? errVal : errVal._ || errVal.message?.[0] || '').toString();
failedNames.push(name);
} else if (tc.skipped) {
status = 'skipped';
}
flows.push({name, classname, file, category, time, status, failureMessage});
}
}
const passes = tests - (failures + errors + skipped);
const passPercent = tests > 0 ? ((passes / tests) * 100).toFixed(2) : '0.00';
const now = new Date().toISOString();
const stats = {
suites: suites.length,
tests,
skipped,
failures,
errors,
duration,
start: now,
end: now,
passes,
passPercent,
};
let statsFieldValue = `
| Key | Value |
|:---|:---|
| Passing Rate | ${stats.passPercent}% |
| Duration | ${(stats.duration / (60 * 1000)).toFixed(4)} mins |
| Tests | ${stats.tests} |
| :white_check_mark: Passed | ${stats.passes} |
| :x: Failed | ${stats.failures} |
| :fast_forward: Skipped | ${stats.skipped} |
`;
if (failedNames.length > 0) {
const maxShow = 5;
const display = failedNames.length > maxShow
? [...failedNames.slice(0, maxShow - 1).map((f) => `- ${f}`), '- more...']
: failedNames.map((f) => `- ${f}`);
statsFieldValue += '###### Failed Tests:\n' + display.join('\n');
}
return {stats, statsFieldValue, flows};
}
/**
* Recursively walk a directory and return relative file paths (POSIX-style).
*/
function walkFiles(rootDir) {
if (!fse.existsSync(rootDir)) {
return [];
}
const collected = [];
const stack = [rootDir];
while (stack.length) {
const dir = stack.pop();
for (const entry of fse.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
collected.push(path.relative(rootDir, full).split(path.sep).join('/'));
}
}
}
return collected;
}
/**
* Sanitize a flow name to a filesystem-style token used for matching artifact
* filenames. Maestro names artifacts using the flow name (minus extension),
* with spaces/special-chars replaced.
*/
function normalizeFlowToken(name) {
return name.toLowerCase().replace(/\.ya?ml$/i, '').replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
}
/**
* Score how strongly a screenshot/log path belongs to a given flow.
*
* Maestro screenshots are named by the flow author via `takeScreenshot:` and
* usually combine a category prefix with an ad-hoc state name
* (e.g. `calls-mute-initial-state.png`). The simple substring rule
* (`lowerToken.includes(token)`) fails because the flow filename token
* (`mute_unmute`) is rarely a substring of the screenshot's token
* (`calls_mute_initial_state`).
*
* Scoring:
* +100 per full-token substring match (kept so legacy well-named artifacts
* like `mute_unmute_step3.png` still attribute correctly).
* + 10 per shared word (>= 2 chars) between the flow's name tokens and
* the file's name tokens — picks up `mute` in `calls-mute-initial-state`.
* + 1 if the screenshot's leading prefix equals the flow's category
* (e.g. `calls-...` for a flow under `detox/maestro/flows/calls/`) — a weak
* tie-breaker so screenshots stay within their suite.
*
* Returns 0 when there's no plausible link; bucketing falls back to
* `__unattributed` in that case.
*/
function scoreFlowMatch(flow, fileToken, fileWords, filePrefix) {
let score = 0;
if (flow.token && fileToken.includes(flow.token)) {
score += 100;
}
if (flow.words.size) {
for (const w of fileWords) {
if (flow.words.has(w)) {
score += 10;
}
}
}
if (flow.category && filePrefix && flow.category === filePrefix) {
score += 1;
}
return score;
}
/**
* Build authoritative screenshot → flow mapping from Maestro command logs.
* Maestro writes commands-(<flow>).json under the test-output dir.
*/
function buildScreenshotMapFromCommandLogs(artifactsDir) {
const map = new Map();
if (!fse.existsSync(artifactsDir)) {
return map;
}
const files = walkFiles(artifactsDir).filter((p) => /commands-\(.+\)\.json$/i.test(path.basename(p)));
for (const relPath of files) {
const absPath = path.join(artifactsDir, relPath);
const base = path.basename(relPath);
const flowMatch = base.match(/^commands-\((.+)\)\.json$/i);
const flowName = flowMatch ? flowMatch[1] : null;
if (!flowName) {
continue;
}
let commands;
try {
commands = fse.readJsonSync(absPath);
} catch {
continue;
}
const list = Array.isArray(commands) ? commands : (commands.commands || []);
for (const cmd of list) {
const shot = cmd.takeScreenshotCommand || cmd.takeScreenshot;
const shotPath = shot?.path || shot?.screenshot;
if (!shotPath) {
continue;
}
const png = shotPath.endsWith('.png') ? shotPath : `${shotPath}.png`;
const rel = png.includes('/') ? png : path.join('screenshots', png);
map.set(rel.replace(/\\/g, '/'), flowName);
}
}
return map;
}
/**
* Merge multiple Maestro JUnit XML files into one report (CI batch runner).
*/
function mergeMaestroJunitReports(xmlPaths, outputPath) {
const existing = xmlPaths.filter((p) => fse.existsSync(p));
if (!existing.length) {
console.log('No Maestro JUnit files to merge');
return false;
}
const mergedFlows = [];
let device = '';
let totalTime = 0;
for (const xmlPath of existing) {
const summary = parseMaestroReport(xmlPath);
if (!summary) {
continue;
}
totalTime += summary.stats.duration || 0;
mergedFlows.push(...summary.flows);
if (!device && summary.stats.device) {
device = summary.stats.device;
}
}
const tests = mergedFlows.length;
const failures = mergedFlows.filter((f) => f.status === 'failed').length;
const errors = mergedFlows.filter((f) => f.status === 'error').length;
const skipped = mergedFlows.filter((f) => f.status === 'skipped').length;
const timeSec = (totalTime / 1000).toFixed(1);
const renderTestcase = (f, classname, filePath) => {
const statusAttr = f.status === 'passed' ? 'SUCCESS' : f.status.toUpperCase();
const fileAttr = filePath ? ` file="${escapeXmlAttr(filePath)}"` : '';
let innerBlock = '';
if (f.status === 'skipped') {
innerBlock = '\n <skipped/>';
} else if (f.status === 'error') {
innerBlock = f.failureMessage ?
`\n <error>${escapeXmlText(f.failureMessage)}</error>` : '\n <error/>';
} else if (f.failureMessage) {
innerBlock = `\n <failure>${escapeXmlText(f.failureMessage)}</failure>`;
}
return ` <testcase id="${escapeXmlAttr(f.name)}" name="${escapeXmlAttr(f.name)}" classname="${escapeXmlAttr(classname)}"${fileAttr} time="${f.time.toFixed(1)}" status="${statusAttr}">${innerBlock}\n </testcase>`;
};
// One testsuite per flow file when every flow has a path — TSIO reads `file`
// into suite FilePath; suite name doubles as a readable title.
const allHaveFiles = mergedFlows.length > 0 && mergedFlows.every((f) => f.file);
let suitesXml;
if (allHaveFiles) {
const byFile = new Map();
for (const f of mergedFlows) {
if (!byFile.has(f.file)) {
byFile.set(f.file, []);
}
byFile.get(f.file).push(f);
}
suitesXml = [...byFile.entries()].map(([filePath, flows]) => {
const suiteFailures = flows.filter((f) => f.status === 'failed').length;
const suiteErrors = flows.filter((f) => f.status === 'error').length;
const suiteSkipped = flows.filter((f) => f.status === 'skipped').length;
const time = flows.reduce((n, f) => n + f.time, 0).toFixed(1);
const cases = flows.map((f) => renderTestcase(f, filePath, filePath)).join('\n');
return ` <testsuite name="${escapeXmlAttr(filePath)}" device="${escapeXmlAttr(device)}" tests="${flows.length}" failures="${suiteFailures}" errors="${suiteErrors}" skipped="${suiteSkipped}" time="${time}">\n${cases}\n </testsuite>`;
}).join('\n');
} else {
const cases = mergedFlows.map((f) =>
renderTestcase(f, f.file || f.classname || f.name, f.file || ''),
).join('\n');
suitesXml = ` <testsuite name="Test Suite" device="${escapeXmlAttr(device)}" tests="${tests}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${timeSec}">\n${cases}\n </testsuite>`;
}
const xml = `<?xml version='1.0' encoding='UTF-8'?>\n<testsuites>\n${suitesXml}\n</testsuites>\n`;
fse.outputFileSync(outputPath, xml, 'utf-8');
console.log(`Merged ${existing.length} Maestro JUnit files -> ${outputPath} (${tests} tests, ${failures} failures, ${errors} errors, ${skipped} skipped)`);
return true;
}
function escapeXmlAttr(s) {
return String(s ?? '').replace(/[&"<>]/g, (c) => ({
'&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;',
}[c]));
}
function escapeXmlText(s) {
return String(s ?? '').replace(/[<>&]/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
}[c]));
}
/**
* Group artifact files by which flow they belong to. Each file is scored
* against every flow (see scoreFlowMatch); the highest-scoring flow wins.
* Files with no plausible link land in `__unattributed`.
*/
function bucketArtifactsByFlow(flows, artifactPaths, commandLogMap = new Map()) {
const buckets = {__unattributed: []};
for (const flow of flows) {
buckets[flow.name] = [];
}
const tokenized = flows.map((f) => {
const token = normalizeFlowToken(f.name);
return {
original: f.name,
token,
words: new Set(token.split('_').filter((w) => w.length >= 1)),
category: (f.category || '').toLowerCase(),
};
});
for (const relPath of artifactPaths) {
const normalized = relPath.replace(/\\/g, '/');
const fromCommandLog = commandLogMap.get(normalized);
if (fromCommandLog && buckets[fromCommandLog]) {
buckets[fromCommandLog].push(relPath);
continue;
}
const lower = relPath.toLowerCase();
const fileToken = lower.replace(/[^a-z0-9/]+/g, '_');
const fileWords = path.basename(lower).
replace(/\.[a-z0-9]+$/i, ''). // strip extension
split(/[^a-z0-9]+/).
filter((w) => w.length >= 1);
const prefixMatch = path.basename(lower).match(/^([a-z0-9]+)[-_]/);
const filePrefix = prefixMatch ? prefixMatch[1] : '';
let bestFlow = null;
let bestScore = 0;
let bestUnique = true;
for (const f of tokenized) {
const score = scoreFlowMatch(f, fileToken, fileWords, filePrefix);
if (score > bestScore) {
bestScore = score;
bestFlow = f;
bestUnique = true;
} else if (score === bestScore && score > 0) {
bestUnique = false;
}
}
// Only attribute when there's a UNIQUE best match. If multiple flows
// tie at the highest score (common for ambiguous ad-hoc screenshot
// names like `calls-call-ended.png` that share words with every
// calls/* flow), defer to `__unattributed` rather than arbitrarily
// picking the first one by iteration order.
if (bestFlow && bestUnique) {
buckets[bestFlow.original].push(relPath);
} else {
buckets.__unattributed.push(relPath);
}
}
return buckets;
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
function formatDuration(seconds) {
if (!seconds || seconds < 0.001) {
return '—';
}
if (seconds < 1) {
return `${(seconds * 1000).toFixed(0)} ms`;
}
if (seconds < 60) {
return `${seconds.toFixed(2)} s`;
}
const m = Math.floor(seconds / 60);
const s = seconds - (m * 60);
return `${m}m ${s.toFixed(0)}s`;
}
function isImage(p) {
return /\.(png|jpe?g|webp|gif)$/i.test(p);
}
function isText(p) {
return /\.(log|txt|json|yaml|yml|md)$/i.test(p);
}
function buildFlowSection(flow, files, artifactDirHref) {
const statusBadge = {
passed: '<span class="badge passed">PASSED</span>',
failed: '<span class="badge failed">FAILED</span>',
error: '<span class="badge failed">ERROR</span>',
skipped: '<span class="badge skipped">SKIPPED</span>',
}[flow.status] || '<span class="badge">?</span>';
const screenshots = files.filter(isImage).sort();
const logs = files.filter(isText).sort();
const screenshotsHtml = screenshots.length ?
`<div class="screenshots">${screenshots.map((p) => `
<a class="thumb" href="${escapeHtml(artifactDirHref + '/' + p)}" target="_blank" rel="noopener">
<img loading="lazy" src="${escapeHtml(artifactDirHref + '/' + p)}" alt="${escapeHtml(p)}">
<div class="caption">${escapeHtml(path.basename(p))}</div>
</a>`).join('')}</div>` :
'<p class="muted">No screenshots captured for this flow.</p>';
const logsHtml = logs.length ?
`<ul class="logs">${logs.map((p) => `<li><a href="${escapeHtml(artifactDirHref + '/' + p)}" target="_blank" rel="noopener">${escapeHtml(p)}</a></li>`).join('')}</ul>` :
'';
const failureBlock = flow.failureMessage ?
`<pre class="failure">${escapeHtml(flow.failureMessage)}</pre>` :
'';
const isOpen = flow.status === 'failed' || flow.status === 'error';
return `
<details class="flow flow-${flow.status}"${isOpen ? ' open' : ''}>
<summary>
${statusBadge}
<span class="flow-name">${escapeHtml(flow.name)}</span>
<span class="flow-duration">${escapeHtml(formatDuration(flow.time))}</span>
</summary>
<div class="flow-body">
${failureBlock}
${screenshotsHtml}
${logsHtml ? `<details class="logs-wrap"><summary>Log files (${logs.length})</summary>${logsHtml}</details>` : ''}
</div>
</details>`;
}
/**
* Generate a self-contained HTML report from the JUnit XML and maestro artifact
* directory. Designed to be served as a single S3 object — all CSS is inline,
* images are referenced by relative URL so they resolve against the same S3
* prefix when the HTML is opened from S3.
*
* @param {object} opts
* @param {string} opts.xmlPath path to maestro-report.xml
* @param {string} opts.artifactsDir path to maestro-artifacts/ dir
* @param {string} opts.outputPath where to write maestro-report.html
* @param {string} opts.platform 'ios' | 'android'
* @param {string} [opts.commitSha] optional, for the header
* @returns {boolean} true on success
*/
function generateMaestroHtmlReport({xmlPath, artifactsDir, outputPath, platform, commitSha = ''}) {
const summary = parseMaestroReport(xmlPath);
if (!summary) {
console.log('Cannot generate HTML report — XML parse failed.');
return false;
}
const {stats, flows} = summary;
const allFiles = walkFiles(artifactsDir);
const commandLogMap = buildScreenshotMapFromCommandLogs(artifactsDir);
const buckets = bucketArtifactsByFlow(flows, allFiles, commandLogMap);
// Artifact links from the HTML are relative to the report file's location.
// We place the HTML at the same level as the artifacts dir so the relative
// base is 'maestro-artifacts'.
const artifactDirHref = 'maestro-artifacts';
const passPct = parseFloat(stats.passPercent);
const overallBadge = stats.failures + stats.errors === 0 ?
'<span class="badge passed">ALL PASSED</span>' :
'<span class="badge failed">FAILURES</span>';
const sortedFlows = [...flows].sort((a, b) => {
const rank = (s) => ({failed: 0, error: 0, skipped: 1, passed: 2}[s] ?? 3);
const r = rank(a.status) - rank(b.status);
return r === 0 ? a.name.localeCompare(b.name) : r;
});
const flowsHtml = sortedFlows.map((flow) => buildFlowSection(flow, buckets[flow.name] || [], artifactDirHref)).join('\n');
const unattributed = buckets.__unattributed;
const unattributedHtml = unattributed.length ? `
<details class="flow">
<summary>
<span class="badge">FILES</span>
<span class="flow-name">Unattributed artifacts (${unattributed.length})</span>
</summary>
<div class="flow-body"><ul class="logs">${unattributed.map((p) => `<li><a href="${escapeHtml(artifactDirHref + '/' + p)}" target="_blank" rel="noopener">${escapeHtml(p)}</a></li>`).join('')}</ul></div>
</details>` : '';
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Maestro ${escapeHtml(platform)} report</title>
<style>
:root { --bg:#0f172a; --panel:#1e293b; --border:#334155; --text:#e2e8f0; --muted:#94a3b8; --pass:#22c55e; --fail:#ef4444; --skip:#f59e0b; --link:#60a5fa; }
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,sans-serif; background:var(--bg); color:var(--text); line-height:1.5; }
header { position:sticky; top:0; background:var(--panel); border-bottom:1px solid var(--border); padding:16px 24px; z-index:10; }
header h1 { margin:0; font-size:20px; font-weight:600; display:flex; align-items:center; gap:12px; }
header .meta { color:var(--muted); font-size:13px; margin-top:4px; }
main { max-width:1200px; margin:0 auto; padding:24px; }
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; margin-bottom:24px; }
.stat { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px 16px; }
.stat .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:0.05em; }
.stat .value { font-size:24px; font-weight:600; margin-top:4px; }
.stat.pass .value { color:var(--pass); }
.stat.fail .value { color:var(--fail); }
.stat.skip .value { color:var(--skip); }
.progress { height:8px; background:var(--border); border-radius:4px; overflow:hidden; margin-bottom:24px; }
.progress > .bar { height:100%; background:var(--pass); transition:width 0.3s; }
h2 { font-size:16px; font-weight:600; margin:24px 0 12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.05em; }
.flow { background:var(--panel); border:1px solid var(--border); border-radius:8px; margin-bottom:8px; overflow:hidden; }
.flow > summary { padding:12px 16px; cursor:pointer; display:flex; align-items:center; gap:12px; user-select:none; list-style:none; }
.flow > summary::-webkit-details-marker { display:none; }
.flow > summary::before { content:'▸'; color:var(--muted); transition:transform 0.15s; display:inline-block; }
.flow[open] > summary::before { transform:rotate(90deg); }
.flow-name { flex:1; font-weight:500; }
.flow-duration { color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; }
.badge { font-size:11px; font-weight:700; padding:3px 8px; border-radius:4px; letter-spacing:0.05em; background:var(--border); color:var(--text); }
.badge.passed { background:rgba(34,197,94,0.15); color:var(--pass); }
.badge.failed { background:rgba(239,68,68,0.15); color:var(--fail); }
.badge.skipped { background:rgba(245,158,11,0.15); color:var(--skip); }
.flow-body { padding:16px; border-top:1px solid var(--border); background:rgba(0,0,0,0.15); }
.failure { background:rgba(239,68,68,0.08); border-left:3px solid var(--fail); padding:12px; border-radius:4px; font-family:'SF Mono',Menlo,Consolas,monospace; font-size:12px; white-space:pre-wrap; word-wrap:break-word; overflow-x:auto; margin:0 0 16px; }
.screenshots { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:12px; }
.thumb { display:block; background:var(--bg); border:1px solid var(--border); border-radius:6px; overflow:hidden; text-decoration:none; color:inherit; transition:border-color 0.15s; }
.thumb:hover { border-color:var(--link); }
.thumb img { display:block; width:100%; height:200px; object-fit:contain; background:#000; }
.thumb .caption { padding:6px 8px; font-size:11px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.muted { color:var(--muted); font-style:italic; margin:0; }
.logs-wrap { margin-top:16px; }
.logs-wrap > summary { cursor:pointer; color:var(--link); font-size:13px; }
.logs { margin:8px 0 0; padding-left:20px; }
.logs a { color:var(--link); text-decoration:none; font-size:13px; }
.logs a:hover { text-decoration:underline; }
</style>
</head>
<body>
<header>
<h1>Maestro ${escapeHtml(platform)} report ${overallBadge}</h1>
<div class="meta">${escapeHtml(stats.tests)} tests · ${escapeHtml(stats.passPercent)}% passing · ${escapeHtml(formatDuration(stats.duration / 1000))}${commitSha ? ' · ' + escapeHtml(commitSha.slice(0, 7)) : ''}</div>
</header>
<main>
<div class="stats">
<div class="stat"><div class="label">Tests</div><div class="value">${escapeHtml(stats.tests)}</div></div>
<div class="stat pass"><div class="label">Passed</div><div class="value">${escapeHtml(stats.passes)}</div></div>
<div class="stat fail"><div class="label">Failed</div><div class="value">${escapeHtml(stats.failures + stats.errors)}</div></div>
<div class="stat skip"><div class="label">Skipped</div><div class="value">${escapeHtml(stats.skipped)}</div></div>
<div class="stat"><div class="label">Duration</div><div class="value">${escapeHtml(formatDuration(stats.duration / 1000))}</div></div>
</div>
<div class="progress"><div class="bar" style="width:${passPct}%"></div></div>
<h2>Flows (${flows.length})</h2>
${flowsHtml || '<p class="muted">No flows reported.</p>'}
${unattributedHtml}
</main>
</body>
</html>`;
fse.outputFileSync(outputPath, html, 'utf-8');
console.log(`Wrote maestro HTML report: ${outputPath} (${allFiles.length} artifact files, ${flows.length} flows)`);
return true;
}
/**
* Convert a Maestro JUnit XML report into Jest --json shape.
* Prefer uploading JUnit with framework=maestro (TSIO #76+). Kept for local/
* tooling that still wants a Jest-shaped artifact.
*
* @param {string} xmlPath
* @param {string} outputPath
* @returns {boolean}
*/
function writeMaestroJestJsonForTsio(xmlPath, outputPath) {
const summary = parseMaestroReport(xmlPath);
if (!summary || !summary.flows.length) {
console.log(`writeMaestroJestJsonForTsio: no flows parsed from ${xmlPath}`);
return false;
}
const now = Date.now();
const byFile = new Map();
for (const flow of summary.flows) {
const fileKey = flow.file || flow.classname || flow.name || 'maestro';
if (!byFile.has(fileKey)) {
byFile.set(fileKey, []);
}
let jestStatus = 'failed';
if (flow.status === 'passed') {
jestStatus = 'passed';
} else if (flow.status === 'skipped') {
jestStatus = 'pending';
}
const durationMs = Math.round((flow.time || 0) * 1000);
byFile.get(fileKey).push({
ancestorTitles: flow.classname && flow.classname !== flow.name ? [flow.classname] : [],
duration: durationMs,
failureDetails: flow.failureMessage ? [{error: flow.failureMessage}] : [],
failureMessages: flow.failureMessage ? [flow.failureMessage] : [],
fullName: flow.classname && flow.classname !== flow.name ?
`${flow.classname} ${flow.name}` : flow.name,
invocations: 1,
location: null,
numPassingAsserts: jestStatus === 'passed' ? 1 : 0,
retryReasons: [],
status: jestStatus,
title: flow.name,
});
}
let numFailedTests = 0;
let numPassedTests = 0;
let numPendingTests = 0;
const testResults = [];
for (const [fileKey, assertionResults] of byFile.entries()) {
for (const ar of assertionResults) {
if (ar.status === 'passed') {
numPassedTests += 1;
} else if (ar.status === 'pending') {
numPendingTests += 1;
} else {
numFailedTests += 1;
}
}
const fileFailed = assertionResults.some((ar) => ar.status === 'failed');
testResults.push({
assertionResults,
endTime: now,
message: fileFailed ? 'failed' : '',
name: fileKey.includes('/') ? fileKey : `maestro/flows/${fileKey}`,
startTime: now,
status: fileFailed ? 'failed' : 'passed',
summary: '',
});
}
const numTotalTests = numFailedTests + numPassedTests + numPendingTests;
const payload = {
numFailedTestSuites: testResults.filter((t) => t.status === 'failed').length,
numFailedTests,
numPassedTestSuites: testResults.filter((t) => t.status === 'passed').length,
numPassedTests,
numPendingTestSuites: 0,
numPendingTests,
numRuntimeErrorTestSuites: 0,
numTodoTests: 0,
numTotalTestSuites: testResults.length,
numTotalTests,
openHandles: [],
snapshot: {
added: 0,
didUpdate: false,
failure: false,
filesAdded: 0,
filesRemoved: 0,
filesRemovedList: [],
filesUnmatched: 0,
filesUpdated: 0,
matched: 0,
total: 0,
unchecked: 0,
uncheckedKeysByFile: [],
unmatched: 0,
updated: 0,
},
startTime: now,
success: numFailedTests === 0,
testResults,
wasInterrupted: false,
};
fse.outputFileSync(outputPath, JSON.stringify(payload, null, 2));
console.log(
`Wrote Maestro→Jest JSON for TSIO -> ${outputPath} ` +
`(${numTotalTests} tests, ${numFailedTests} failed)`,
);
return true;
}
/**
* When the batch runner exits before merging (e.g. set -e abort), reconstruct
* maestro-report.xml from maestro-batch-*.xml so PR status reflects real counts.
*/
function mergeMaestroBatchReportsFromDir(buildDir, outputPath) {
if (fse.existsSync(outputPath)) {
return outputPath;
}
if (!fse.existsSync(buildDir)) {
return null;
}
const batchFiles = fse.readdirSync(buildDir).
filter((name) => /^maestro-batch-\d+\.xml$/.test(name)).
sort((a, b) => {
const num = (file) => parseInt(file.match(/maestro-batch-(\d+)\.xml/)[1], 10);
return num(a) - num(b);
}).
map((name) => path.join(buildDir, name));
if (!batchFiles.length) {
return null;
}
console.log(`Merging ${batchFiles.length} batch JUnit files from ${buildDir}`);
mergeMaestroJunitReports(batchFiles, outputPath);
return fse.existsSync(outputPath) ? outputPath : null;
}
/**
* Build screenshot → flow mapping by reading `takeScreenshot:` names from each
* flow YAML referenced in the JUnit report. Used when Maestro command logs are
* absent (common after --flatten-debug-output / partial artifact uploads).
*
* @param {Array<{name: string, file?: string}>} flows
* @param {string} [repoRoot]
* @returns {Map<string, string>}
*/
function buildScreenshotMapFromFlowYamls(flows, repoRoot = process.cwd()) {
const map = new Map();
const roots = [
repoRoot,
path.resolve(repoRoot, '..'),
].filter(Boolean);
for (const flow of flows) {
if (!flow.file || !flow.name) {
continue;
}
let yamlText = null;
for (const root of roots) {
const candidate = path.resolve(root, flow.file);
if (fse.existsSync(candidate)) {
try {
yamlText = fse.readFileSync(candidate, 'utf8');
} catch {
yamlText = null;
}
break;
}
}
if (!yamlText) {
continue;
}
for (const match of yamlText.matchAll(/takeScreenshot:\s*['"]?([^\s#'"]+)/g)) {
const shot = match[1];
if (!shot) {
continue;
}
const png = shot.endsWith('.png') ? shot : `${shot}.png`;
const rel = png.includes('/') ? png.replace(/\\/g, '/') : path.join('screenshots', png).replace(/\\/g, '/');
map.set(rel, flow.name);
map.set(path.basename(rel), flow.name);
}
}
return map;
}
/**
* Stage Maestro screenshots into per-flow folders for TSIO linking.
*
* Maestro writes takeScreenshot PNGs as flat `screenshots/<name>.png`, which
* cannot match JUnit full_title (`<flow.yml> > <flowName>`). TSIO's linker
* matches folder-derived test_name against full_title, so we copy images to
* `<outputDir>/<flowName>/<basename>.png` using the same attribution as the
* HTML report (command logs + YAML takeScreenshot map + scoreFlowMatch).
*
* @param {{xmlPath: string, artifactsDir: string, outputDir: string, repoRoot?: string}} opts
* @returns {{copied: number, flows: number}}
*/
function prepareMaestroScreenshotsForTsio({xmlPath, artifactsDir, outputDir, repoRoot}) {
const summary = parseMaestroReport(xmlPath);
if (!summary || !summary.flows.length) {
console.log(`prepareMaestroScreenshotsForTsio: no flows in ${xmlPath}`);
return {copied: 0, flows: 0};
}
const allFiles = walkFiles(artifactsDir).filter(isImage);
const commandLogMap = buildScreenshotMapFromCommandLogs(artifactsDir);
const yamlMap = buildScreenshotMapFromFlowYamls(summary.flows, repoRoot || process.cwd());
// Command logs win when present; YAML map fills the gaps.
const combinedMap = new Map([...yamlMap, ...commandLogMap]);
const buckets = bucketArtifactsByFlow(summary.flows, allFiles, combinedMap);
fse.emptyDirSync(outputDir);
let copied = 0;
for (const [flowName, files] of Object.entries(buckets)) {
if (flowName === '__unattributed' || !files.length) {
continue;
}
for (const rel of files) {
const src = path.join(artifactsDir, rel);
const dest = path.join(outputDir, flowName, path.basename(rel));
fse.ensureDirSync(path.dirname(dest));
fse.copySync(src, dest, {overwrite: true});
copied += 1;
}
}
// Keep unattributed images discoverable (won't link to a case).
const orphaned = buckets.__unattributed || [];
for (const rel of orphaned) {
const src = path.join(artifactsDir, rel);
const dest = path.join(outputDir, '__unattributed', path.basename(rel));
fse.ensureDirSync(path.dirname(dest));
fse.copySync(src, dest, {overwrite: true});
copied += 1;
}
console.log(`prepareMaestroScreenshotsForTsio: copied ${copied} image(s) for ${summary.flows.length} flow(s) -> ${outputDir}`);
return {copied, flows: summary.flows.length};
}
module.exports = {
parseMaestroReport,
generateMaestroHtmlReport,
mergeMaestroJunitReports,
mergeMaestroBatchReportsFromDir,
writeMaestroJestJsonForTsio,
buildScreenshotMapFromCommandLogs,
buildScreenshotMapFromFlowYamls,
prepareMaestroScreenshotsForTsio,
};