├── test ├── fixtures │ ├── empty.xml │ ├── spaces.xml │ ├── testsuite-merging │ │ ├── 1 │ │ │ ├── file1.xml │ │ │ ├── file2.xml │ │ │ └── expected.xml │ │ ├── 2 │ │ │ ├── file1.xml │ │ │ ├── file2.xml │ │ │ └── expected.xml │ │ ├── 3 │ │ │ ├── file2.xml │ │ │ ├── file1.xml │ │ │ └── expected.xml │ │ ├── 4 │ │ │ ├── file1.xml │ │ │ ├── file2.xml │ │ │ └── expected.xml │ │ └── 5 │ │ │ ├── file1.xml │ │ │ ├── file3.xml │ │ │ ├── file2.xml │ │ │ └── expected.xml │ ├── with-empty-tag.xml │ ├── with-entities-in-attributes.xml │ ├── m3.xml │ ├── with-entity-char.xml │ ├── m2.xml │ ├── m1.xml │ └── expected │ │ └── expected-combined-1-3.xml ├── output │ └── readme.md └── e2e.spec.mjs ├── .prettierignore ├── .npmrc ├── .prettierrc.js ├── .renovaterc ├── lefthook.yml ├── .editorconfig ├── index.js ├── vitest.config.mjs ├── .gitignore ├── src ├── domHelpers.js ├── helpers.js ├── attributes.js ├── mergeStreams.js ├── mergeFiles.js └── mergeToString.js ├── .github └── workflows │ └── main.yml ├── LICENSE ├── cli.js ├── package.json └── README.md /test/fixtures/empty.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /test/fixtures/spaces.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@bhovhannes/shared-config/prettier') 2 | -------------------------------------------------------------------------------- /.renovaterc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>bhovhannes/shared-config//renovate/default"] 3 | } 4 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - node_modules/@bhovhannes/shared-config/lefthook/pre-commit/format.yml 3 | -------------------------------------------------------------------------------- /test/output/readme.md: -------------------------------------------------------------------------------- 1 | Temporary output files from tests will be put in this folder and deleted after each test. 2 | 3 | Do not delete this folder. 4 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/4/file1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/4/file2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{js,jsx,ts,tsx}] 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/4/expected.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/5/file1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @license MIT http://www.opensource.org/licenses/mit-license.php 3 | * @author Hovhannes Babayan 4 | */ 5 | 6 | const { mergeFiles } = require('./src/mergeFiles.js') 7 | const { mergeStreams } = require('./src/mergeStreams.js') 8 | const { mergeToString } = require('./src/mergeToString.js') 9 | 10 | module.exports = { 11 | mergeFiles, 12 | mergeStreams, 13 | mergeToString 14 | } 15 | -------------------------------------------------------------------------------- /test/fixtures/with-empty-tag.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['test/**/*.spec.mjs'], 6 | coverage: { 7 | provider: 'v8', 8 | reporter: ['lcovonly', 'html', 'text-summary'], 9 | reportsDirectory: './coverage', 10 | include: ['src/**/*.js'], 11 | all: true 12 | }, 13 | clearMocks: true, 14 | restoreMocks: true, 15 | environment: 'node' 16 | } 17 | }) 18 | 19 | -------------------------------------------------------------------------------- /test/fixtures/with-entities-in-attributes.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/5/file3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/5/file2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/fixtures/m3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # node-waf configuration 18 | .lock-wscript 19 | 20 | # Compiled binary addons (http://nodejs.org/api/addons.html) 21 | build/Release 22 | 23 | # Dependency directory 24 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 25 | node_modules 26 | 27 | # Optional npm cache directory 28 | .npm 29 | 30 | # Optional REPL history 31 | .node_repl_history 32 | 33 | typings 34 | 35 | test/output/*.xml 36 | 37 | -------------------------------------------------------------------------------- /test/fixtures/with-entity-char.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | java.lang.AssertionError: failure message with ]]> 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/fixtures/m2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | (src/fail.spec.js:14:21)]]> 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/3/file2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/domHelpers.js: -------------------------------------------------------------------------------- 1 | function getNodeAttribute(node, name) { 2 | for (const attrNode of node.attributes) { 3 | if (attrNode.name === name) { 4 | return attrNode.value 5 | } 6 | } 7 | } 8 | 9 | function isTestSuiteNode(node) { 10 | return node.nodeName.toLowerCase() === 'testsuite' 11 | } 12 | 13 | function isTestSuitesNode(node) { 14 | return node.nodeName.toLowerCase() === 'testsuites' 15 | } 16 | 17 | function findTestSuiteByName(builder, suiteName) { 18 | return builder.find( 19 | ({ node }) => isTestSuiteNode(node) && suiteName === getNodeAttribute(node, 'name'), 20 | false, 21 | false 22 | ) 23 | } 24 | 25 | module.exports = { 26 | findTestSuiteByName, 27 | isTestSuiteNode, 28 | isTestSuitesNode, 29 | getNodeAttribute 30 | } 31 | -------------------------------------------------------------------------------- /test/fixtures/m1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | AssertionError: expected undefined not to be an undefined 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x, 22.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v6 22 | name: Use Node.js ${{ matrix.node-version }} 23 | - uses: actions/setup-node@v6 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm ci 27 | - run: npm test 28 | - uses: codecov/codecov-action@v5 29 | with: 30 | directory: ./coverage/ 31 | fail_ci_if_error: true 32 | path_to_write_report: ./coverage/codecov_report.txt 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/1/file1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 8 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/1/file2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 8 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/2/file1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 8 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/3/file1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 8 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | function normalizeArgs(options, cb) { 2 | let normalizedOptions = options || {} 3 | let callback 4 | if (typeof cb === 'function') { 5 | callback = cb 6 | } else if (typeof options === 'function' && !cb) { 7 | normalizedOptions = {} 8 | callback = options 9 | } 10 | 11 | let returnValue 12 | if (!callback) { 13 | returnValue = new Promise((resolve, reject) => { 14 | callback = (err, value) => { 15 | if (err) { 16 | reject(err) 17 | } else { 18 | resolve(value) 19 | } 20 | } 21 | }) 22 | } 23 | 24 | return { 25 | callback, 26 | normalizedOptions, 27 | returnValue 28 | } 29 | } 30 | 31 | async function readableToString(readable) { 32 | let result = '' 33 | for await (const chunk of readable) { 34 | result += chunk 35 | } 36 | return result 37 | } 38 | 39 | function isNumeric(str) { 40 | return !isNaN(str) && !isNaN(parseFloat(str)) 41 | } 42 | 43 | module.exports = { 44 | normalizeArgs, 45 | readableToString, 46 | isNumeric 47 | } 48 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/5/expected.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Hovhannes Babayan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/2/file2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 8 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9 | 10 | 11 | 12 | 13 | 14 | ffmpeg 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/attributes.js: -------------------------------------------------------------------------------- 1 | function sumAggregator(a, b) { 2 | return Number(a) + Number(b) 3 | } 4 | 5 | function maxAggregator(a, b) { 6 | return Math.max(Number(a), Number(b)) 7 | } 8 | 9 | /** 10 | * We use https://github.com/windyroad/JUnit-Schema/blob/master/JUnit.xsd as a reference. 11 | * 12 | * `rollup: true` - means that attribute will be aggregated for "testsuite" 13 | * elements and applied to the root "testsuites" element. 14 | * 15 | * `rollup: false` - means that attribute will be aggregated only for "testsuite" elements. 16 | * 17 | * Attributes not in this list won't be aggregated. 18 | */ 19 | module.exports.KNOWN_ATTRIBUTES = { 20 | tests: { 21 | aggregator: sumAggregator, 22 | rollup: true 23 | }, 24 | failures: { 25 | aggregator: sumAggregator, 26 | rollup: true 27 | }, 28 | errors: { 29 | aggregator: sumAggregator, 30 | rollup: true 31 | }, 32 | skipped: { 33 | aggregator: sumAggregator, 34 | rollup: true 35 | }, 36 | time: { 37 | // usually, reports are being generated in a parallel, so using "sum" aggregator here can be wrong. 38 | aggregator: maxAggregator, 39 | rollup: true 40 | }, 41 | assertions: { 42 | aggregator: sumAggregator, 43 | rollup: false 44 | }, 45 | warnings: { 46 | aggregator: sumAggregator, 47 | rollup: false 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { Command } = require('commander') 4 | const pkg = require('./package.json') 5 | const { mergeFiles } = require('./src/mergeFiles.js') 6 | 7 | function getProgram() { 8 | const program = new Command() 9 | program.version(pkg.version) 10 | program.description( 11 | pkg.description + 12 | '\n\n' + 13 | 'Example (combines a.xml and b.xml into target.xml):\n jrm target.xml a.xml b.xml' + 14 | '\n\n' + 15 | 'Example (glob patterns to match input files):\n jrm ./results/combined.xml "./results/units/*.xml" "./results/e2e/*.xml"' 16 | ) 17 | program.arguments(' ') 18 | return program 19 | } 20 | 21 | const program = getProgram() 22 | program.action(async function (destination, sources) { 23 | const destFilePath = destination 24 | const srcFilePathsOrGlobPatterns = sources 25 | 26 | let processedFileCount = 0 27 | await mergeFiles(destFilePath, srcFilePathsOrGlobPatterns, { 28 | onFileMatched: () => { 29 | ++processedFileCount 30 | } 31 | }) 32 | process.stdout.write(`Done. ${processedFileCount} files processed.\n`) 33 | if (processedFileCount === 0) { 34 | process.stdout.write(`Provided input file patterns did not matched any file.\n`) 35 | } 36 | }) 37 | program.parseAsync().catch((e) => { 38 | console.error(e) 39 | process.exitCode = 1 40 | }) 41 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/1/expected.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 8 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9 | 10 | 11 | 12 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 13 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/2/expected.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 8 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9 | 10 | 11 | 12 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 13 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 14 | 15 | 16 | 17 | 18 | 19 | ffmpeg 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/fixtures/testsuite-merging/3/expected.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 8 | built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "junit-report-merger", 3 | "version": "9.0.3", 4 | "description": "Merges multiple JUnit XML reports into one.", 5 | "main": "index.js", 6 | "bin": { 7 | "junit-report-merger": "./cli.js", 8 | "jrm": "./cli.js" 9 | }, 10 | "files": [ 11 | "typings", 12 | "index.js", 13 | "cli.js", 14 | "src/**/*.js" 15 | ], 16 | "types": "typings/index.d.ts", 17 | "scripts": { 18 | "format": "prettier --write '**/*.{ts,tsx,js,jsx,css,md,yml}'", 19 | "prepublishOnly": "npm run typings", 20 | "test": "vitest run --coverage", 21 | "typings": "tsc index.js --declaration --allowJs --emitDeclarationOnly --skipLibCheck --outDir typings" 22 | }, 23 | "dependencies": { 24 | "commander": "~14.0.0", 25 | "tinyglobby": "^0.2.15", 26 | "xmlbuilder2": "4.0.3" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/bhovhannes/junit-report-merger.git" 31 | }, 32 | "keywords": [ 33 | "junit", 34 | "xml", 35 | "cypress", 36 | "report", 37 | "test", 38 | "result", 39 | "merge", 40 | "combine" 41 | ], 42 | "author": "Hovhannes Babayan", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/bhovhannes/junit-report-merger/issues" 46 | }, 47 | "homepage": "https://github.com/bhovhannes/junit-report-merger#readme", 48 | "devDependencies": { 49 | "@bhovhannes/shared-config": "0.0.1", 50 | "@evilmartians/lefthook": "2.0.11", 51 | "@vitest/coverage-v8": "^4.0.13", 52 | "prettier": "3.7.4", 53 | "typescript": "5.9.3", 54 | "vitest": "^4.0.13" 55 | }, 56 | "engines": { 57 | "node": ">=20" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/fixtures/expected/expected-combined-1-3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | AssertionError: expected undefined not to be an undefined 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | (src/fail.spec.js:14:21)]]> 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/mergeStreams.js: -------------------------------------------------------------------------------- 1 | const { normalizeArgs, readableToString } = require('./helpers.js') 2 | const { mergeToString } = require('./mergeToString.js') 3 | 4 | /** 5 | * @typedef {{}} MergeStreamsOptions 6 | * 7 | * @callback TMergeStreamsCallback 8 | * @param {Error} [err] Error if any 9 | * @return {void} 10 | * 11 | * 12 | * @callback MergeStreamsCallbackStyle 13 | * @param {import('stream').Writable} destStream A stream which will be used to write the merge result. 14 | * @param {import('stream').Readable[]} srcStreams Streams which will be used to read data from. 15 | * @param {MergeStreamsOptions} options Merge options. Currently unused. 16 | * @param {TMergeStreamsCallback} cb Callback function which will be called at completion. Will receive error as first argument if any. 17 | * @return {void} 18 | * 19 | * @callback MergeStreamsPromiseStyle 20 | * @param {import('stream').Writable} destStream A stream which will be used to write the merge result. 21 | * @param {import('stream').Readable[]} srcStreams Streams which will be used to read data from. 22 | * @param {MergeStreamsOptions} [options] Merge options. Currently unused. 23 | * @return {Promise} 24 | * 25 | * @typedef {MergeStreamsCallbackStyle & MergeStreamsPromiseStyle} MergeStreamsFn 26 | * 27 | * @type {MergeStreamsFn} 28 | */ 29 | module.exports.mergeStreams = function (destStream, srcStreams, options, cb) { 30 | const { callback, normalizedOptions, returnValue } = normalizeArgs(options, cb) 31 | 32 | Promise.all(srcStreams.map(readableToString)) 33 | .then(async (srcStrings) => { 34 | let destString = await mergeToString(srcStrings, options) 35 | destStream.on('error', callback) 36 | destStream.write(destString, 'utf8', callback) 37 | }) 38 | .catch(callback) 39 | 40 | return returnValue 41 | } 42 | -------------------------------------------------------------------------------- /src/mergeFiles.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs') 2 | const { glob } = require('tinyglobby') 3 | const { normalizeArgs } = require('./helpers.js') 4 | const { mergeToString } = require('./mergeToString.js') 5 | 6 | /** 7 | * @typedef {Object} MatchInfo Describes a single file match which will be processed 8 | * @property {string} filePath Path to the file 9 | * 10 | * @callback MergeFilesCallback 11 | * @param {MatchInfo} matchInfo 12 | * @returns {void} 13 | * 14 | * @typedef {Object} MergeFilesOptions 15 | * @property {MergeFilesCallback} [onFileMatched] A callback function which will be called for the each match 16 | * 17 | * @callback TMergeFilesCompletionCallback 18 | * @param {Error} [err] Error if any 19 | * @return {void} 20 | * 21 | * 22 | * @callback MergeFilesCallbackStyle Reads multiple files, merges their contents and write into the given file. 23 | * @param {String} destFilePath Where the output should be stored. Denotes a path to file. If file already exists, it will be overwritten. 24 | * @param {String[]} srcFilePathsOrGlobPatterns Paths to the files which should be merged or glob patterns to find them. 25 | * @param {MergeFilesOptions} options Merge options. 26 | * @param {TMergeFilesCompletionCallback} cb Callback function which will be called at completion. Will receive error as first argument if any. 27 | * @return {void} 28 | * 29 | * @callback MergeFilesPromiseStyle Reads multiple files, merges their contents and write into the given file. 30 | * @param {String} destFilePath Where the output should be stored. Denotes a path to file. If file already exists, it will be overwritten. 31 | * @param {String[]} srcFilePathsOrGlobPatterns Paths to the files which should be merged or glob patterns to find them. 32 | * @param {MergeFilesOptions} [options] Merge options. Currently unused. 33 | * @return {Promise} 34 | * 35 | * @typedef {MergeFilesCallbackStyle & MergeFilesPromiseStyle} MergeFilesFn 36 | * 37 | * @type {MergeFilesFn} 38 | */ 39 | module.exports.mergeFiles = function (destFilePath, srcFilePathsOrGlobPatterns, options, cb) { 40 | const { callback, normalizedOptions, returnValue } = normalizeArgs(options, cb) 41 | 42 | glob(srcFilePathsOrGlobPatterns, { dot: true, expandDirectories: false }) 43 | .then(async (srcFilePaths) => { 44 | const srcStrings = [] 45 | 46 | for (const srcFilePath of srcFilePaths) { 47 | if (normalizedOptions.onFileMatched) { 48 | normalizedOptions.onFileMatched({ 49 | filePath: srcFilePath 50 | }) 51 | } 52 | 53 | const content = await fs.promises.readFile(srcFilePath, 'utf8') 54 | srcStrings.push(content) 55 | } 56 | 57 | const mergedContent = await mergeToString(srcStrings, {}) 58 | await fs.promises.writeFile(destFilePath, mergedContent, 'utf8') 59 | 60 | callback() 61 | }) 62 | .catch(callback) 63 | 64 | return returnValue 65 | } 66 | -------------------------------------------------------------------------------- /src/mergeToString.js: -------------------------------------------------------------------------------- 1 | const { KNOWN_ATTRIBUTES } = require('./attributes.js') 2 | const { isNumeric } = require('./helpers.js') 3 | const { 4 | getNodeAttribute, 5 | findTestSuiteByName, 6 | isTestSuiteNode, 7 | isTestSuitesNode 8 | } = require('./domHelpers.js') 9 | 10 | /** 11 | * @typedef {{}} MergeStringsOptions 12 | */ 13 | 14 | /** 15 | * Merges contents of given XML strings and returns resulting XML string. 16 | * @param {String[]} srcStrings Array of strings to merge together. 17 | * @param {MergeStringsOptions} [options] Merge options. Currently unused. 18 | * @return {Promise} 19 | */ 20 | module.exports.mergeToString = async function (srcStrings, options) { 21 | const { create } = await import('xmlbuilder2') 22 | const targetDoc = create( 23 | { 24 | encoding: 'UTF-8' 25 | }, 26 | { 27 | testsuites: {} 28 | } 29 | ) 30 | 31 | srcStrings 32 | .map((s) => s.trim()) 33 | .filter(Boolean) 34 | .forEach((srcString) => { 35 | function handleTestSuiteElement(visitorContext, builder) { 36 | const suiteName = getNodeAttribute(builder.node, 'name') 37 | const targetTestSuite = findTestSuiteByName(visitorContext.targetBuilder, suiteName) 38 | if (targetTestSuite) { 39 | // merge attributes from builder.node with targetTestSuite.node 40 | for (let srcAttr of builder.node.attributes) { 41 | const existingValue = getNodeAttribute(targetTestSuite.node, srcAttr.name) 42 | if (existingValue !== undefined) { 43 | if ( 44 | srcAttr.name in KNOWN_ATTRIBUTES && 45 | isNumeric(srcAttr.value) && 46 | isNumeric(existingValue) 47 | ) { 48 | const { aggregator } = KNOWN_ATTRIBUTES[srcAttr.name] 49 | targetTestSuite.att(srcAttr.name, aggregator(existingValue, srcAttr.value)) 50 | } 51 | } else { 52 | targetTestSuite.att(srcAttr.name, srcAttr.value) 53 | } 54 | } 55 | return targetTestSuite 56 | } else { 57 | visitorContext.targetBuilder.import(builder) 58 | } 59 | } 60 | 61 | function visitNodesRecursively(visitorContext, startingBuilder) { 62 | startingBuilder.each( 63 | (builder) => { 64 | const { node } = builder 65 | if (isTestSuiteNode(node)) { 66 | const childBuilder = handleTestSuiteElement(visitorContext, builder) 67 | if (childBuilder) { 68 | let targetBuilderBackup = visitorContext.targetBuilder 69 | visitorContext.targetBuilder = childBuilder 70 | visitNodesRecursively(visitorContext, builder) 71 | visitorContext.targetBuilder = targetBuilderBackup 72 | } 73 | } else { 74 | visitorContext.targetBuilder.import(builder) 75 | } 76 | }, 77 | false, 78 | false 79 | ) 80 | } 81 | 82 | let srcBuilder = create(srcString) 83 | if (!isTestSuitesNode(srcBuilder.root().node)) { 84 | srcBuilder = create( 85 | { 86 | encoding: 'UTF-8' 87 | }, 88 | { 89 | testsuites: [srcBuilder.toObject()] 90 | } 91 | ) 92 | } 93 | visitNodesRecursively( 94 | { 95 | currentPath: [], 96 | targetBuilder: targetDoc.root() 97 | }, 98 | srcBuilder.root() 99 | ) 100 | }) 101 | 102 | const attributes = {} 103 | const attributeNames = [] 104 | for (let attrName of Object.keys(KNOWN_ATTRIBUTES)) { 105 | if (KNOWN_ATTRIBUTES[attrName].rollup) { 106 | attributeNames.push(attrName) 107 | } 108 | } 109 | const testSuitesElement = targetDoc.root() 110 | testSuitesElement.each( 111 | ({ node }) => { 112 | if (isTestSuiteNode(node)) { 113 | for (let attrName of attributeNames) { 114 | const attrValue = getNodeAttribute(node, attrName) 115 | if (attrValue !== undefined && isNumeric(attrValue)) { 116 | const { aggregator } = KNOWN_ATTRIBUTES[attrName] 117 | attributes[attrName] = aggregator(attributes[attrName] || 0, attrValue) 118 | } 119 | } 120 | } 121 | }, 122 | false, 123 | false 124 | ) 125 | for (let attrName of attributeNames) { 126 | if (attrName in attributes) { 127 | testSuitesElement.att(attrName, attributes[attrName]) 128 | } 129 | } 130 | 131 | return targetDoc.toString({ 132 | allowEmptyTags: true, 133 | prettyPrint: true, 134 | noDoubleEncoding: true 135 | }) 136 | } 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # junit-report-merger 2 | 3 | [![NPM version][npm-version-image]][npm-url] [![NPM downloads][npm-downloads-image]][npm-url] [![codecov][codecov-image]][codecov-url] [![MIT License][license-image]][license-url] 4 | 5 | Merges multiple JUnit XML reports into one. 6 | 7 | Reporters of many testing frameworks generate JUnit XML reports. [`mocha-junit-reporter`](https://www.npmjs.com/package/mocha-junit-reporter), [`karma-junit-reporter`](https://www.npmjs.com/package/karma-junit-reporter) to name a few. Sometimes there is a need to combine multiple reports together in a single file. This is what `junit-report-merger` does. 8 | 9 | `junit-report-merger` creates a new test results report in [JUnit XML format](#junit-xml-format) by collecting all `` elements from all XML reports and putting them together. 10 | 11 | ## CLI 12 | 13 | Package provides a `jrm` binary, which you can use to merge multiple xml reports into one. 14 | In a nutshell it is a tiny wrapper around [mergeFiles](#mergefiles) api. 15 | 16 | ### Installing 17 | 18 | #### Globally 19 | 20 | ```shell script 21 | npm install -g junit-report-merger 22 | ``` 23 | 24 | In this case you'll be able to execute `jrm` binary from within your shell. 25 | 26 | #### Locally 27 | 28 | ```shell script 29 | npm install junit-report-merger --save-dev 30 | ``` 31 | 32 | In this case `jrm` binary will be available only inside `package.json` scripts: 33 | 34 | ``` 35 | scripts: { 36 | "merge-reports": "jrm combined.xml \"results/*.xml\"" 37 | } 38 | ``` 39 | 40 | ### Usage 41 | 42 | Assuming your JUnit test results are in `./results/units/` folder, and you want to get a combined test result file in `./results/combined.xml`: 43 | 44 | ```shell script 45 | jrm ./results/combined.xml "./results/units/*.xml" 46 | ``` 47 | 48 | You can also specify multiple glob patterns: 49 | 50 | ```shell script 51 | jrm ./results/combined.xml "./results/units/*.xml" "./results/e2e/*.xml" 52 | ``` 53 | 54 | **NOTE** 55 | Make sure to wrap each pattern with double quotes (`"`), otherwise your shell may try to expand it instead of passing to Node.js. 56 | 57 | ## API 58 | 59 | Package exports a single object with the following methods. 60 | 61 | [mergeFiles](#mergefiles) - Merges contents of multiple XML report files into a single XML report file. 62 | 63 | [mergeStreams](#mergestreams) - Merges contents of multiple XML report streams into a single XML report stream. 64 | 65 | [mergeToString](#mergetostring) - Merges multiple XML report strings into a single XML report string. 66 | 67 | ## Usage 68 | 69 | ```javascript 70 | const path = require('path') 71 | const { mergeFiles } = require('junit-report-merger') 72 | 73 | const outputFile = path.join(__dirname, 'results', 'combined.xml') 74 | 75 | const inputFiles = ['./results/units/*.xml', './results/e2e/*.xml'] 76 | 77 | try { 78 | await mergeFiles(outputFile, inputFiles) 79 | console.log('Merged, check ./results/combined.xml') 80 | } catch (err) { 81 | console.error(error) 82 | } 83 | ``` 84 | 85 | ## `mergeFiles` 86 | 87 | Signature: 88 | 89 | ```typescript 90 | mergeFiles( 91 | destFilePath: string, 92 | srcFilePathsOrGlobPatterns: string[], 93 | options?: MergeFilesOptions 94 | ) => Promise 95 | 96 | mergeFiles( 97 | destFilePath: string, 98 | srcFilePathsOrGlobPatterns: string[], 99 | options: MergeFilesOptions, 100 | cb: (err?: Error) => void 101 | ) => void 102 | ``` 103 | 104 | Reads multiple files, merges their contents and write into the given file. 105 | 106 | | Param | Type | Description | 107 | | -------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | 108 | | destFilePath | string | Where the output should be stored. Denotes a path to file. If file already exists, it will be overwritten. | 109 | | srcFilePathsOrGlobPatterns | string[] | Paths to the files which should be merged. You can also specify glob patterns, such as `results/**/report-*.xml` | 110 | | [options] | [MergeFilesOptions](#mergefilesoptions) | Merge options. | 111 | | [cb] | (err?: Error) => void | Callback function which will be called at completion. Will receive error as first argument if any. | 112 | 113 | Last argument - `cb` is a Node.js style callback function. If callback function is not passed, function will return a promise. That is, all the following variants will work: 114 | 115 | ```javascript 116 | // options passed, callback style 117 | mergeFiles(destFilePath, srcFilePaths, {}, (err) => {}) 118 | 119 | // options missing, callback style 120 | mergeFiles(destFilePath, srcFilePaths, (err) => {}) 121 | 122 | // options passed, promise style 123 | await mergeFiles(destFilePath, srcFilePaths, {}) 124 | 125 | // options missing, promise style 126 | await mergeFiles(destFilePath, srcFilePaths) 127 | ``` 128 | 129 | ### `MergeFilesOptions` 130 | 131 | These are the options accepted by [`mergeFiles`](#mergefiles). 132 | 133 | Signature: 134 | 135 | ```typescript 136 | type MergeFilesOptions = { 137 | onFileMatched? (matchInfo: { 138 | filePath: string 139 | }) => void 140 | } 141 | ``` 142 | 143 | #### `onFileMatched` 144 | 145 | [`mergeFiles`](#mergefiles) calls function specified by the `onFileMatched` option once for each file matched by `srcFilePaths`, right before file processing begins. 146 | 147 | ## mergeStreams 148 | 149 | Signature: 150 | 151 | ```typescript 152 | mergeStreams( 153 | destStream: WritableStream, 154 | srcStreams: ReadableStream[], 155 | options?: {} 156 | ) => Promise 157 | 158 | mergeStreams( 159 | destStream: WritableStream, 160 | srcStreams: ReadableStream[], 161 | options: {}, 162 | cb: (err?: Error) => void 163 | ) => void 164 | ``` 165 | 166 | Reads multiple streams, merges their contents and write into the given stream. 167 | 168 | | Param | Type | Description | 169 | | ---------- | ---------------------------------- | -------------------------------------------------------------------------------------------------- | 170 | | destStream | WritableStream | A stream which will be used to write the merge result. | 171 | | srcStreams | ReadableStream[] | Streams which will be used to read data from. | 172 | | [options] | object | Merge options. Currently unused. | 173 | | [cb] | (err?: Error) => void | Callback function which will be called at completion. Will receive error as first argument if any. | 174 | 175 | Last argument - `cb` is a Node.js style callback function. If callback function is not passed, function will return a promise. That is, all the following variants will work: 176 | 177 | ```javascript 178 | // options passed, callback style 179 | mergeStreams(destStream, srcStreams, {}, (err) => {}) 180 | 181 | // options missing, callback style 182 | mergeStreams(destStream, srcStreams, (err) => {}) 183 | 184 | // options passed, promise style 185 | await mergeStreams(destStream, srcStreams, {}) 186 | 187 | // options missing, promise style 188 | await mergeStreams(destStream, srcStreams) 189 | ``` 190 | 191 | ## mergeToString 192 | 193 | Signature: 194 | 195 | ```typescript 196 | mergeToString( 197 | srcStrings: string[], 198 | options?: {} 199 | ) => string 200 | ``` 201 | 202 | Merges given XML strings and returns the result. 203 | 204 | | Param | Type | Description | 205 | | ---------- | --------------------- | ----------------------------------- | 206 | | srcStrings | string[] | Array of strings to merge together. | 207 | | [options] | object | Merge options. Currently unused. | 208 | 209 | ## JUnit XML Format 210 | 211 | Unfortunately, there is no official specification of JUnit XML file format. 212 | 213 | The XML schema for the original JUnit XML format is [here](https://github.com/windyroad/JUnit-Schema/blob/master/JUnit.xsd). 214 | 215 | Over the time, various CI tools and test management software augmented original format with their own properties. 216 | The most comprehensive overview of the format is put together by folks at Testmo [here](https://github.com/testmoapp/junitxml). 217 | `jrm` produces output conforming to that format and accepts files conforming to that format. 218 | 219 | ## License 220 | 221 | MIT (http://www.opensource.org/licenses/mit-license.php) 222 | 223 | [license-image]: http://img.shields.io/badge/license-MIT-blue.svg?style=flat 224 | [license-url]: LICENSE 225 | [npm-url]: https://www.npmjs.org/package/junit-report-merger 226 | [npm-version-image]: https://img.shields.io/npm/v/junit-report-merger.svg?style=flat 227 | [npm-downloads-image]: https://img.shields.io/npm/dm/junit-report-merger.svg?style=flat 228 | [codecov-url]: https://codecov.io/gh/bhovhannes/junit-report-merger 229 | [codecov-image]: https://codecov.io/gh/bhovhannes/junit-report-merger/branch/master/graph/badge.svg?token=iJvUUKrgzB 230 | -------------------------------------------------------------------------------- /test/e2e.spec.mjs: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest' 2 | import path from 'node:path' 3 | import fsPromises from 'node:fs/promises' 4 | import fs from 'node:fs' 5 | import { create } from 'xmlbuilder2' 6 | import { Writable, Readable } from 'node:stream' 7 | import { mergeFiles, mergeStreams } from '../index.js' 8 | 9 | describe('e2e', function () { 10 | let fixturePaths 11 | beforeEach(() => { 12 | fixturePaths = { 13 | inputs: [ 14 | path.join(__dirname, 'fixtures', 'm1.xml'), 15 | path.join(__dirname, 'fixtures', 'm2.xml'), 16 | path.join(__dirname, 'fixtures', 'm3.xml') 17 | ], 18 | output: path.join(__dirname, 'output', 'actual-combined-1-3.xml') 19 | } 20 | }) 21 | 22 | async function assertOutput() { 23 | const contents = await fsPromises.readFile(fixturePaths.output, { encoding: 'utf8' }) 24 | const doc = create(contents).root() 25 | expect(doc.node.childNodes).toHaveLength(4) 26 | 27 | expect(doc.node.nodeName.toLowerCase()).toBe('testsuites') 28 | const foundAttrs = {} 29 | for (const attrNode of doc.node.attributes) { 30 | const name = attrNode.name 31 | if (['tests', 'errors', 'failures'].includes(name)) { 32 | foundAttrs[name] = attrNode.value 33 | } 34 | } 35 | expect(foundAttrs).toEqual({ 36 | tests: '6', 37 | errors: '0', 38 | failures: '2' 39 | }) 40 | } 41 | 42 | describe('mergeFiles', function () { 43 | afterEach(async () => { 44 | await fsPromises.unlink(fixturePaths.output) 45 | }) 46 | 47 | it('merges xml reports (options passed)', async () => { 48 | await mergeFiles(fixturePaths.output, fixturePaths.inputs, {}) 49 | await assertOutput() 50 | }) 51 | 52 | it('merges xml reports (options omitted)', async () => { 53 | await mergeFiles(fixturePaths.output, fixturePaths.inputs) 54 | await assertOutput() 55 | }) 56 | 57 | it('merges xml reports matching given glob pattern', async () => { 58 | await mergeFiles(fixturePaths.output, ['./**/fixtures/m*.xml']) 59 | await assertOutput() 60 | }) 61 | 62 | it('calls onFileMatched for each matching file', async () => { 63 | const onFileMatched = vi.fn() 64 | await mergeFiles(fixturePaths.output, ['./**/fixtures/m*.xml'], { 65 | onFileMatched 66 | }) 67 | expect(onFileMatched.mock.calls).toEqual([ 68 | [ 69 | { 70 | filePath: 'test/fixtures/m1.xml' 71 | } 72 | ], 73 | [ 74 | { 75 | filePath: 'test/fixtures/m2.xml' 76 | } 77 | ], 78 | [ 79 | { 80 | filePath: 'test/fixtures/m3.xml' 81 | } 82 | ] 83 | ]) 84 | await assertOutput() 85 | }) 86 | 87 | it('produces an empty xml report when no files match given glob pattern', async () => { 88 | await mergeFiles(fixturePaths.output, ['./no/files/will/match/this/*.xml']) 89 | const contents = await fsPromises.readFile(fixturePaths.output, { encoding: 'utf8' }) 90 | expect(create(contents).root().node.childNodes).toHaveLength(0) 91 | }) 92 | 93 | it('merges xml reports (options passed, callback style)', async () => { 94 | await new Promise((resolve, reject) => { 95 | mergeFiles(fixturePaths.output, fixturePaths.inputs, {}, (err) => { 96 | if (err) { 97 | reject(err) 98 | } else { 99 | resolve() 100 | } 101 | }) 102 | }) 103 | 104 | await assertOutput() 105 | }) 106 | 107 | it('merges xml reports (options omitted, callback style)', async () => { 108 | await new Promise((resolve, reject) => { 109 | mergeFiles(fixturePaths.output, fixturePaths.inputs, (err) => { 110 | if (err) { 111 | reject(err) 112 | } else { 113 | resolve() 114 | } 115 | }) 116 | }) 117 | 118 | await assertOutput() 119 | }) 120 | 121 | it('preserves empty tags', async () => { 122 | await mergeFiles( 123 | fixturePaths.output, 124 | [path.join(__dirname, 'fixtures', 'with-empty-tag.xml')], 125 | {} 126 | ) 127 | 128 | const contents = await fsPromises.readFile(fixturePaths.output, { encoding: 'utf8' }) 129 | expect(contents).toContain('') 130 | }) 131 | 132 | it('correctly merges empty reports', async () => { 133 | await mergeFiles( 134 | fixturePaths.output, 135 | [ 136 | path.join(__dirname, 'fixtures', 'empty.xml'), 137 | path.join(__dirname, 'fixtures', 'spaces.xml') 138 | ], 139 | {} 140 | ) 141 | 142 | const contents = await fsPromises.readFile(fixturePaths.output, { encoding: 'utf8' }) 143 | expect(contents).toBe( 144 | '\n' + '' 145 | ) 146 | }) 147 | 148 | it('preserves xml entities', async () => { 149 | await mergeFiles( 150 | fixturePaths.output, 151 | [path.join(__dirname, 'fixtures', 'with-entity-char.xml')], 152 | {} 153 | ) 154 | 155 | const contents = await fsPromises.readFile(fixturePaths.output, { encoding: 'utf8' }) 156 | expect(contents).toContain('failure attr with ]]>') 157 | expect(contents).toContain('failure message with ]]>') 158 | }) 159 | 160 | it('preserves xml entities in attributes', async () => { 161 | await mergeFiles( 162 | fixturePaths.output, 163 | [path.join(__dirname, 'fixtures', 'with-entities-in-attributes.xml')], 164 | {} 165 | ) 166 | 167 | const contents = await fsPromises.readFile(fixturePaths.output, { encoding: 'utf8' }) 168 | expect(contents).toContain('SingleSemicolon(&)') 169 | expect(contents).toContain('DoubleSemicolon(&;)') 170 | }) 171 | 172 | it('merges m*.xml files into one, matching predefined snapshot', async () => { 173 | await mergeFiles(fixturePaths.output, fixturePaths.inputs) 174 | const actualContents = await fsPromises.readFile(fixturePaths.output, { encoding: 'utf8' }) 175 | const expectedContents = await fsPromises.readFile( 176 | path.join(__dirname, 'fixtures', 'expected', 'expected-combined-1-3.xml'), 177 | 'utf8' 178 | ) 179 | expect(create(actualContents).toObject()).toEqual(create(expectedContents).toObject()) 180 | }) 181 | 182 | it.each([ 183 | { fixtureFolder: path.join('testsuite-merging', '1') }, 184 | { fixtureFolder: path.join('testsuite-merging', '2') }, 185 | { fixtureFolder: path.join('testsuite-merging', '3') }, 186 | { fixtureFolder: path.join('testsuite-merging', '4') }, 187 | { fixtureFolder: path.join('testsuite-merging', '5') } 188 | ])( 189 | 'combines similar testsuite elements ("$fixtureFolder/file*.xml" -> "$fixtureFolder/expected.xml")', 190 | async ({ fixtureFolder }) => { 191 | const expectedOutputPath = path.join(__dirname, 'fixtures', fixtureFolder, 'expected.xml') 192 | fixturePaths.inputs = [path.join(__dirname, 'fixtures', fixtureFolder, 'file*.xml')] 193 | await mergeFiles(fixturePaths.output, fixturePaths.inputs) 194 | const actualContents = await fsPromises.readFile(fixturePaths.output, { encoding: 'utf8' }) 195 | const expectedContents = await fsPromises.readFile(expectedOutputPath, { encoding: 'utf8' }) 196 | expect(create(actualContents).toObject()).toEqual(create(expectedContents).toObject()) 197 | } 198 | ) 199 | }) 200 | 201 | describe('mergeStreams', function () { 202 | let destStream 203 | let destBuffer 204 | 205 | beforeEach(() => { 206 | destBuffer = [] 207 | destStream = new Writable({ 208 | write(chunk, encoding, callback) { 209 | destBuffer.push(chunk.toString()) 210 | callback() 211 | } 212 | }) 213 | }) 214 | 215 | function createReadableFromString(str) { 216 | return Readable.from([str]) 217 | } 218 | 219 | function getDestString() { 220 | return destBuffer.join('') 221 | } 222 | 223 | it('merges multiple streams (promise style)', async () => { 224 | const srcStreams = [ 225 | fs.createReadStream(path.join(__dirname, 'fixtures', 'm1.xml')), 226 | fs.createReadStream(path.join(__dirname, 'fixtures', 'm2.xml')) 227 | ] 228 | 229 | await mergeStreams(destStream, srcStreams, {}) 230 | 231 | const result = getDestString() 232 | expect(result).toContain('') 234 | }) 235 | 236 | it('merges multiple streams (options omitted, promise style)', async () => { 237 | const srcStreams = [ 238 | fs.createReadStream(path.join(__dirname, 'fixtures', 'm1.xml')), 239 | fs.createReadStream(path.join(__dirname, 'fixtures', 'm2.xml')) 240 | ] 241 | 242 | await mergeStreams(destStream, srcStreams) 243 | 244 | const result = getDestString() 245 | expect(result).toContain(' { 249 | const srcStreams = [ 250 | fs.createReadStream(path.join(__dirname, 'fixtures', 'm1.xml')), 251 | fs.createReadStream(path.join(__dirname, 'fixtures', 'm2.xml')) 252 | ] 253 | 254 | await new Promise((resolve, reject) => { 255 | mergeStreams(destStream, srcStreams, {}, (err) => { 256 | if (err) { 257 | reject(err) 258 | } else { 259 | resolve() 260 | } 261 | }) 262 | }) 263 | 264 | const result = getDestString() 265 | expect(result).toContain(' { 269 | const srcStreams = [ 270 | fs.createReadStream(path.join(__dirname, 'fixtures', 'm1.xml')), 271 | fs.createReadStream(path.join(__dirname, 'fixtures', 'm2.xml')) 272 | ] 273 | 274 | await new Promise((resolve, reject) => { 275 | mergeStreams(destStream, srcStreams, (err) => { 276 | if (err) { 277 | reject(err) 278 | } else { 279 | resolve() 280 | } 281 | }) 282 | }) 283 | 284 | const result = getDestString() 285 | expect(result).toContain(' { 289 | const errorStream = new Readable({ 290 | read() { 291 | this.destroy(new Error('Stream read error')) 292 | } 293 | }) 294 | 295 | const srcStreams = [errorStream] 296 | 297 | await expect(mergeStreams(destStream, srcStreams, {})).rejects.toThrow('Stream read error') 298 | }) 299 | 300 | it('handles destination stream write errors', async () => { 301 | const errorDestStream = new Writable({ 302 | write(chunk, encoding, callback) { 303 | callback(new Error('Write error')) 304 | } 305 | }) 306 | 307 | const srcStreams = [ 308 | createReadableFromString( 309 | '\n' 310 | ) 311 | ] 312 | 313 | await expect(mergeStreams(errorDestStream, srcStreams, {})).rejects.toThrow('Write error') 314 | }) 315 | }) 316 | 317 | describe('cli', function () { 318 | it('merges xml reports', async () => { 319 | const { exec } = await import('node:child_process') 320 | const stdout = await new Promise((resolve, reject) => { 321 | exec( 322 | 'node ./cli.js ./test/output/actual-combined-1-3.xml "./test/**/m1.xml" "./test/**/m?.xml"', 323 | function (error, stdout, stderr) { 324 | if (error) { 325 | reject(error) 326 | } else { 327 | resolve(stdout) 328 | } 329 | } 330 | ) 331 | }) 332 | expect(stdout).toMatch('3 files processed') 333 | expect(stdout).not.toMatch('Provided input file patterns did not matched any file.') 334 | await assertOutput() 335 | }) 336 | 337 | it('provides meaningful message if no input files can be found', async () => { 338 | const { exec } = await import('node:child_process') 339 | const stdout = await new Promise((resolve, reject) => { 340 | exec( 341 | 'node ./cli.js ./test/output/actual-combined-1-3.xml "./does-not-exist/**/x1.xml"', 342 | function (error, stdout, stderr) { 343 | if (error) { 344 | reject(error) 345 | } else { 346 | resolve(stdout) 347 | } 348 | } 349 | ) 350 | }) 351 | expect(stdout).toMatch('0 files processed') 352 | expect(stdout).toMatch('Provided input file patterns did not matched any file.') 353 | }) 354 | }) 355 | }) 356 | --------------------------------------------------------------------------------