|
1 | | -#!/usr/bin/env node |
2 | 1 | 'use strict'; |
3 | 2 | /** |
4 | | - * Merge multiple JUnit XML files for cross-run flaky test detection. |
| 3 | + * Download previous-attempt JUnit reports from Scality artifacts, |
| 4 | + * merge them with the current attempt's reports, and write two output files: |
5 | 5 | * |
6 | | - * Suites with the same name across files are combined so that |
7 | | - * mikepenz/action-junit-report check_retries can detect tests that failed in |
8 | | - * one attempt but passed in another (both testcase entries end up in the same |
9 | | - * suite, which is what check_retries inspects). |
| 6 | + * {outputDir}/raw-reports.xml — current-attempt only (for future runs to download) |
| 7 | + * {outputDir}/junit-merged.xml — all attempts merged (for mikepenz/action-junit-report) |
10 | 8 | * |
11 | | - * Assumes standard JUnit format with no nested <testsuite> elements |
12 | | - * (i.e. <testsuites> -> <testsuite> -> <testcase>). |
| 9 | + * Suites with the same name are merged so that `check_retries: true` in |
| 10 | + * mikepenz/action-junit-report can detect tests that failed in one attempt |
| 11 | + * but passed in another. |
13 | 12 | * |
14 | | - * Usage: node merge-junit-reports.js <output> <input1> [input2 ...] |
| 13 | + * Called from actions/github-script@v7 via require(): |
| 14 | + * const merge = require('./.github/scripts/merge-junit-reports.js') |
| 15 | + * await merge({ core, glob, link, user, password, jobName, runAttempt, junitGlob, outputDir }) |
15 | 16 | */ |
16 | 17 |
|
| 18 | +const https = require('https'); |
| 19 | +const http = require('http'); |
17 | 20 | const fs = require('fs'); |
18 | 21 | const path = require('path'); |
| 22 | +/* @xmldom/xmldom is installed at build time; NODE_PATH is set by the calling step */ |
| 23 | +const { DOMParser, XMLSerializer } = require('@xmldom/xmldom'); |
| 24 | + |
| 25 | +const parser = new DOMParser(); |
| 26 | +const serializer = new XMLSerializer(); |
| 27 | + |
| 28 | +/** Fetch a URL with Basic auth. Returns the response body, or null on failure. */ |
| 29 | +async function fetchText(url, user, password) { |
| 30 | + return new Promise((resolve) => { |
| 31 | + const auth = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`; |
| 32 | + const lib = url.startsWith('https://') ? https : http; |
| 33 | + lib.get(url, { headers: { Authorization: auth } }, (res) => { |
| 34 | + if (res.statusCode !== 200) { res.resume(); resolve(null); return; } |
| 35 | + const chunks = []; |
| 36 | + res.on('data', c => chunks.push(c)); |
| 37 | + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); |
| 38 | + }).on('error', () => resolve(null)); |
| 39 | + }); |
| 40 | +} |
19 | 41 |
|
20 | | -const [, , output, ...inputs] = process.argv; |
21 | | - |
22 | | -if (!output || inputs.length === 0) { |
23 | | - process.stderr.write('Usage: node merge-junit-reports.js <output> <input1> [input2 ...]\n'); |
24 | | - process.exit(1); |
| 42 | +/** Parse a JUnit XML string and return all <testsuite> elements. */ |
| 43 | +function parseSuites(xml) { |
| 44 | + const doc = parser.parseFromString(xml, 'application/xml'); |
| 45 | + return Array.from(doc.getElementsByTagName('testsuite')); |
25 | 46 | } |
26 | 47 |
|
27 | 48 | /** |
28 | | - * Extract all top-level <testsuite>...</testsuite> blocks from an XML string. |
29 | | - * Returns an array of objects: { name, inner } |
30 | | - * where `inner` is the content between the opening and closing testsuite tags. |
31 | | - * |
32 | | - * Handles both <testsuites> root and bare <testsuite> root. |
33 | | - * Assumes no nested <testsuite> elements (standard JUnit format). |
| 49 | + * Build a merged <testsuites> XML from an array of <testsuite> DOM elements. |
| 50 | + * Suites sharing the same name are combined into one suite. |
34 | 51 | */ |
35 | | -function extractSuites(xml) { |
36 | | - // Strip XML declaration |
37 | | - xml = xml.replace(/<\?xml[^?]*\?>\s*/i, ''); |
38 | | - |
39 | | - // Unwrap <testsuites> root if present |
40 | | - const tsMatch = xml.match(/^<testsuites\b[^>]*>([\s\S]*)<\/testsuites>\s*$/); |
41 | | - const inner = tsMatch ? tsMatch[1] : xml; |
42 | | - |
43 | | - const suites = []; |
44 | | - let pos = 0; |
45 | | - while (pos < inner.length) { |
46 | | - const start = inner.indexOf('<testsuite', pos); |
47 | | - if (start === -1) break; |
48 | | - |
49 | | - // Get the opening tag (up to and including '>') |
50 | | - const openEnd = inner.indexOf('>', start); |
51 | | - if (openEnd === -1) break; |
52 | | - const openTag = inner.slice(start, openEnd + 1); |
53 | | - |
54 | | - // Self-closing <testsuite ... /> — treat as empty suite |
55 | | - if (openTag.endsWith('/>')) { |
56 | | - const nameMatch = openTag.match(/\bname="([^"]*)"/); |
57 | | - suites.push({ name: nameMatch ? nameMatch[1] : '', inner: '' }); |
58 | | - pos = openEnd + 1; |
59 | | - continue; |
60 | | - } |
61 | | - |
62 | | - // Find closing tag |
63 | | - const closeStart = inner.indexOf('</testsuite>', openEnd + 1); |
64 | | - if (closeStart === -1) break; |
| 52 | +function buildXml(allSuites) { |
| 53 | + const suiteMap = new Map(); |
| 54 | + for (const s of allSuites) { |
| 55 | + const name = s.getAttribute('name') || ''; |
| 56 | + if (!suiteMap.has(name)) suiteMap.set(name, []); |
| 57 | + suiteMap.get(name).push(s); |
| 58 | + } |
65 | 59 |
|
66 | | - const nameMatch = openTag.match(/\bname="([^"]*)"/); |
67 | | - suites.push({ |
68 | | - name: nameMatch ? nameMatch[1] : '', |
69 | | - inner: inner.slice(openEnd + 1, closeStart), |
70 | | - }); |
71 | | - pos = closeStart + '</testsuite>'.length; |
| 60 | + let totalTests = 0, totalFailures = 0, totalErrors = 0; |
| 61 | + const suiteParts = []; |
| 62 | + |
| 63 | + for (const [name, group] of suiteMap) { |
| 64 | + const testcases = group.flatMap(s => Array.from(s.getElementsByTagName('testcase'))); |
| 65 | + const failures = testcases.filter(tc => tc.getElementsByTagName('failure').length > 0).length; |
| 66 | + const errors = testcases.filter(tc => tc.getElementsByTagName('error').length > 0).length; |
| 67 | + totalTests += testcases.length; |
| 68 | + totalFailures += failures; |
| 69 | + totalErrors += errors; |
| 70 | + |
| 71 | + const tcXml = testcases.map(tc => serializer.serializeToString(tc)).join('\n '); |
| 72 | + suiteParts.push( |
| 73 | + ` <testsuite name="${escapeXmlAttr(name)}" tests="${testcases.length}" failures="${failures}" errors="${errors}">\n ${tcXml}\n </testsuite>` |
| 74 | + ); |
72 | 75 | } |
73 | | - return suites; |
| 76 | + |
| 77 | + return ( |
| 78 | + '<?xml version="1.0" encoding="UTF-8"?>\n' + |
| 79 | + `<testsuites tests="${totalTests}" failures="${totalFailures}" errors="${totalErrors}">\n` + |
| 80 | + suiteParts.join('\n') + |
| 81 | + '\n</testsuites>\n' |
| 82 | + ); |
74 | 83 | } |
75 | 84 |
|
76 | | -// Collect suites keyed by name; merge same-name suites across files |
77 | | -const suiteMap = new Map(); // name -> concatenated inner content |
| 85 | +/** Escape a string for use as an XML attribute value. */ |
| 86 | +function escapeXmlAttr(s) { |
| 87 | + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); |
| 88 | +} |
78 | 89 |
|
79 | | -let fileCount = 0; |
80 | | -for (const file of inputs) { |
81 | | - if (!fs.existsSync(file)) { |
82 | | - process.stderr.write(`Warning: file not found: ${file}\n`); |
83 | | - continue; |
| 90 | +module.exports = async function mergeJUnitReports({ |
| 91 | + core, glob, |
| 92 | + link, user, password, |
| 93 | + jobName, runAttempt, |
| 94 | + junitGlob, outputDir, |
| 95 | +}) { |
| 96 | + // ── Current attempt's XML files ─────────────────────────────────────────── |
| 97 | + const globber = await glob.create(junitGlob); |
| 98 | + const currentFiles = await globber.glob(); |
| 99 | + core.info(`Current JUnit files: ${currentFiles.length ? currentFiles.join(', ') : '(none)'}`); |
| 100 | + |
| 101 | + const currentSuites = []; |
| 102 | + for (const f of currentFiles) { |
| 103 | + try { |
| 104 | + const suites = parseSuites(fs.readFileSync(f, 'utf8')); |
| 105 | + currentSuites.push(...suites); |
| 106 | + core.info(`Parsed ${path.basename(f)}: ${suites.length} suite(s)`); |
| 107 | + } catch (e) { |
| 108 | + core.warning(`Could not parse ${f}: ${e.message}`); |
| 109 | + } |
84 | 110 | } |
85 | 111 |
|
86 | | - let xml; |
87 | | - try { |
88 | | - xml = fs.readFileSync(file, 'utf8'); |
89 | | - } catch (e) { |
90 | | - process.stderr.write(`Warning: could not read ${file}: ${e.message}\n`); |
91 | | - continue; |
| 112 | + // Write current-attempt raw report so future re-runs can download it |
| 113 | + if (currentSuites.length > 0) { |
| 114 | + fs.mkdirSync(outputDir, { recursive: true }); |
| 115 | + fs.writeFileSync(path.join(outputDir, 'raw-reports.xml'), buildXml(currentSuites), 'utf8'); |
| 116 | + core.info(`Written raw-reports.xml for attempt ${runAttempt}`); |
92 | 117 | } |
93 | 118 |
|
94 | | - const suites = extractSuites(xml); |
95 | | - fileCount++; |
96 | | - |
97 | | - for (const { name, inner } of suites) { |
98 | | - if (suiteMap.has(name)) { |
99 | | - suiteMap.set(name, suiteMap.get(name) + '\n' + inner); |
100 | | - } else { |
101 | | - suiteMap.set(name, inner); |
| 119 | + // ── Download previous attempts' raw reports from Scality ───────────────── |
| 120 | + const allSuites = [...currentSuites]; |
| 121 | + if (link && runAttempt > 1) { |
| 122 | + const base = link.replace(/\/$/, ''); |
| 123 | + for (let attempt = 1; attempt < runAttempt; attempt++) { |
| 124 | + const url = `${base}/data/${jobName}.${attempt}/raw-reports.xml`; |
| 125 | + core.info(`Downloading attempt ${attempt} from ${url}...`); |
| 126 | + const xml = await fetchText(url, user, password); |
| 127 | + if (xml) { |
| 128 | + try { |
| 129 | + const suites = parseSuites(xml); |
| 130 | + allSuites.push(...suites); |
| 131 | + core.info(`Downloaded attempt ${attempt}: ${suites.length} suite(s)`); |
| 132 | + } catch (e) { |
| 133 | + core.warning(`Could not parse attempt ${attempt} reports: ${e.message}`); |
| 134 | + } |
| 135 | + } else { |
| 136 | + core.warning(`Attempt ${attempt} reports not available (skipped)`); |
| 137 | + } |
102 | 138 | } |
103 | 139 | } |
104 | 140 |
|
105 | | - process.stdout.write(`Parsed ${file}: ${suites.length} suite(s)\n`); |
106 | | -} |
107 | | - |
108 | | -if (fileCount === 0) { |
109 | | - process.stderr.write('No input files were found or readable\n'); |
110 | | - process.exit(1); |
111 | | -} |
112 | | - |
113 | | -// Build merged XML |
114 | | -let totalTests = 0; |
115 | | -let totalFailures = 0; |
116 | | -let totalErrors = 0; |
117 | | - |
118 | | -const outputSuites = []; |
119 | | -for (const [name, combined] of suiteMap) { |
120 | | - const tests = (combined.match(/<testcase\b/g) || []).length; |
121 | | - const failures = (combined.match(/<failure\b/g) || []).length; |
122 | | - const errors = (combined.match(/<error\b/g) || []).length; |
123 | | - |
124 | | - totalTests += tests; |
125 | | - totalFailures += failures; |
126 | | - totalErrors += errors; |
127 | | - |
128 | | - const escapedName = name |
129 | | - .replace(/&/g, '&') |
130 | | - .replace(/"/g, '"') |
131 | | - .replace(/</g, '<') |
132 | | - .replace(/>/g, '>'); |
133 | | - outputSuites.push( |
134 | | - ` <testsuite name="${escapedName}" tests="${tests}" failures="${failures}" errors="${errors}">${combined}\n </testsuite>` |
135 | | - ); |
136 | | -} |
| 141 | + if (allSuites.length === 0) { |
| 142 | + core.warning('No JUnit XML files found; skipping merge.'); |
| 143 | + return; |
| 144 | + } |
137 | 145 |
|
138 | | -const merged = |
139 | | - '<?xml version="1.0" encoding="UTF-8"?>\n' + |
140 | | - `<testsuites tests="${totalTests}" failures="${totalFailures}" errors="${totalErrors}">\n` + |
141 | | - outputSuites.join('\n') + |
142 | | - '\n</testsuites>\n'; |
143 | | - |
144 | | -fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true }); |
145 | | -fs.writeFileSync(output, merged, 'utf8'); |
146 | | -process.stdout.write( |
147 | | - `Merged ${fileCount} file(s), ${suiteMap.size} suite(s) into ${output}\n` |
148 | | -); |
| 146 | + // ── Write merged XML ────────────────────────────────────────────────────── |
| 147 | + fs.mkdirSync(outputDir, { recursive: true }); |
| 148 | + const mergedPath = path.join(outputDir, 'junit-merged.xml'); |
| 149 | + fs.writeFileSync(mergedPath, buildXml(allSuites), 'utf8'); |
| 150 | + core.info(`Merged ${allSuites.length} suite(s) → ${mergedPath}`); |
| 151 | +}; |
0 commit comments