@speqkit/plugin-junit 0.1.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 +21 -0
- package/README.md +71 -0
- package/dist/build.d.ts +46 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +163 -0
- package/dist/build.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
- package/src/build.ts +213 -0
- package/src/index.ts +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 speqkit contributors
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @speqkit/plugin-junit
|
|
2
|
+
|
|
3
|
+
JUnit XML, folded out of the run's event stream.
|
|
4
|
+
|
|
5
|
+
```yaml
|
|
6
|
+
# speq.yaml
|
|
7
|
+
plugins:
|
|
8
|
+
- junit
|
|
9
|
+
|
|
10
|
+
junit:
|
|
11
|
+
output: junit.xml # relative to reports/, or an absolute path
|
|
12
|
+
suiteName: payments # the name attribute on <testsuites>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
speq run --reporter console,junit
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Reporters are opt-in per run. `--reporter` names them; the default is
|
|
20
|
+
`console` alone, so adding this plugin costs a registration and nothing else
|
|
21
|
+
until a run asks for it.
|
|
22
|
+
|
|
23
|
+
## Where it writes, and why there
|
|
24
|
+
|
|
25
|
+
`reports/junit.xml` — the stable directory, not `reports/<runId>/`.
|
|
26
|
+
|
|
27
|
+
A CI workflow names one fixed path and cannot interpolate a run id it will not
|
|
28
|
+
learn until the step has already finished:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
- run: speq install --frozen
|
|
32
|
+
- run: speq run --env ci --reporter console,junit
|
|
33
|
+
- uses: actions/upload-artifact@v4
|
|
34
|
+
if: always()
|
|
35
|
+
with:
|
|
36
|
+
name: speq-report
|
|
37
|
+
path: .speq/reports/
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The per-run directory is right for artifacts, which are addressed from inside
|
|
41
|
+
the report. It is wrong for the report itself.
|
|
42
|
+
|
|
43
|
+
## `failure` and `error` are not the same thing
|
|
44
|
+
|
|
45
|
+
JUnit distinguishes them and so does the spine. `failed` is the system under
|
|
46
|
+
test saying no; `error` is the test never getting an answer at all. Collapsing
|
|
47
|
+
both into failures is what makes a flaky environment look like a broken build,
|
|
48
|
+
so a step that threw lands in `<error>` and an assertion that returned false
|
|
49
|
+
lands in `<failure>`.
|
|
50
|
+
|
|
51
|
+
Attachments are listed as `[[ATTACHMENT|path]]` in `<system-out>`, the
|
|
52
|
+
convention several CI viewers already understand.
|
|
53
|
+
|
|
54
|
+
## Nothing here reads the runner
|
|
55
|
+
|
|
56
|
+
Every number in the file is built from `RunEvent`s alone — no access to the
|
|
57
|
+
runner's result object, no hooks, no privileged channel. That is deliberate,
|
|
58
|
+
and `packages/core/test/reporting.test.ts` pins it: replaying a recorded run
|
|
59
|
+
through `speq report` has to produce a byte-identical file to the live run.
|
|
60
|
+
|
|
61
|
+
If that test ever fails, the event stream has stopped being sufficient to
|
|
62
|
+
describe a run, and every consumer resting on it — this plugin, a TUI, the VS
|
|
63
|
+
Code panel — inherits the gap. The fix would belong in the event contract, not
|
|
64
|
+
here.
|
|
65
|
+
|
|
66
|
+
## Control characters
|
|
67
|
+
|
|
68
|
+
Assertion messages routinely carry terminal colour codes; the console reporter
|
|
69
|
+
emits them by design. XML 1.0 cannot represent them at all, and one is enough
|
|
70
|
+
to make the file unparseable by the CI that has to read it — which shows up as
|
|
71
|
+
a broken build rather than a broken report. They are stripped on the way out.
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { RunEvent, StepStatus } from '@speqkit/plugin-api';
|
|
2
|
+
export interface JUnitCase {
|
|
3
|
+
name: string;
|
|
4
|
+
suite: string;
|
|
5
|
+
file?: string;
|
|
6
|
+
status: StepStatus;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
/** Why it failed, in the order the run found out. */
|
|
9
|
+
failures: string[];
|
|
10
|
+
/** Free-form lines CI viewers show under the case. */
|
|
11
|
+
output: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface JUnitSuite {
|
|
14
|
+
name: string;
|
|
15
|
+
cases: JUnitCase[];
|
|
16
|
+
}
|
|
17
|
+
export interface JUnitRun {
|
|
18
|
+
runId?: string;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
suites: JUnitSuite[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Folds the event stream into the shape JUnit wants.
|
|
24
|
+
*
|
|
25
|
+
* Kept apart from the file writing so it can be tested on a plain list of
|
|
26
|
+
* events, and so `speq report` and a live run go through exactly the same
|
|
27
|
+
* code — the event stream being sufficient on its own is the property worth
|
|
28
|
+
* protecting.
|
|
29
|
+
*/
|
|
30
|
+
export declare class RunBuilder {
|
|
31
|
+
#private;
|
|
32
|
+
on(event: RunEvent): void;
|
|
33
|
+
/**
|
|
34
|
+
* A reporter is registered once and may see several runs in one process —
|
|
35
|
+
* `speq report` replaying after a run, or a long-lived editor session. Two
|
|
36
|
+
* runs' worth of cases in one file would be nobody's intent.
|
|
37
|
+
*/
|
|
38
|
+
reset(): void;
|
|
39
|
+
result(): JUnitRun;
|
|
40
|
+
private suiteFor;
|
|
41
|
+
}
|
|
42
|
+
export interface RenderOptions {
|
|
43
|
+
name: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function renderJUnit(run: JUnitRun, options: RenderOptions): string;
|
|
46
|
+
//# sourceMappingURL=build.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAE/D,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,UAAU,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,sDAAsD;IACtD,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,SAAS,EAAE,CAAA;CACnB;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,UAAU,EAAE,CAAA;CACrB;AAED;;;;;;;GAOG;AACH,qBAAa,UAAU;;IAQrB,EAAE,CAAC,KAAK,EAAE,QAAQ,GAAG,IAAI;IA0DzB;;;;OAIG;IACH,KAAK,IAAI,IAAI;IASb,MAAM,IAAI,QAAQ;IAIlB,OAAO,CAAC,QAAQ;CASjB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;CACb;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,EAAE,OAAO,EAAE,aAAa,GAAG,MAAM,CAsBzE"}
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folds the event stream into the shape JUnit wants.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from the file writing so it can be tested on a plain list of
|
|
5
|
+
* events, and so `speq report` and a live run go through exactly the same
|
|
6
|
+
* code — the event stream being sufficient on its own is the property worth
|
|
7
|
+
* protecting.
|
|
8
|
+
*/
|
|
9
|
+
export class RunBuilder {
|
|
10
|
+
#suites = [];
|
|
11
|
+
#byName = new Map();
|
|
12
|
+
#suite = '(inline)';
|
|
13
|
+
#case;
|
|
14
|
+
#runId;
|
|
15
|
+
#durationMs = 0;
|
|
16
|
+
on(event) {
|
|
17
|
+
switch (event.type) {
|
|
18
|
+
case 'run.started':
|
|
19
|
+
this.reset();
|
|
20
|
+
this.#runId = event.runId;
|
|
21
|
+
break;
|
|
22
|
+
// Tests do not carry their suite on the event; the bracketing does. That
|
|
23
|
+
// holds on replay too, because the log preserves the order.
|
|
24
|
+
case 'suite.started':
|
|
25
|
+
this.#suite = event.suite;
|
|
26
|
+
break;
|
|
27
|
+
case 'test.started':
|
|
28
|
+
this.#case = {
|
|
29
|
+
name: event.test,
|
|
30
|
+
suite: this.#suite,
|
|
31
|
+
file: event.source,
|
|
32
|
+
status: 'passed',
|
|
33
|
+
durationMs: 0,
|
|
34
|
+
failures: [],
|
|
35
|
+
output: []
|
|
36
|
+
};
|
|
37
|
+
break;
|
|
38
|
+
case 'step.finished':
|
|
39
|
+
if (this.#case && event.status !== 'passed' && event.status !== 'skipped') {
|
|
40
|
+
const label = event.stepId ? `${event.stepId} (${event.stepType})` : event.stepType;
|
|
41
|
+
this.#case.failures.push(`step ${label}: ${event.message ?? event.status}`);
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
case 'assertion.evaluated':
|
|
45
|
+
if (this.#case && !event.passed) {
|
|
46
|
+
this.#case.failures.push(`assertion ${event.assertionType}: ${event.message}`);
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
case 'artifact.attached':
|
|
50
|
+
this.#case?.output.push(`[[ATTACHMENT|${event.path ?? event.name}]]`);
|
|
51
|
+
break;
|
|
52
|
+
case 'test.finished': {
|
|
53
|
+
const entry = this.#case;
|
|
54
|
+
this.#case = undefined;
|
|
55
|
+
if (!entry)
|
|
56
|
+
break;
|
|
57
|
+
entry.status = event.status;
|
|
58
|
+
entry.durationMs = event.durationMs;
|
|
59
|
+
this.suiteFor(entry.suite).cases.push(entry);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case 'run.finished':
|
|
63
|
+
this.#durationMs = event.durationMs;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A reporter is registered once and may see several runs in one process —
|
|
69
|
+
* `speq report` replaying after a run, or a long-lived editor session. Two
|
|
70
|
+
* runs' worth of cases in one file would be nobody's intent.
|
|
71
|
+
*/
|
|
72
|
+
reset() {
|
|
73
|
+
this.#suites = [];
|
|
74
|
+
this.#byName = new Map();
|
|
75
|
+
this.#suite = '(inline)';
|
|
76
|
+
this.#case = undefined;
|
|
77
|
+
this.#runId = undefined;
|
|
78
|
+
this.#durationMs = 0;
|
|
79
|
+
}
|
|
80
|
+
result() {
|
|
81
|
+
return { runId: this.#runId, durationMs: this.#durationMs, suites: this.#suites };
|
|
82
|
+
}
|
|
83
|
+
suiteFor(name) {
|
|
84
|
+
let suite = this.#byName.get(name);
|
|
85
|
+
if (!suite) {
|
|
86
|
+
suite = { name, cases: [] };
|
|
87
|
+
this.#byName.set(name, suite);
|
|
88
|
+
this.#suites.push(suite);
|
|
89
|
+
}
|
|
90
|
+
return suite;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function renderJUnit(run, options) {
|
|
94
|
+
const all = run.suites.flatMap((s) => s.cases);
|
|
95
|
+
const lines = [
|
|
96
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
97
|
+
`<testsuites name="${esc(options.name)}" tests="${all.length}" ` +
|
|
98
|
+
`failures="${count(all, 'failed')}" errors="${count(all, 'error')}" ` +
|
|
99
|
+
`skipped="${count(all, 'skipped')}" time="${seconds(run.durationMs)}">`
|
|
100
|
+
];
|
|
101
|
+
for (const suite of run.suites) {
|
|
102
|
+
lines.push(` <testsuite name="${esc(suite.name)}" tests="${suite.cases.length}" ` +
|
|
103
|
+
`failures="${count(suite.cases, 'failed')}" errors="${count(suite.cases, 'error')}" ` +
|
|
104
|
+
`skipped="${count(suite.cases, 'skipped')}" ` +
|
|
105
|
+
`time="${seconds(suite.cases.reduce((sum, c) => sum + c.durationMs, 0))}">`);
|
|
106
|
+
for (const entry of suite.cases)
|
|
107
|
+
lines.push(...renderCase(entry));
|
|
108
|
+
lines.push(` </testsuite>`);
|
|
109
|
+
}
|
|
110
|
+
lines.push(`</testsuites>`);
|
|
111
|
+
return `${lines.join('\n')}\n`;
|
|
112
|
+
}
|
|
113
|
+
function renderCase(entry) {
|
|
114
|
+
const attrs = `name="${esc(entry.name)}" classname="${esc(entry.suite)}" ` +
|
|
115
|
+
`time="${seconds(entry.durationMs)}"` +
|
|
116
|
+
(entry.file ? ` file="${esc(entry.file)}"` : '');
|
|
117
|
+
const body = [];
|
|
118
|
+
if (entry.status === 'skipped') {
|
|
119
|
+
body.push(` <skipped/>`);
|
|
120
|
+
}
|
|
121
|
+
else if (entry.status === 'failed' || entry.status === 'error') {
|
|
122
|
+
// JUnit distinguishes the two, and so does the spine: `failed` is the
|
|
123
|
+
// system under test saying no, `error` is the test never getting an answer
|
|
124
|
+
// at all. Reporting both as failures is what makes a flaky environment
|
|
125
|
+
// look like a broken build.
|
|
126
|
+
const tag = entry.status === 'error' ? 'error' : 'failure';
|
|
127
|
+
const message = entry.failures[0] ?? entry.status;
|
|
128
|
+
body.push(` <${tag} message="${esc(message)}" type="${entry.status}">` +
|
|
129
|
+
`${esc(entry.failures.join('\n'))}</${tag}>`);
|
|
130
|
+
}
|
|
131
|
+
if (entry.output.length > 0) {
|
|
132
|
+
body.push(` <system-out>${esc(entry.output.join('\n'))}</system-out>`);
|
|
133
|
+
}
|
|
134
|
+
return body.length === 0
|
|
135
|
+
? [` <testcase ${attrs}/>`]
|
|
136
|
+
: [` <testcase ${attrs}>`, ...body, ` </testcase>`];
|
|
137
|
+
}
|
|
138
|
+
function count(cases, status) {
|
|
139
|
+
return cases.filter((c) => c.status === status).length;
|
|
140
|
+
}
|
|
141
|
+
function seconds(ms) {
|
|
142
|
+
return (ms / 1000).toFixed(3);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Escapes for both attribute and text position, so one function covers every
|
|
146
|
+
* place a value can land.
|
|
147
|
+
*
|
|
148
|
+
* The control-character strip is not pedantry. XML 1.0 cannot represent them at
|
|
149
|
+
* all, and assertion messages routinely carry terminal colour codes — the
|
|
150
|
+
* console reporter's own output is full of them. One escape sequence is enough
|
|
151
|
+
* to make the file unparseable by the CI that has to read it, and a CI that
|
|
152
|
+
* cannot parse the report shows the build as broken rather than the report.
|
|
153
|
+
*/
|
|
154
|
+
function esc(value) {
|
|
155
|
+
return value
|
|
156
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
157
|
+
.replace(/&/g, '&')
|
|
158
|
+
.replace(/</g, '<')
|
|
159
|
+
.replace(/>/g, '>')
|
|
160
|
+
.replace(/"/g, '"')
|
|
161
|
+
.replace(/\r?\n/g, ' ');
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=build.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build.js","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The format CI already knows how to read.
|
|
3
|
+
*
|
|
4
|
+
* It is a plugin, and that is the whole argument: JUnit is one of a dozen
|
|
5
|
+
* report formats a team might need, and none of them belong in the kernel.
|
|
6
|
+
* What the kernel owes a reporter is the event stream, and this plugin is the
|
|
7
|
+
* proof that the stream carries enough — every number in the file below is
|
|
8
|
+
* folded out of events, with no access to the runner's own result object.
|
|
9
|
+
*/
|
|
10
|
+
declare const _default: import("@speqkit/plugin-api").PluginSpec;
|
|
11
|
+
export default _default;
|
|
12
|
+
export { RunBuilder, renderJUnit } from './build.js';
|
|
13
|
+
export type { JUnitRun, JUnitSuite, JUnitCase } from './build.js';
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA;;;;;;;;GAQG;;AACH,wBAqCE;AAgBF,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AACpD,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { definePlugin } from '@speqkit/plugin-api';
|
|
4
|
+
import { RunBuilder, renderJUnit } from './build.js';
|
|
5
|
+
/**
|
|
6
|
+
* The format CI already knows how to read.
|
|
7
|
+
*
|
|
8
|
+
* It is a plugin, and that is the whole argument: JUnit is one of a dozen
|
|
9
|
+
* report formats a team might need, and none of them belong in the kernel.
|
|
10
|
+
* What the kernel owes a reporter is the event stream, and this plugin is the
|
|
11
|
+
* proof that the stream carries enough — every number in the file below is
|
|
12
|
+
* folded out of events, with no access to the runner's own result object.
|
|
13
|
+
*/
|
|
14
|
+
export default definePlugin({
|
|
15
|
+
name: '@speqkit/plugin-junit',
|
|
16
|
+
configSchema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
output: { type: 'string' },
|
|
20
|
+
suiteName: { type: 'string' }
|
|
21
|
+
},
|
|
22
|
+
additionalProperties: false
|
|
23
|
+
},
|
|
24
|
+
setup(ctx) {
|
|
25
|
+
const builder = new RunBuilder();
|
|
26
|
+
let target;
|
|
27
|
+
ctx.defineReporter('junit', {
|
|
28
|
+
init(run) {
|
|
29
|
+
builder.reset();
|
|
30
|
+
target = targetFile(ctx.config(), run);
|
|
31
|
+
},
|
|
32
|
+
on(event) {
|
|
33
|
+
builder.on(event);
|
|
34
|
+
},
|
|
35
|
+
finalize() {
|
|
36
|
+
if (!target)
|
|
37
|
+
return;
|
|
38
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
39
|
+
const xml = renderJUnit(builder.result(), {
|
|
40
|
+
name: ctx.config().suiteName ?? 'speq'
|
|
41
|
+
});
|
|
42
|
+
writeFileSync(target, xml);
|
|
43
|
+
process.stdout.write(`junit: ${target}\n`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Defaults to `reports/junit.xml` — the stable directory, not `reports/<runId>/`.
|
|
50
|
+
*
|
|
51
|
+
* A workflow names one fixed path in `upload-artifact` and cannot interpolate a
|
|
52
|
+
* run id it will not learn until the step has already finished. The per-run
|
|
53
|
+
* directory is right for artifacts, which are addressed from inside the report;
|
|
54
|
+
* it is wrong for the report itself.
|
|
55
|
+
*/
|
|
56
|
+
function targetFile(config, run) {
|
|
57
|
+
const output = config.output ?? 'junit.xml';
|
|
58
|
+
if (isAbsolute(output))
|
|
59
|
+
return output;
|
|
60
|
+
return run.outputDir ? join(run.outputDir, output) : resolve(process.cwd(), output);
|
|
61
|
+
}
|
|
62
|
+
export { RunBuilder, renderJUnit } from './build.js';
|
|
63
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAClD,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAwB,MAAM,qBAAqB,CAAA;AACxE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AASpD;;;;;;;;GAQG;AACH,eAAe,YAAY,CAAC;IAC1B,IAAI,EAAE,uBAAuB;IAE7B,YAAY,EAAE;QACZ,IAAI,EAAE,QAAQ;QACd,UAAU,EAAE;YACV,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;YAC1B,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;SAC9B;QACD,oBAAoB,EAAE,KAAK;KAC5B;IAED,KAAK,CAAC,GAAG;QACP,MAAM,OAAO,GAAG,IAAI,UAAU,EAAE,CAAA;QAChC,IAAI,MAA0B,CAAA;QAE9B,GAAG,CAAC,cAAc,CAAC,OAAO,EAAE;YAC1B,IAAI,CAAC,GAAoB;gBACvB,OAAO,CAAC,KAAK,EAAE,CAAA;gBACf,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,MAAM,EAAe,EAAE,GAAG,CAAC,CAAA;YACrD,CAAC;YAED,EAAE,CAAC,KAAK;gBACN,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,CAAA;YACnB,CAAC;YAED,QAAQ;gBACN,IAAI,CAAC,MAAM;oBAAE,OAAM;gBACnB,SAAS,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;gBAC/C,MAAM,GAAG,GAAG,WAAW,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE;oBACxC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAe,CAAC,SAAS,IAAI,MAAM;iBACpD,CAAC,CAAA;gBACF,aAAa,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;gBAC1B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,MAAM,IAAI,CAAC,CAAA;YAC5C,CAAC;SACF,CAAC,CAAA;IACJ,CAAC;CACF,CAAC,CAAA;AAEF;;;;;;;GAOG;AACH,SAAS,UAAU,CAAC,MAAmB,EAAE,GAAoB;IAC3D,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,WAAW,CAAA;IAC3C,IAAI,UAAU,CAAC,MAAM,CAAC;QAAE,OAAO,MAAM,CAAA;IACrC,OAAO,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,MAAM,CAAC,CAAA;AACrF,CAAC;AAED,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@speqkit/plugin-junit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JUnit XML from the run's event stream. What CI reads.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Stepan Kaziatko",
|
|
7
|
+
"homepage": "https://github.com/speqkit/speqkit#readme",
|
|
8
|
+
"bugs": "https://github.com/speqkit/speqkit/issues",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/speqkit/speqkit.git",
|
|
12
|
+
"directory": "packages/plugin-junit"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"speqkit-plugin"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@speqkit/plugin-api": "^0.4.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@speqkit/plugin-api": "0.4.0"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20.0.0"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { RunEvent, StepStatus } from '@speqkit/plugin-api'
|
|
2
|
+
|
|
3
|
+
export interface JUnitCase {
|
|
4
|
+
name: string
|
|
5
|
+
suite: string
|
|
6
|
+
file?: string
|
|
7
|
+
status: StepStatus
|
|
8
|
+
durationMs: number
|
|
9
|
+
/** Why it failed, in the order the run found out. */
|
|
10
|
+
failures: string[]
|
|
11
|
+
/** Free-form lines CI viewers show under the case. */
|
|
12
|
+
output: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface JUnitSuite {
|
|
16
|
+
name: string
|
|
17
|
+
cases: JUnitCase[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface JUnitRun {
|
|
21
|
+
runId?: string
|
|
22
|
+
durationMs: number
|
|
23
|
+
suites: JUnitSuite[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Folds the event stream into the shape JUnit wants.
|
|
28
|
+
*
|
|
29
|
+
* Kept apart from the file writing so it can be tested on a plain list of
|
|
30
|
+
* events, and so `speq report` and a live run go through exactly the same
|
|
31
|
+
* code — the event stream being sufficient on its own is the property worth
|
|
32
|
+
* protecting.
|
|
33
|
+
*/
|
|
34
|
+
export class RunBuilder {
|
|
35
|
+
#suites: JUnitSuite[] = []
|
|
36
|
+
#byName = new Map<string, JUnitSuite>()
|
|
37
|
+
#suite = '(inline)'
|
|
38
|
+
#case: JUnitCase | undefined
|
|
39
|
+
#runId: string | undefined
|
|
40
|
+
#durationMs = 0
|
|
41
|
+
|
|
42
|
+
on(event: RunEvent): void {
|
|
43
|
+
switch (event.type) {
|
|
44
|
+
case 'run.started':
|
|
45
|
+
this.reset()
|
|
46
|
+
this.#runId = event.runId
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
// Tests do not carry their suite on the event; the bracketing does. That
|
|
50
|
+
// holds on replay too, because the log preserves the order.
|
|
51
|
+
case 'suite.started':
|
|
52
|
+
this.#suite = event.suite
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
case 'test.started':
|
|
56
|
+
this.#case = {
|
|
57
|
+
name: event.test,
|
|
58
|
+
suite: this.#suite,
|
|
59
|
+
file: event.source,
|
|
60
|
+
status: 'passed',
|
|
61
|
+
durationMs: 0,
|
|
62
|
+
failures: [],
|
|
63
|
+
output: []
|
|
64
|
+
}
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
case 'step.finished':
|
|
68
|
+
if (this.#case && event.status !== 'passed' && event.status !== 'skipped') {
|
|
69
|
+
const label = event.stepId ? `${event.stepId} (${event.stepType})` : event.stepType
|
|
70
|
+
this.#case.failures.push(`step ${label}: ${event.message ?? event.status}`)
|
|
71
|
+
}
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
case 'assertion.evaluated':
|
|
75
|
+
if (this.#case && !event.passed) {
|
|
76
|
+
this.#case.failures.push(`assertion ${event.assertionType}: ${event.message}`)
|
|
77
|
+
}
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
case 'artifact.attached':
|
|
81
|
+
this.#case?.output.push(`[[ATTACHMENT|${event.path ?? event.name}]]`)
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
case 'test.finished': {
|
|
85
|
+
const entry = this.#case
|
|
86
|
+
this.#case = undefined
|
|
87
|
+
if (!entry) break
|
|
88
|
+
entry.status = event.status
|
|
89
|
+
entry.durationMs = event.durationMs
|
|
90
|
+
this.suiteFor(entry.suite).cases.push(entry)
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case 'run.finished':
|
|
95
|
+
this.#durationMs = event.durationMs
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A reporter is registered once and may see several runs in one process —
|
|
102
|
+
* `speq report` replaying after a run, or a long-lived editor session. Two
|
|
103
|
+
* runs' worth of cases in one file would be nobody's intent.
|
|
104
|
+
*/
|
|
105
|
+
reset(): void {
|
|
106
|
+
this.#suites = []
|
|
107
|
+
this.#byName = new Map()
|
|
108
|
+
this.#suite = '(inline)'
|
|
109
|
+
this.#case = undefined
|
|
110
|
+
this.#runId = undefined
|
|
111
|
+
this.#durationMs = 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
result(): JUnitRun {
|
|
115
|
+
return { runId: this.#runId, durationMs: this.#durationMs, suites: this.#suites }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private suiteFor(name: string): JUnitSuite {
|
|
119
|
+
let suite = this.#byName.get(name)
|
|
120
|
+
if (!suite) {
|
|
121
|
+
suite = { name, cases: [] }
|
|
122
|
+
this.#byName.set(name, suite)
|
|
123
|
+
this.#suites.push(suite)
|
|
124
|
+
}
|
|
125
|
+
return suite
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface RenderOptions {
|
|
130
|
+
name: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function renderJUnit(run: JUnitRun, options: RenderOptions): string {
|
|
134
|
+
const all = run.suites.flatMap((s) => s.cases)
|
|
135
|
+
const lines: string[] = [
|
|
136
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
137
|
+
`<testsuites name="${esc(options.name)}" tests="${all.length}" ` +
|
|
138
|
+
`failures="${count(all, 'failed')}" errors="${count(all, 'error')}" ` +
|
|
139
|
+
`skipped="${count(all, 'skipped')}" time="${seconds(run.durationMs)}">`
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
for (const suite of run.suites) {
|
|
143
|
+
lines.push(
|
|
144
|
+
` <testsuite name="${esc(suite.name)}" tests="${suite.cases.length}" ` +
|
|
145
|
+
`failures="${count(suite.cases, 'failed')}" errors="${count(suite.cases, 'error')}" ` +
|
|
146
|
+
`skipped="${count(suite.cases, 'skipped')}" ` +
|
|
147
|
+
`time="${seconds(suite.cases.reduce((sum, c) => sum + c.durationMs, 0))}">`
|
|
148
|
+
)
|
|
149
|
+
for (const entry of suite.cases) lines.push(...renderCase(entry))
|
|
150
|
+
lines.push(` </testsuite>`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
lines.push(`</testsuites>`)
|
|
154
|
+
return `${lines.join('\n')}\n`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderCase(entry: JUnitCase): string[] {
|
|
158
|
+
const attrs =
|
|
159
|
+
`name="${esc(entry.name)}" classname="${esc(entry.suite)}" ` +
|
|
160
|
+
`time="${seconds(entry.durationMs)}"` +
|
|
161
|
+
(entry.file ? ` file="${esc(entry.file)}"` : '')
|
|
162
|
+
|
|
163
|
+
const body: string[] = []
|
|
164
|
+
if (entry.status === 'skipped') {
|
|
165
|
+
body.push(` <skipped/>`)
|
|
166
|
+
} else if (entry.status === 'failed' || entry.status === 'error') {
|
|
167
|
+
// JUnit distinguishes the two, and so does the spine: `failed` is the
|
|
168
|
+
// system under test saying no, `error` is the test never getting an answer
|
|
169
|
+
// at all. Reporting both as failures is what makes a flaky environment
|
|
170
|
+
// look like a broken build.
|
|
171
|
+
const tag = entry.status === 'error' ? 'error' : 'failure'
|
|
172
|
+
const message = entry.failures[0] ?? entry.status
|
|
173
|
+
body.push(
|
|
174
|
+
` <${tag} message="${esc(message)}" type="${entry.status}">` +
|
|
175
|
+
`${esc(entry.failures.join('\n'))}</${tag}>`
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
if (entry.output.length > 0) {
|
|
179
|
+
body.push(` <system-out>${esc(entry.output.join('\n'))}</system-out>`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return body.length === 0
|
|
183
|
+
? [` <testcase ${attrs}/>`]
|
|
184
|
+
: [` <testcase ${attrs}>`, ...body, ` </testcase>`]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function count(cases: JUnitCase[], status: StepStatus): number {
|
|
188
|
+
return cases.filter((c) => c.status === status).length
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function seconds(ms: number): string {
|
|
192
|
+
return (ms / 1000).toFixed(3)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Escapes for both attribute and text position, so one function covers every
|
|
197
|
+
* place a value can land.
|
|
198
|
+
*
|
|
199
|
+
* The control-character strip is not pedantry. XML 1.0 cannot represent them at
|
|
200
|
+
* all, and assertion messages routinely carry terminal colour codes — the
|
|
201
|
+
* console reporter's own output is full of them. One escape sequence is enough
|
|
202
|
+
* to make the file unparseable by the CI that has to read it, and a CI that
|
|
203
|
+
* cannot parse the report shows the build as broken rather than the report.
|
|
204
|
+
*/
|
|
205
|
+
function esc(value: string): string {
|
|
206
|
+
return value
|
|
207
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
208
|
+
.replace(/&/g, '&')
|
|
209
|
+
.replace(/</g, '<')
|
|
210
|
+
.replace(/>/g, '>')
|
|
211
|
+
.replace(/"/g, '"')
|
|
212
|
+
.replace(/\r?\n/g, ' ')
|
|
213
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
3
|
+
import { definePlugin, type ReporterContext } from '@speqkit/plugin-api'
|
|
4
|
+
import { RunBuilder, renderJUnit } from './build.js'
|
|
5
|
+
|
|
6
|
+
interface JUnitConfig {
|
|
7
|
+
/** Where to write, relative to `reports/` unless absolute. */
|
|
8
|
+
output?: string
|
|
9
|
+
/** The `name` attribute on `<testsuites>`. */
|
|
10
|
+
suiteName?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The format CI already knows how to read.
|
|
15
|
+
*
|
|
16
|
+
* It is a plugin, and that is the whole argument: JUnit is one of a dozen
|
|
17
|
+
* report formats a team might need, and none of them belong in the kernel.
|
|
18
|
+
* What the kernel owes a reporter is the event stream, and this plugin is the
|
|
19
|
+
* proof that the stream carries enough — every number in the file below is
|
|
20
|
+
* folded out of events, with no access to the runner's own result object.
|
|
21
|
+
*/
|
|
22
|
+
export default definePlugin({
|
|
23
|
+
name: '@speqkit/plugin-junit',
|
|
24
|
+
|
|
25
|
+
configSchema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
output: { type: 'string' },
|
|
29
|
+
suiteName: { type: 'string' }
|
|
30
|
+
},
|
|
31
|
+
additionalProperties: false
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
setup(ctx) {
|
|
35
|
+
const builder = new RunBuilder()
|
|
36
|
+
let target: string | undefined
|
|
37
|
+
|
|
38
|
+
ctx.defineReporter('junit', {
|
|
39
|
+
init(run: ReporterContext) {
|
|
40
|
+
builder.reset()
|
|
41
|
+
target = targetFile(ctx.config<JUnitConfig>(), run)
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
on(event) {
|
|
45
|
+
builder.on(event)
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
finalize() {
|
|
49
|
+
if (!target) return
|
|
50
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
51
|
+
const xml = renderJUnit(builder.result(), {
|
|
52
|
+
name: ctx.config<JUnitConfig>().suiteName ?? 'speq'
|
|
53
|
+
})
|
|
54
|
+
writeFileSync(target, xml)
|
|
55
|
+
process.stdout.write(`junit: ${target}\n`)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Defaults to `reports/junit.xml` — the stable directory, not `reports/<runId>/`.
|
|
63
|
+
*
|
|
64
|
+
* A workflow names one fixed path in `upload-artifact` and cannot interpolate a
|
|
65
|
+
* run id it will not learn until the step has already finished. The per-run
|
|
66
|
+
* directory is right for artifacts, which are addressed from inside the report;
|
|
67
|
+
* it is wrong for the report itself.
|
|
68
|
+
*/
|
|
69
|
+
function targetFile(config: JUnitConfig, run: ReporterContext): string {
|
|
70
|
+
const output = config.output ?? 'junit.xml'
|
|
71
|
+
if (isAbsolute(output)) return output
|
|
72
|
+
return run.outputDir ? join(run.outputDir, output) : resolve(process.cwd(), output)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { RunBuilder, renderJUnit } from './build.js'
|
|
76
|
+
export type { JUnitRun, JUnitSuite, JUnitCase } from './build.js'
|