├── 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 |
--------------------------------------------------------------------------------