@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 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.
@@ -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, '&amp;')
158
+ .replace(/</g, '&lt;')
159
+ .replace(/>/g, '&gt;')
160
+ .replace(/"/g, '&quot;')
161
+ .replace(/\r?\n/g, '&#10;');
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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"}
@@ -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, '&amp;')
209
+ .replace(/</g, '&lt;')
210
+ .replace(/>/g, '&gt;')
211
+ .replace(/"/g, '&quot;')
212
+ .replace(/\r?\n/g, '&#10;')
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'