@currents/node-test-reporter 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +8 -0
- package/README.md +33 -0
- package/dist/index.d.ts +423 -0
- package/dist/index.js +3 -0
- package/package.json +70 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright 2024 Currents Software Inc
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
8
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @currents/node-test-reporter
|
|
2
|
+
|
|
3
|
+
A [Node.js](https://nodejs.org/) test reporter for [Currents](https://currents.dev), a cloud platform for CI test analysis, debugging, troubleshooting, and analyzing CI test results:
|
|
4
|
+
|
|
5
|
+
- Save NodeJS test results to a cloud
|
|
6
|
+
- Fetch git information and associate with CI builds
|
|
7
|
+
- Integrate with your workflow - Slack, GitHub or GitLab PR comments and status checks
|
|
8
|
+
- Flakiness, failure rate, duration and much more aggregative metrics
|
|
9
|
+
- Errors and root cause tracker
|
|
10
|
+
- Automated reports with test suite health metrics
|
|
11
|
+
- Get access to test results via REST API and HTTP webhooks
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
- NodeJS 18.20.4+
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
```shell
|
|
19
|
+
npm i -D @currents/node-test-reporter
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Run your tests with the following commands to generate reports:
|
|
25
|
+
```sh
|
|
26
|
+
node --test --test-reporter @currents/node-test-reporter --test-reporter-destination report.xml
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
You can also use this with other reporters to generate multiple outputs, such as detailed logs for debugging and summary reports for CI pipelines.
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
node --test --test-reporter @currents/node-test-reporter --test-reporter-destination report.xml --test-reporter spec --test-reporter-destination stdout
|
|
33
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { hostname } from 'os';
|
|
2
|
+
import { create } from 'xmlbuilder2';
|
|
3
|
+
|
|
4
|
+
const HOSTNAME = hostname();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Collects all parent suite names for a given test.
|
|
8
|
+
*
|
|
9
|
+
* This function walks up the test tree, skipping the root suite.
|
|
10
|
+
* @param {object} test - The test object. It must have a `parent` property (another test object)
|
|
11
|
+
* that forms a chain up to a root suite (which has a name of 'root').
|
|
12
|
+
* @returns {string[]}
|
|
13
|
+
* An array of suite names, ordered from the highest level to the immediate parent.
|
|
14
|
+
*/
|
|
15
|
+
function collectParentNames(test) {
|
|
16
|
+
const names = [];
|
|
17
|
+
|
|
18
|
+
// Start with the direct parent of the test.
|
|
19
|
+
let { parent } = test;
|
|
20
|
+
|
|
21
|
+
// Loop until we reach the root suite.
|
|
22
|
+
while (parent && parent.name !== 'root') {
|
|
23
|
+
// Prepend the parent's name to maintain proper hierarchy order.
|
|
24
|
+
names.unshift(parent.name);
|
|
25
|
+
parent = parent.parent;
|
|
26
|
+
}
|
|
27
|
+
return names;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates a new XML `<testsuite>` element for the given test node.
|
|
32
|
+
*
|
|
33
|
+
* Maps key test metrics to XML attributes on the created element.
|
|
34
|
+
* @param {string} suiteName
|
|
35
|
+
* The unique name for the suite, used as the element's `name` attribute.
|
|
36
|
+
* @param {object} node - The test object containing metrics.
|
|
37
|
+
* @param {string} node.timestamp - ISO timestamp.
|
|
38
|
+
* @param {number} node.errors - Number of errors.
|
|
39
|
+
* @param {number} node.tests - Number of tests.
|
|
40
|
+
* @param {number} node.failures - Number of failed tests.
|
|
41
|
+
* @param {number} node.skipped - Number of skipped tests.
|
|
42
|
+
* @param {number} node.time - Duration in seconds.
|
|
43
|
+
* @param {object} parentElement - The parent XML element under which the suite element is created.
|
|
44
|
+
* @param {Map<string, object>} suiteMap
|
|
45
|
+
* A map to cache created suite elements, keyed by suiteName.
|
|
46
|
+
* @returns {object} The newly created XML `<testsuite>` element.
|
|
47
|
+
*/
|
|
48
|
+
function createSuiteElement(suiteName, node, parentElement, suiteMap) {
|
|
49
|
+
const suiteElement = parentElement.ele('testsuite', {
|
|
50
|
+
name: suiteName,
|
|
51
|
+
timestamp: node.timestamp,
|
|
52
|
+
hostname: node.hostname,
|
|
53
|
+
errors: node.errors,
|
|
54
|
+
tests: node.tests,
|
|
55
|
+
failures: node.failures,
|
|
56
|
+
skipped: node.skipped,
|
|
57
|
+
time: node.time,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Store the created element for future reference.
|
|
61
|
+
suiteMap.set(suiteName, suiteElement);
|
|
62
|
+
return suiteElement;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Formats an error object as a pretty-printed JSON string.
|
|
67
|
+
*
|
|
68
|
+
* If an error is not provided (null or undefined), it returns an empty string.
|
|
69
|
+
* @param {object|null|undefined} error
|
|
70
|
+
* The error object to format. May contain properties like message and stack.
|
|
71
|
+
* @returns {string} A formatted string representing the error information,
|
|
72
|
+
* or an empty string if no error is provided.
|
|
73
|
+
*/
|
|
74
|
+
function formatError(error) {
|
|
75
|
+
if (!error) return '';
|
|
76
|
+
return `${JSON.stringify(error, null, 2)}\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Constructs the full display name for a test.
|
|
81
|
+
*
|
|
82
|
+
* Uses parent suite names (if any) concatenated with the test name,
|
|
83
|
+
* ensuring that double quotes in the test name are escaped properly.
|
|
84
|
+
* @param {object} test - The test object
|
|
85
|
+
* @param {string} test.name - The name of the test.
|
|
86
|
+
* @param {object} [test.parent] - A reference to its parent test object.
|
|
87
|
+
* @returns {string}
|
|
88
|
+
* The fully qualified name of the test, with parent suite names separated by " > ".
|
|
89
|
+
*/
|
|
90
|
+
function getFullTestName(test) {
|
|
91
|
+
const describeNames = collectParentNames(test);
|
|
92
|
+
const testName = test.name.replace(/"/g, '"');
|
|
93
|
+
|
|
94
|
+
// If there are parent names, join them with ' > ', otherwise use the test name alone.
|
|
95
|
+
return describeNames.length
|
|
96
|
+
? `${describeNames.join(' > ')} > ${testName}`
|
|
97
|
+
: testName;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Determines if a node has a child element with a specified tag.
|
|
102
|
+
* @param {object} node - The node to check.
|
|
103
|
+
* Expected to have a `children` array or properties corresponding to tags.
|
|
104
|
+
* @param {string} tag - The tag name to search for (e.g., `failure` or `skipped`).
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
* `true` if a child element or property with the specified tag exists; otherwise, `false`.
|
|
107
|
+
*/
|
|
108
|
+
function hasChildWithTag(node, tag) {
|
|
109
|
+
return node?.children?.some((child) => child.tag === tag) || node?.[tag];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Traverses the children of a test node and adds them to the XML document.
|
|
114
|
+
*
|
|
115
|
+
* For suite nodes, the function recurses into child nodes. For test cases,
|
|
116
|
+
* it creates an XML `<testcase>` element and adds any additional child elements
|
|
117
|
+
* such as skipped or failure details.
|
|
118
|
+
* @param {object|string} childNode - The child node to process. It can be:
|
|
119
|
+
* - an object representing a test/suite with a `children` property,
|
|
120
|
+
* - or a string representing a comment.
|
|
121
|
+
* @param {object} suiteElement - The XML element representing the parent testsuite.
|
|
122
|
+
* @returns {void}
|
|
123
|
+
*/
|
|
124
|
+
function traverseChildren(childNode, suiteElement) {
|
|
125
|
+
// If the node represents a suite, iterate recursively over its children.
|
|
126
|
+
if (childNode.isSuite) {
|
|
127
|
+
// Generate a unique full name for the suite.
|
|
128
|
+
childNode.children.forEach((grandChild) =>
|
|
129
|
+
traverseChildren(grandChild, suiteElement)
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
// Generate a unique full name for the test case.
|
|
133
|
+
const fullTestName = getFullTestName(childNode);
|
|
134
|
+
const testCaseElement = suiteElement.ele('testcase', {
|
|
135
|
+
name: fullTestName,
|
|
136
|
+
time: childNode.time,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Process each child of the test case for skipped, todo, or failure information.
|
|
140
|
+
childNode.children.forEach((grandChild) => {
|
|
141
|
+
// Add a skipped element if the test was skipped or marked as todo.
|
|
142
|
+
if (['skipped', 'todo'].includes(grandChild.type)) {
|
|
143
|
+
testCaseElement.ele('skipped', {
|
|
144
|
+
message: grandChild.message,
|
|
145
|
+
type: grandChild.type === 'todo' ? 'todo' : undefined,
|
|
146
|
+
});
|
|
147
|
+
} else if (grandChild.type) {
|
|
148
|
+
// Add a failure element if the test encountered an error.
|
|
149
|
+
const failureElement = testCaseElement.ele('failure', {
|
|
150
|
+
message: `${grandChild.message}\n${formatError(grandChild.stack)}`,
|
|
151
|
+
type: grandChild.type,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Depending on whether the stack is an object, use different methods to add text data.
|
|
155
|
+
typeof grandChild.stack === 'object'
|
|
156
|
+
? failureElement.dat(formatError(grandChild.stack))
|
|
157
|
+
: failureElement.txt(grandChild.stack);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Updates the test metrics based on the provided event details and appends additional children.
|
|
165
|
+
*
|
|
166
|
+
* This includes recording skipped tests, todo items, and failure details.
|
|
167
|
+
* @param {object} event - The event object containing test data.
|
|
168
|
+
* @param {object} event.data
|
|
169
|
+
* The test data object with properties like `skip`, `todo`, and `details`.
|
|
170
|
+
* @param {string} event.type - The type of event (e.g., 'test:fail').
|
|
171
|
+
* @param {object} currentTest
|
|
172
|
+
* The current test object to be updated. Should have a `children` array.
|
|
173
|
+
* @param {object} state - The global state object holding aggregate test metrics like
|
|
174
|
+
* `totalTests`, `totalSkipped`, and `totalFailures`.
|
|
175
|
+
* @returns {void}
|
|
176
|
+
*/
|
|
177
|
+
function updateTestMetrics(event, currentTest, state) {
|
|
178
|
+
state.totalTests += 1;
|
|
179
|
+
currentTest.isSuite = false;
|
|
180
|
+
|
|
181
|
+
// Record skipped tests.
|
|
182
|
+
if (event.data.skip) {
|
|
183
|
+
state.totalSkipped += 1;
|
|
184
|
+
currentTest.children.push({
|
|
185
|
+
nesting: event.data.nesting + 1,
|
|
186
|
+
type: 'skipped',
|
|
187
|
+
message: event.data.skip,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Record tests marked as todo.
|
|
192
|
+
if (event.data.todo) {
|
|
193
|
+
currentTest.children.push({
|
|
194
|
+
nesting: event.data.nesting + 1,
|
|
195
|
+
type: 'todo',
|
|
196
|
+
message: event.data.todo,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// If the test fails, update failure metrics and add error details.
|
|
201
|
+
if (event.type === 'test:fail') {
|
|
202
|
+
state.totalFailures += 1;
|
|
203
|
+
const error = event.data.details?.error;
|
|
204
|
+
|
|
205
|
+
// Add the failure details to the test node.
|
|
206
|
+
currentTest.children.push({
|
|
207
|
+
nesting: event.data.nesting + 1,
|
|
208
|
+
type: error?.failureType || error?.code,
|
|
209
|
+
message: error?.message || '',
|
|
210
|
+
stack: error,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Attach failure summary to the current test.
|
|
214
|
+
Object.assign(currentTest, {
|
|
215
|
+
failures: 1,
|
|
216
|
+
failure: error?.message || '',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Converts a test node and its children into corresponding XML elements.
|
|
223
|
+
*
|
|
224
|
+
* This function uses a `suiteMap` to avoid duplicate creation of suite elements and recursively
|
|
225
|
+
* processes child nodes.
|
|
226
|
+
* @param {object|string} node - The test node to convert. It can be:
|
|
227
|
+
* - an object representing a test or suite with a `children` array,
|
|
228
|
+
* - or a string representing an XML comment.
|
|
229
|
+
* @param {object} parentElement
|
|
230
|
+
* The parent XML element (using xmlbuilder2) to which the conversion is appended.
|
|
231
|
+
* @param {Map<string, object>} suiteMap - A map caching created suite elements by suite name.
|
|
232
|
+
* @returns {void}
|
|
233
|
+
*/
|
|
234
|
+
function convertToXML(node, parentElement, suiteMap) {
|
|
235
|
+
// If node is a comment (string), add it as an XML comment.
|
|
236
|
+
if (typeof node === 'string') {
|
|
237
|
+
parentElement.com(node.trim());
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* For suite nodes, generate a unique suite name, create or reuse the suite element,
|
|
242
|
+
and then process its children recursively. */
|
|
243
|
+
if (node.isSuite) {
|
|
244
|
+
// Generate a unique suite name based on the file path.
|
|
245
|
+
const suiteName = node.file
|
|
246
|
+
.replace(process.cwd(), '')
|
|
247
|
+
.replace(/"/g, '"');
|
|
248
|
+
// Create or reuse the suite element based on the suite name.
|
|
249
|
+
const suiteElement =
|
|
250
|
+
suiteMap.get(suiteName) ||
|
|
251
|
+
createSuiteElement(suiteName, node, parentElement, suiteMap);
|
|
252
|
+
|
|
253
|
+
// Process each child of the suite for skipped, todo, or failure information.
|
|
254
|
+
node.children.forEach((child) => traverseChildren(child, suiteElement));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Starts a new test or suite node based on event data.
|
|
260
|
+
*
|
|
261
|
+
* It creates a new test node and updates the state to track the current test context.
|
|
262
|
+
* @param {object} event - The event object with test initiation data.
|
|
263
|
+
* @param {string} event.name - The name of the test,
|
|
264
|
+
* @param {number} event.nesting - The nesting level of the test,
|
|
265
|
+
* @param {string} event.file - The file path where the test is defined.
|
|
266
|
+
* @param {object} state - The state object tracking the test tree. Expected properties include:
|
|
267
|
+
* @param {object|null} state.currentSuite - The current test suite,
|
|
268
|
+
* @param {Array} state.roots - The array of top-level test nodes.
|
|
269
|
+
* @returns {void}
|
|
270
|
+
*/
|
|
271
|
+
function startTest(event, state) {
|
|
272
|
+
// Construct a new test node with associated metadata.
|
|
273
|
+
const newSuite = {
|
|
274
|
+
name: event.data.name,
|
|
275
|
+
nesting: event.data.nesting,
|
|
276
|
+
parent: state.currentSuite,
|
|
277
|
+
children: [],
|
|
278
|
+
file: event.data.file,
|
|
279
|
+
timestamp: new Date().toISOString(),
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Register the new test node under its parent's children array.
|
|
283
|
+
state.currentSuite?.children.push(newSuite);
|
|
284
|
+
// Update the current suite to the new test node.
|
|
285
|
+
state.currentSuite = newSuite;
|
|
286
|
+
|
|
287
|
+
// If this is the top-level suite, add it to the roots collection.
|
|
288
|
+
if (!newSuite.parent) state.roots.push(newSuite);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Finalizes the XML document by setting aggregate test metrics as attributes on the root element
|
|
293
|
+
* and converting all test nodes into XML segments.
|
|
294
|
+
* @param {object} root - The root XML element (using xmlbuilder2) that will contain test metrics.
|
|
295
|
+
* @param {object} state - The state object that holds overall metrics
|
|
296
|
+
* (e.g., totalTests, totalFailures, totalTime) and an array of test node roots.
|
|
297
|
+
* @param {Map<string, object>} suiteMap
|
|
298
|
+
* A map of suite elements keyed by suite names to prevent duplication.
|
|
299
|
+
* @returns {void}
|
|
300
|
+
*/
|
|
301
|
+
function finalizeXML(root, state, suiteMap) {
|
|
302
|
+
// Attach aggregated metrics to the root element.
|
|
303
|
+
root.att('tests', state.totalTests);
|
|
304
|
+
root.att('failures', state.totalFailures);
|
|
305
|
+
root.att('errors', state.totalErrors);
|
|
306
|
+
root.att('skipped', state.totalSkipped);
|
|
307
|
+
root.att('time', state.totalTime.toFixed(6));
|
|
308
|
+
|
|
309
|
+
// Convert all recorded test roots to XML segments.
|
|
310
|
+
state.roots.forEach((suite) => {
|
|
311
|
+
// Skip comment strings.
|
|
312
|
+
if (typeof suite !== 'string') convertToXML(suite, root, suiteMap);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Processes an individual test event and updates the test node and state accordingly.
|
|
318
|
+
*
|
|
319
|
+
* Depending on the event's details, this function may create a new test node if the current
|
|
320
|
+
* suite does not match the event details. It also calculates the test duration and updates
|
|
321
|
+
* aggregate metrics.
|
|
322
|
+
* @param {object} event - The test event object.
|
|
323
|
+
* @param {string} event.type - The type of test event (e.g., 'test:pass', 'test:fail'),
|
|
324
|
+
* @param {object} event.data
|
|
325
|
+
* With properties such as `name`, `nesting`, and `details` (including duration in ms).
|
|
326
|
+
* @param {object} state
|
|
327
|
+
* The state object that tracks the current test context and aggregate metrics.
|
|
328
|
+
* @returns {void}
|
|
329
|
+
*/
|
|
330
|
+
function handleTestResult(event, state) {
|
|
331
|
+
// Ensure there is an active suite; if not, create a root-level suite.
|
|
332
|
+
if (!state.currentSuite)
|
|
333
|
+
startTest({ data: { name: 'root', nesting: 0 } }, state);
|
|
334
|
+
|
|
335
|
+
// If the current suite does not match the event's test details, start a new test.
|
|
336
|
+
if (
|
|
337
|
+
state.currentSuite.name !== event.data.name ||
|
|
338
|
+
state.currentSuite.nesting !== event.data.nesting
|
|
339
|
+
) {
|
|
340
|
+
startTest(event, state);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const currentTest = state.currentSuite;
|
|
344
|
+
/* If the event indicates completion of the current test (nesting match),
|
|
345
|
+
move back to the parent suite. */
|
|
346
|
+
if (currentTest.nesting === event.data.nesting) {
|
|
347
|
+
state.currentSuite = state.currentSuite.parent;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Calculate the test duration in seconds and update aggregate time.
|
|
351
|
+
currentTest.time = (event.data.details.duration_ms / 1000).toFixed(6);
|
|
352
|
+
state.totalTime += Number(currentTest.time);
|
|
353
|
+
|
|
354
|
+
// Determine if any child nodes indicate failure or skipped status.
|
|
355
|
+
const isFailure = (node) => hasChildWithTag(node, 'failure');
|
|
356
|
+
const isSkipped = (node) => hasChildWithTag(node, 'skipped');
|
|
357
|
+
const nonCommentChildren = currentTest.children.filter(
|
|
358
|
+
(child) => typeof child !== 'string'
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
// If there are non-comment children, treat the test as a suite and recalculate metrics.
|
|
362
|
+
if (nonCommentChildren.length) {
|
|
363
|
+
Object.assign(currentTest, {
|
|
364
|
+
isSuite: true,
|
|
365
|
+
errors: 0,
|
|
366
|
+
tests: nonCommentChildren.length,
|
|
367
|
+
failures: currentTest.children.filter(isFailure).length,
|
|
368
|
+
skipped: currentTest.children.filter(isSkipped).length,
|
|
369
|
+
hostname: HOSTNAME,
|
|
370
|
+
});
|
|
371
|
+
} else {
|
|
372
|
+
// Otherwise, update metrics for a regular test.
|
|
373
|
+
updateTestMetrics(event, currentTest, state);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The main generator function that processes test events into a JUnit XML report.
|
|
379
|
+
*
|
|
380
|
+
* This async generator accepts an async iterable source that yields test event objects.
|
|
381
|
+
* Each event object is expected to have a `type` property
|
|
382
|
+
* (e.g., 'test:start', 'test:pass', 'test:fail', or 'test:diagnostic')
|
|
383
|
+
* as well as a `data` property with detailed test information.
|
|
384
|
+
* @param {AsyncIterable<object>} source - An async iterable that produces test event objects.
|
|
385
|
+
* @returns {AsyncGenerator<string, void, unknown>}
|
|
386
|
+
* An async generator that yields the final XML report as a pretty-printed string.
|
|
387
|
+
*/
|
|
388
|
+
async function* junitReporter(source) {
|
|
389
|
+
const state = {
|
|
390
|
+
currentSuite: null,
|
|
391
|
+
totalFailures: 0,
|
|
392
|
+
totalSkipped: 0,
|
|
393
|
+
totalTests: 0,
|
|
394
|
+
totalTime: 0,
|
|
395
|
+
totalErrors: 0,
|
|
396
|
+
roots: [],
|
|
397
|
+
};
|
|
398
|
+
const suiteMap = new Map();
|
|
399
|
+
const root = create({ version: '1.0', encoding: 'UTF-8' }).ele('testsuites', {
|
|
400
|
+
name: 'NodeJS',
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
404
|
+
for await (const event of source) {
|
|
405
|
+
switch (event.type) {
|
|
406
|
+
case 'test:start':
|
|
407
|
+
startTest(event, state);
|
|
408
|
+
break;
|
|
409
|
+
case 'test:pass':
|
|
410
|
+
case 'test:fail':
|
|
411
|
+
handleTestResult(event, state);
|
|
412
|
+
break;
|
|
413
|
+
case 'test:diagnostic':
|
|
414
|
+
state.roots.push(`# ${event.data.message}`);
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
finalizeXML(root, state, suiteMap);
|
|
420
|
+
yield root.end({ prettyPrint: true });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export { junitReporter as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import{hostname as f}from"os";import{create as m}from"xmlbuilder2";var d=f();function g(t){let e=[],{parent:s}=t;for(;s&&s.name!=="root";)e.unshift(s.name),s=s.parent;return e}function h(t,e,s,a){let i=s.ele("testsuite",{name:t,timestamp:e.timestamp,hostname:e.hostname,errors:e.errors,tests:e.tests,failures:e.failures,skipped:e.skipped,time:e.time});return a.set(t,i),i}function c(t){return t?`${JSON.stringify(t,null,2)}
|
|
2
|
+
`:""}function S(t){let e=g(t),s=t.name.replace(/"/g,""");return e.length?`${e.join(" > ")} > ${s}`:s}function l(t,e){return t?.children?.some(s=>s.tag===e)||t?.[e]}function p(t,e){if(t.isSuite)t.children.forEach(s=>p(s,e));else{let s=S(t),a=e.ele("testcase",{name:s,time:t.time});t.children.forEach(i=>{if(["skipped","todo"].includes(i.type))a.ele("skipped",{message:i.message,type:i.type==="todo"?"todo":void 0});else if(i.type){let r=a.ele("failure",{message:`${i.message}
|
|
3
|
+
${c(i.stack)}`,type:i.type});typeof i.stack=="object"?r.dat(c(i.stack)):r.txt(i.stack)}})}}function k(t,e,s){if(s.totalTests+=1,e.isSuite=!1,t.data.skip&&(s.totalSkipped+=1,e.children.push({nesting:t.data.nesting+1,type:"skipped",message:t.data.skip})),t.data.todo&&e.children.push({nesting:t.data.nesting+1,type:"todo",message:t.data.todo}),t.type==="test:fail"){s.totalFailures+=1;let a=t.data.details?.error;e.children.push({nesting:t.data.nesting+1,type:a?.failureType||a?.code,message:a?.message||"",stack:a}),Object.assign(e,{failures:1,failure:a?.message||""})}}function y(t,e,s){if(typeof t=="string"){e.com(t.trim());return}if(t.isSuite){let a=t.file.replace(process.cwd(),"").replace(/"/g,"""),i=s.get(a)||h(a,t,e,s);t.children.forEach(r=>p(r,i))}}function o(t,e){let s={name:t.data.name,nesting:t.data.nesting,parent:e.currentSuite,children:[],file:t.data.file,timestamp:new Date().toISOString()};e.currentSuite?.children.push(s),e.currentSuite=s,s.parent||e.roots.push(s)}function T(t,e,s){t.att("tests",e.totalTests),t.att("failures",e.totalFailures),t.att("errors",e.totalErrors),t.att("skipped",e.totalSkipped),t.att("time",e.totalTime.toFixed(6)),e.roots.forEach(a=>{typeof a!="string"&&y(a,t,s)})}function E(t,e){e.currentSuite||o({data:{name:"root",nesting:0}},e),(e.currentSuite.name!==t.data.name||e.currentSuite.nesting!==t.data.nesting)&&o(t,e);let s=e.currentSuite;s.nesting===t.data.nesting&&(e.currentSuite=e.currentSuite.parent),s.time=(t.data.details.duration_ms/1e3).toFixed(6),e.totalTime+=Number(s.time);let a=n=>l(n,"failure"),i=n=>l(n,"skipped"),r=s.children.filter(n=>typeof n!="string");r.length?Object.assign(s,{isSuite:!0,errors:0,tests:r.length,failures:s.children.filter(a).length,skipped:s.children.filter(i).length,hostname:d}):k(t,s,e)}async function*u(t){let e={currentSuite:null,totalFailures:0,totalSkipped:0,totalTests:0,totalTime:0,totalErrors:0,roots:[]},s=new Map,a=m({version:"1.0",encoding:"UTF-8"}).ele("testsuites",{name:"NodeJS"});for await(let i of t)switch(i.type){case"test:start":o(i,e);break;case"test:pass":case"test:fail":E(i,e,s);break;case"test:diagnostic":e.roots.push(`# ${i.data.message}`);break;default:break}T(a,e,s),yield a.end({prettyPrint:!0})}var $=u;export{$ as default};
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@currents/node-test-reporter",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Currents Software Inc",
|
|
7
|
+
"email": "hello@currents.dev"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"description": "Currents reporter for Node Test",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "vitest",
|
|
14
|
+
"types": "echo \"No types to generate\"",
|
|
15
|
+
"rm": "rimraf dist",
|
|
16
|
+
"build": "tsup-node --minify --dts",
|
|
17
|
+
"dev": "tsup-node --watch --dts",
|
|
18
|
+
"release": "release-it",
|
|
19
|
+
"publish:npm": "npm run rm && npm run build && ./publish.js"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.20.4"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"!**/*.map",
|
|
27
|
+
"!**/*/*.map"
|
|
28
|
+
],
|
|
29
|
+
"keywords": [
|
|
30
|
+
"node",
|
|
31
|
+
"node test",
|
|
32
|
+
"node dashboard",
|
|
33
|
+
"cloud dashboard",
|
|
34
|
+
"reporter",
|
|
35
|
+
"currents",
|
|
36
|
+
"tests reporter",
|
|
37
|
+
"ci"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"xmlbuilder2": "^3.1.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@release-it/conventional-changelog": "^10.0.0",
|
|
44
|
+
"commander": "^12.1.0",
|
|
45
|
+
"rimraf": "^6.0.1",
|
|
46
|
+
"tsup": "^8.4.0",
|
|
47
|
+
"vitest": "^3.0.8"
|
|
48
|
+
},
|
|
49
|
+
"release-it": {
|
|
50
|
+
"npm": {
|
|
51
|
+
"publish": false
|
|
52
|
+
},
|
|
53
|
+
"github": {
|
|
54
|
+
"release": true,
|
|
55
|
+
"releaseName": "${npm.name} v${version}"
|
|
56
|
+
},
|
|
57
|
+
"plugins": {
|
|
58
|
+
"@release-it/conventional-changelog": {
|
|
59
|
+
"preset": "angular",
|
|
60
|
+
"header": "# Changelog",
|
|
61
|
+
"ignoreVersion": true,
|
|
62
|
+
"infile": "./CHANGELOG.md"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"git": {
|
|
66
|
+
"commitMessage": "chore: release ${npm.name} v${version}",
|
|
67
|
+
"tagName": "${npm.name}-v${version}"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|