faf-taf-git 2.3.1 → 2.4.1

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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * JUnit XML parser
3
+ *
4
+ * Extracts test counts from a JUnit XML report — the common format nearly every
5
+ * test runner can write: pytest (--junitxml), cargo (nextest, cargo2junit), Go
6
+ * (go-junit-report, gotestsum), Maven/Gradle, .NET, PHPUnit, Jest (jest-junit).
7
+ */
8
+
9
+ import { TestResults } from './jest';
10
+
11
+ const SUITE_TAG = /<(\/?)testsuite\b([^>]*?)(\/?)>/g;
12
+
13
+ /** Read one numeric attribute; undefined when the attribute is absent. */
14
+ function attr(attrs: string, name: string): number | undefined {
15
+ const match = attrs.match(new RegExp(`\\b${name}\\s*=\\s*["'](\\d+)["']`));
16
+ return match ? parseInt(match[1], 10) : undefined;
17
+ }
18
+
19
+ interface Counts {
20
+ tests: number;
21
+ failed: number;
22
+ skipped: number;
23
+ }
24
+
25
+ /** Counts from a <testsuite> or <testsuites> attribute list, or null without a `tests` count. */
26
+ function countsFromAttrs(attrs: string): Counts | null {
27
+ const tests = attr(attrs, 'tests');
28
+ if (tests === undefined) return null;
29
+ return {
30
+ tests,
31
+ failed: (attr(attrs, 'failures') ?? 0) + (attr(attrs, 'errors') ?? 0),
32
+ skipped: (attr(attrs, 'skipped') ?? 0) + (attr(attrs, 'disabled') ?? 0),
33
+ };
34
+ }
35
+
36
+ /** Fallback when suites carry no counts: walk the <testcase> elements themselves. */
37
+ function countsFromTestcases(xml: string): Counts {
38
+ const counts: Counts = { tests: 0, failed: 0, skipped: 0 };
39
+ const testcase = /<testcase\b[^>]*?(?:\/>|>([\s\S]*?)<\/testcase>)/g;
40
+ let match: RegExpExecArray | null;
41
+ while ((match = testcase.exec(xml)) !== null) {
42
+ counts.tests++;
43
+ const body = match[1] ?? '';
44
+ if (/<(failure|error)\b/.test(body)) counts.failed++;
45
+ else if (/<skipped\b/.test(body)) counts.skipped++;
46
+ }
47
+ return counts;
48
+ }
49
+
50
+ /**
51
+ * Parse a JUnit XML report to extract test results
52
+ *
53
+ * Handles:
54
+ * - a <testsuites> root that carries the totals (gotestsum, Maven)
55
+ * - a <testsuites> root without totals, around counted <testsuite>s (pytest)
56
+ * - a single <testsuite> root
57
+ * - nested <testsuite>s (only the outermost level is counted, never twice)
58
+ * - suites with no counts at all (the <testcase> elements are counted instead)
59
+ *
60
+ * failures + errors count as failed; skipped + disabled count as skipped.
61
+ */
62
+ export function parseJunitOutput(output: string): TestResults | null {
63
+ const xml = output.replace(/<!--[\s\S]*?-->/g, '');
64
+ if (!/<testsuites?\b/.test(xml)) return null;
65
+
66
+ let counts: Counts | null = null;
67
+
68
+ const root = xml.match(/<testsuites\b([^>]*)>/);
69
+ if (root) counts = countsFromAttrs(root[1]);
70
+
71
+ if (!counts) {
72
+ // Sum the outermost <testsuite>s; a suite without counts sends us to the testcases.
73
+ let depth = 0;
74
+ let summed: Counts | null = { tests: 0, failed: 0, skipped: 0 };
75
+ let seen = 0;
76
+ let match: RegExpExecArray | null;
77
+ SUITE_TAG.lastIndex = 0;
78
+ while ((match = SUITE_TAG.exec(xml)) !== null) {
79
+ const [, closing, attrs, selfClosing] = match;
80
+ if (closing) {
81
+ depth--;
82
+ continue;
83
+ }
84
+ if (depth === 0 && summed) {
85
+ seen++;
86
+ const suite = countsFromAttrs(attrs);
87
+ if (suite) {
88
+ summed.tests += suite.tests;
89
+ summed.failed += suite.failed;
90
+ summed.skipped += suite.skipped;
91
+ } else {
92
+ summed = null;
93
+ }
94
+ }
95
+ if (!selfClosing) depth++;
96
+ }
97
+ counts = seen > 0 && summed ? summed : countsFromTestcases(xml);
98
+ }
99
+
100
+ if (counts.tests === 0) return null;
101
+
102
+ const passed = Math.max(0, counts.tests - counts.failed - counts.skipped);
103
+ return {
104
+ total: counts.tests,
105
+ passed,
106
+ failed: counts.failed,
107
+ skipped: counts.skipped > 0 ? counts.skipped : undefined,
108
+ result: counts.failed > 0 ? 'FAILED' : 'PASSED',
109
+ };
110
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `faf-taf-git --version` is a stranger's first command. Before 2.4.1 it fell
3
+ * through to "No test output file specified" and exit 1 (found by /dev-notes
4
+ * on the published 2.4.0).
5
+ */
6
+ import { spawnSync } from 'child_process';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { cliVersion } from '../src/cli';
10
+
11
+ const root = path.join(__dirname, '..');
12
+ const pkgVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')).version;
13
+ const bin = path.join(root, 'dist', 'cli.js');
14
+
15
+ describe('--version', () => {
16
+ test('cliVersion() is the package.json version', () => {
17
+ expect(cliVersion()).toBe(pkgVersion);
18
+ });
19
+
20
+ test.each(['--version', '-V'])('the built bin answers %s with the version and exit 0', (flag) => {
21
+ const r = spawnSync(process.execPath, [bin, flag], { encoding: 'utf-8' });
22
+ expect(r.status).toBe(0);
23
+ expect(r.stdout.trim()).toBe(pkgVersion);
24
+ });
25
+
26
+ test('-v is still --verbose, not the version', () => {
27
+ const r = spawnSync(process.execPath, [bin, '-v'], { encoding: 'utf-8' });
28
+ expect(r.status).toBe(1);
29
+ expect(r.stderr).toContain('No test output file specified');
30
+ });
31
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * JUnit XML Parser Tests
3
+ */
4
+
5
+ import { parseJunitOutput } from '../src/parsers/junit';
6
+ import { parseTestOutput, SUPPORTED_PARSERS } from '../src/parsers';
7
+
8
+ describe('JUnit XML Parser', () => {
9
+ describe('parseJunitOutput', () => {
10
+ test('should parse a pytest report (<testsuites> root without totals)', () => {
11
+ const output = `<?xml version="1.0" encoding="utf-8"?>
12
+ <testsuites name="pytest tests"><testsuite name="pytest" errors="0" failures="1" skipped="2" tests="12" time="0.4" timestamp="2026-10-29T20:00:00" hostname="runner">
13
+ <testcase classname="tests.test_a" name="test_ok" time="0.001" />
14
+ <testcase classname="tests.test_a" name="test_bad" time="0.002"><failure message="assert 1 == 2">assert 1 == 2</failure></testcase>
15
+ </testsuite></testsuites>`;
16
+ const result = parseJunitOutput(output);
17
+ expect(result).toEqual({ total: 12, passed: 9, failed: 1, skipped: 2, result: 'FAILED' });
18
+ });
19
+
20
+ test('should use the totals on a <testsuites> root (gotestsum, Maven)', () => {
21
+ const output = `<testsuites tests="40" failures="0" errors="0" skipped="3">
22
+ <testsuite name="pkg/a" tests="25" failures="0" errors="0" skipped="1"></testsuite>
23
+ <testsuite name="pkg/b" tests="15" failures="0" errors="0" skipped="2"></testsuite>
24
+ </testsuites>`;
25
+ expect(parseJunitOutput(output)).toEqual({ total: 40, passed: 37, failed: 0, skipped: 3, result: 'PASSED' });
26
+ });
27
+
28
+ test('should sum the suites when the root has no totals (cargo2junit)', () => {
29
+ const output = `<testsuites>
30
+ <testsuite id="0" name="faf_rust_sdk" package="testsuite/faf_rust_sdk" tests="30" errors="0" failures="0" hostname="localhost" timestamp="2026-10-29T20:00:00Z" time="0">
31
+ <testcase name="parses_minimal" time="0" />
32
+ </testsuite>
33
+ <testsuite id="1" name="integration" tests="8" errors="1" failures="0" skipped="0" time="0"></testsuite>
34
+ </testsuites>`;
35
+ expect(parseJunitOutput(output)).toEqual({ total: 38, passed: 37, failed: 1, skipped: undefined, result: 'FAILED' });
36
+ });
37
+
38
+ test('should parse a single <testsuite> root', () => {
39
+ const output = `<?xml version="1.0"?>
40
+ <testsuite name="jest tests" tests="7" failures="0" errors="0" skipped="0"></testsuite>`;
41
+ expect(parseJunitOutput(output)).toEqual({ total: 7, passed: 7, failed: 0, skipped: undefined, result: 'PASSED' });
42
+ });
43
+
44
+ test('should count nested suites once (outermost level only)', () => {
45
+ const output = `<testsuites>
46
+ <testsuite name="outer" tests="5" failures="0" errors="0">
47
+ <testsuite name="inner" tests="5" failures="0" errors="0"></testsuite>
48
+ </testsuite>
49
+ </testsuites>`;
50
+ expect(parseJunitOutput(output)!.total).toBe(5);
51
+ });
52
+
53
+ test('should count failures and errors together as failed', () => {
54
+ const output = `<testsuite tests="10" failures="2" errors="1" skipped="1"></testsuite>`;
55
+ expect(parseJunitOutput(output)).toEqual({ total: 10, passed: 6, failed: 3, skipped: 1, result: 'FAILED' });
56
+ });
57
+
58
+ test('should count disabled tests as skipped', () => {
59
+ const output = `<testsuites tests="6" failures="0" errors="0" disabled="2"></testsuites>`;
60
+ expect(parseJunitOutput(output)).toEqual({ total: 6, passed: 4, failed: 0, skipped: 2, result: 'PASSED' });
61
+ });
62
+
63
+ test('should count <testcase> elements when the suites carry no counts', () => {
64
+ const output = `<testsuites><testsuite name="bare">
65
+ <testcase name="a" />
66
+ <testcase name="b"><failure message="boom" /></testcase>
67
+ <testcase name="c"><skipped /></testcase>
68
+ <testcase name="d"><error message="crash" /></testcase>
69
+ </testsuite></testsuites>`;
70
+ expect(parseJunitOutput(output)).toEqual({ total: 4, passed: 1, failed: 2, skipped: 1, result: 'FAILED' });
71
+ });
72
+
73
+ test('should accept single-quoted attributes', () => {
74
+ expect(parseJunitOutput(`<testsuite tests='3' failures='0'></testsuite>`)!.total).toBe(3);
75
+ });
76
+
77
+ test('should ignore a commented-out suite', () => {
78
+ const output = `<!-- <testsuite tests="99" failures="9"></testsuite> -->
79
+ <testsuite tests="2" failures="0"></testsuite>`;
80
+ expect(parseJunitOutput(output)).toEqual({ total: 2, passed: 2, failed: 0, skipped: undefined, result: 'PASSED' });
81
+ });
82
+
83
+ test('should return null for a report with zero tests', () => {
84
+ expect(parseJunitOutput(`<testsuites><testsuite tests="0" failures="0"></testsuite></testsuites>`)).toBeNull();
85
+ });
86
+
87
+ test('should return null for output that is not JUnit XML', () => {
88
+ expect(parseJunitOutput('Tests: 173 passed, 173 total')).toBeNull();
89
+ expect(parseJunitOutput('')).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe('parseTestOutput routing', () => {
94
+ test('should list JUnit XML first among the supported parsers', () => {
95
+ expect(SUPPORTED_PARSERS[0]).toBe('JUnit XML');
96
+ });
97
+
98
+ test('should read the XML counts even when captured stdout holds another summary line', () => {
99
+ const output = `<testsuite tests="20" failures="0" errors="0" skipped="0">
100
+ <system-out>Tests: 3 passed, 3 total</system-out>
101
+ </testsuite>`;
102
+ expect(parseTestOutput(output)!.total).toBe(20);
103
+ });
104
+
105
+ test('should still route plain Jest output to the Jest parser', () => {
106
+ expect(parseTestOutput('Tests: 1 failed, 172 passed, 173 total')!.total).toBe(173);
107
+ });
108
+ });
109
+ });