Skip to content

Instantly share code, notes, and snippets.

@hassankhan
Last active April 14, 2025 12:55
Show Gist options
  • Save hassankhan/6f15f27bab6e4f5493c48c035a3ebe4b to your computer and use it in GitHub Desktop.
Save hassankhan/6f15f27bab6e4f5493c48c035a3ebe4b to your computer and use it in GitHub Desktop.
Script that merges JUnit test reports generated by Jest in an Nx monorepo and improves them for use in Azure DevOps
#!/usr/bin/env zx
import { $, fs, path } from 'zx';
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
const junitDir = path.resolve(process.cwd(), 'coverage', 'junit');
const mergedOutputFile = path.join(junitDir, 'complete-junit.xml');
try {
const fileExists = await fs.exists(mergedOutputFile);
if (fileExists) {
await fs.remove(mergedOutputFile);
console.log(`Removed existing ${mergedOutputFile}`);
}
} catch (error) {
if (error.code !== 'ENOENT') {
console.error(`Error removing existing ${mergedOutputFile}:`, error);
}
}
const files = await fs.readdir(junitDir);
const reportFiles = files.filter(
(file) => file.endsWith('.junit.xml') && file !== 'complete-junit.xml',
);
console.log(`Found ${reportFiles.length} report files to process.`);
for (const reportFile of reportFiles) {
const projectNameMatch = reportFile.match(/^(.*?)\.junit\.xml$/);
if (!projectNameMatch) {
console.warn(
`Could not extract project name from ${reportFile}, skipping.`,
);
continue;
}
const projectName = projectNameMatch[1];
const reportFilePath = path.join(junitDir, reportFile);
try {
const projectInfoRaw = await $`pnpm nx show project ${projectName} --json`;
const projectInfo = JSON.parse(projectInfoRaw.stdout);
const projectRoot = projectInfo.root;
if (!projectRoot) {
console.warn(
`Could not find root for project ${projectName}, skipping file ${reportFile}`,
);
continue;
}
console.log(
`Processing ${reportFile} for project ${projectName} (root: ${projectRoot})...`,
);
const xmlContent = await fs.readFile(reportFilePath, 'utf-8');
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
});
const parsedXml = parser.parse(xmlContent);
if (parsedXml.testsuites && parsedXml.testsuites.testsuite) {
const suites = Array.isArray(parsedXml.testsuites.testsuite)
? parsedXml.testsuites.testsuite
: [parsedXml.testsuites.testsuite];
suites.forEach((suite) => {
if (suite.name && !suite.name.startsWith(projectRoot + '/')) {
suite.name = path.join(projectRoot, suite.name);
}
if (suite.testcase) {
const cases = Array.isArray(suite.testcase)
? suite.testcase
: [suite.testcase];
cases.forEach((testcase) => {
if (testcase.file && !testcase.file.startsWith(projectRoot + '/')) {
testcase.file = path.join(projectRoot, testcase.file);
}
});
}
});
} else if (parsedXml.testsuite) {
const suite = parsedXml.testsuite;
if (suite.name && !suite.name.startsWith(projectRoot + '/')) {
suite.name = path.join(projectRoot, suite.name);
}
if (suite.testcase) {
const cases = Array.isArray(suite.testcase)
? suite.testcase
: [suite.testcase];
cases.forEach((testcase) => {
if (testcase.file && !testcase.file.startsWith(projectRoot + '/')) {
testcase.file = path.join(projectRoot, testcase.file);
}
});
}
}
const builder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '',
format: true,
});
const modifiedXmlContent = builder.build(parsedXml);
await fs.writeFile(reportFilePath, modifiedXmlContent);
console.log(` Processed and saved ${reportFile}`);
} catch (error) {
console.error(
`Error processing file ${reportFile} for project ${projectName}:`,
error,
);
}
}
console.log(`Merging reports into ${mergedOutputFile}...`);
try {
await $`pnpm junit-merge --dir=${junitDir} --out=${mergedOutputFile}`;
console.log('Successfully merged JUnit reports.');
} catch (mergeError) {
console.error('Error merging JUnit reports:', mergeError);
process.exit(1);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment