faf-taf-git 2.0.4 → 2.2.2

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.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +53 -0
  2. package/.github/workflows/claude-code-review.yml.disabled +29 -0
  3. package/.github/workflows/claude.yml +39 -0
  4. package/.github/workflows/homebrew.yml +19 -0
  5. package/.taf +79 -2
  6. package/CHANGELOG.md +32 -0
  7. package/CLAUDE.md +29 -21
  8. package/CODE_OF_CONDUCT.md +21 -0
  9. package/CONTRIBUTING.md +3 -0
  10. package/README.md +75 -12
  11. package/SECURITY.md +19 -0
  12. package/action.yml +7 -2
  13. package/badge.svg +21 -0
  14. package/dist/badge.d.ts +23 -0
  15. package/dist/badge.d.ts.map +1 -0
  16. package/dist/badge.js +147 -0
  17. package/dist/badge.js.map +1 -0
  18. package/dist/cli.d.ts +5 -6
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +144 -193
  21. package/dist/cli.js.map +1 -1
  22. package/dist/index.js +26045 -20566
  23. package/dist/index.js.map +1 -1
  24. package/dist/licenses.txt +0 -22
  25. package/dist/parsers/bun.d.ts +59 -0
  26. package/dist/parsers/bun.d.ts.map +1 -0
  27. package/dist/parsers/bun.js +100 -0
  28. package/dist/parsers/bun.js.map +1 -0
  29. package/dist/parsers/index.d.ts +20 -0
  30. package/dist/parsers/index.d.ts.map +1 -0
  31. package/dist/parsers/index.js +26 -0
  32. package/dist/parsers/index.js.map +1 -0
  33. package/dist/parsers/vitest.d.ts +17 -0
  34. package/dist/parsers/vitest.d.ts.map +1 -0
  35. package/dist/parsers/vitest.js +61 -0
  36. package/dist/parsers/vitest.js.map +1 -0
  37. package/package.json +6 -7
  38. package/project.faf +20 -24
  39. package/src/badge.ts +138 -0
  40. package/src/cli.ts +154 -200
  41. package/src/index.ts +79 -10
  42. package/src/parsers/bun.ts +103 -0
  43. package/src/parsers/index.ts +27 -0
  44. package/src/parsers/vitest.ts +65 -0
  45. package/tests/badge.test.ts +260 -0
  46. package/tests/bun-parser.test.ts +173 -0
  47. package/tests/vitest-parser.test.ts +112 -0
  48. package/.faf +0 -126
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Bun test output parser
3
+ *
4
+ * Extracts test counts from `bun test` CLI output.
5
+ */
6
+
7
+ import { TestResults } from './jest';
8
+
9
+ /**
10
+ * Parse Bun test output to extract test results.
11
+ *
12
+ * Bun output format (summary block at the end of a run):
13
+ *
14
+ * ```
15
+ * 232 pass
16
+ * 1 skip
17
+ * 1 todo
18
+ * 0 fail
19
+ * 848 expect() calls
20
+ * Ran 234 tests across 9 files. [21.53s]
21
+ * ```
22
+ *
23
+ * Notes:
24
+ * - Anchored on `Ran <N> tests across <M> files.` — the canonical truthful
25
+ * total (includes skip + todo + pass + fail). Without this line, the parser
26
+ * returns null and the dispatcher falls through to jest/vitest.
27
+ * - Per-category counts (`pass`, `fail`, `skip`, `todo`) come from their own
28
+ * lines elsewhere in the same output. Singular forms — bun does not use
29
+ * `passed`/`failed`/`skipped` (intentional differentiator from jest/vitest).
30
+ * - `skipped` aggregates `skip` + `todo` (matches the convention vitest.ts
31
+ * already uses for parity in the shared TestResults shape).
32
+ */
33
+ export function parseBunOutput(output: string): TestResults | null {
34
+ // Strip ANSI color codes + normalize line endings (parity with siblings).
35
+ // eslint-disable-next-line no-control-regex
36
+ let cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
37
+ cleanOutput = cleanOutput.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
38
+
39
+ // Canonical anchor: bun-unique "Ran N tests across M files." summary line.
40
+ // Without this, this isn't bun output — let the dispatcher try the others.
41
+ const ranMatch = cleanOutput.match(/^Ran\s+(\d+)\s+tests?\s+across\s+\d+\s+files?/m);
42
+ if (!ranMatch) return null;
43
+
44
+ const total = parseInt(ranMatch[1], 10);
45
+ if (total === 0) return null;
46
+
47
+ let passed = 0;
48
+ let failed = 0;
49
+ let skipped = 0;
50
+
51
+ // Per-line counts. Each line is `<leading-ws><N> <singular-word>` on its own.
52
+ // Anchored with ^...$ + multiline to avoid catching the "expect() calls" line
53
+ // or any noise inside test descriptions.
54
+ const passedMatch = cleanOutput.match(/^\s*(\d+)\s+pass\s*$/m);
55
+ if (passedMatch) passed = parseInt(passedMatch[1], 10);
56
+
57
+ const failedMatch = cleanOutput.match(/^\s*(\d+)\s+fail\s*$/m);
58
+ if (failedMatch) failed = parseInt(failedMatch[1], 10);
59
+
60
+ const skipMatch = cleanOutput.match(/^\s*(\d+)\s+skip\s*$/m);
61
+ if (skipMatch) skipped += parseInt(skipMatch[1], 10);
62
+
63
+ const todoMatch = cleanOutput.match(/^\s*(\d+)\s+todo\s*$/m);
64
+ if (todoMatch) skipped += parseInt(todoMatch[1], 10);
65
+
66
+ const result: 'PASSED' | 'FAILED' = failed > 0 ? 'FAILED' : 'PASSED';
67
+
68
+ return {
69
+ total,
70
+ passed,
71
+ failed,
72
+ skipped: skipped > 0 ? skipped : undefined,
73
+ result,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Example Bun outputs for testing:
79
+ *
80
+ * All passing:
81
+ * ```
82
+ * 173 pass
83
+ * 0 fail
84
+ * Ran 173 tests across 8 files. [4.2s]
85
+ * ```
86
+ *
87
+ * Mixed (the grok-faf-mcp 1.4.9 shape):
88
+ * ```
89
+ * 232 pass
90
+ * 1 skip
91
+ * 1 todo
92
+ * 0 fail
93
+ * 848 expect() calls
94
+ * Ran 234 tests across 9 files. [21.53s]
95
+ * ```
96
+ *
97
+ * Failing:
98
+ * ```
99
+ * 170 pass
100
+ * 3 fail
101
+ * Ran 173 tests across 8 files. [5.1s]
102
+ * ```
103
+ */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Unified test output parser
3
+ *
4
+ * Tries each framework parser in order until one succeeds.
5
+ */
6
+
7
+ import { TestResults } from './jest';
8
+ import { parseBunOutput } from './bun';
9
+ import { parseJestOutput } from './jest';
10
+ import { parseVitestOutput } from './vitest';
11
+
12
+ export { TestResults } from './jest';
13
+
14
+ /**
15
+ * Parse test output from any supported framework.
16
+ *
17
+ * Order matters: Bun is tried first because its `Ran N tests across M files`
18
+ * anchor is bun-unique — it never false-matches on jest/vitest output. Putting
19
+ * it first also lets bun consumers ship truthful totals even when their CI
20
+ * still has a legacy `Tests: N passed, N total` jest-shim line appended to the
21
+ * test output (the bun parser ignores it and reads the canonical `Ran` line).
22
+ *
23
+ * Tries parsers in order: Bun, Jest, Vitest.
24
+ */
25
+ export function parseTestOutput(output: string): TestResults | null {
26
+ return parseBunOutput(output) || parseJestOutput(output) || parseVitestOutput(output);
27
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Vitest output parser
3
+ *
4
+ * Extracts test counts from Vitest CLI output
5
+ */
6
+
7
+ import { TestResults } from './jest';
8
+
9
+ /**
10
+ * Parse Vitest output to extract test results
11
+ *
12
+ * Vitest output formats:
13
+ * - " Tests 8 passed (8)"
14
+ * - " Tests 2 failed | 6 passed (8)"
15
+ * - " Tests 1 skipped | 7 passed (8)"
16
+ * - " Tests 1 failed | 2 skipped | 5 passed (8)"
17
+ */
18
+ export function parseVitestOutput(output: string): TestResults | null {
19
+ // Strip ANSI color codes
20
+ // eslint-disable-next-line no-control-regex
21
+ let cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
22
+ cleanOutput = cleanOutput.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
23
+
24
+ // Vitest summary pattern: " Tests ... (N)" where N is total in parens
25
+ const testSummaryPattern = /^\s*Tests\s+(.+?\(\d+\))/m;
26
+ const testLineMatch = cleanOutput.match(testSummaryPattern);
27
+
28
+ if (!testLineMatch || !testLineMatch[1]) {
29
+ return null;
30
+ }
31
+
32
+ const testLine = testLineMatch[1];
33
+
34
+ // Extract total from parentheses at end
35
+ const totalMatch = testLine.match(/\((\d+)\)\s*$/);
36
+ if (!totalMatch) return null;
37
+ const total = parseInt(totalMatch[1], 10);
38
+ if (total === 0) return null;
39
+
40
+ let passed = 0;
41
+ let failed = 0;
42
+ let skipped = 0;
43
+
44
+ const passedMatch = testLine.match(/(\d+)\s+passed/i);
45
+ if (passedMatch) passed = parseInt(passedMatch[1], 10);
46
+
47
+ const failedMatch = testLine.match(/(\d+)\s+failed/i);
48
+ if (failedMatch) failed = parseInt(failedMatch[1], 10);
49
+
50
+ const skippedMatch = testLine.match(/(\d+)\s+skipped/i);
51
+ if (skippedMatch) skipped = parseInt(skippedMatch[1], 10);
52
+
53
+ const todoMatch = testLine.match(/(\d+)\s+todo/i);
54
+ if (todoMatch) skipped += parseInt(todoMatch[1], 10);
55
+
56
+ let result: 'PASSED' | 'FAILED' = failed > 0 ? 'FAILED' : 'PASSED';
57
+
58
+ return {
59
+ total,
60
+ passed,
61
+ failed,
62
+ skipped: skipped > 0 ? skipped : undefined,
63
+ result,
64
+ };
65
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Badge Generator Tests
3
+ *
4
+ * Tests SVG output validity, .taf parsing, and edge cases.
5
+ */
6
+
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import * as os from 'os';
10
+ import { renderBadgeSvg, generateBadge } from '../src/badge';
11
+
12
+ function createTempDir(prefix: string): string {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
14
+ }
15
+
16
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17
+ // SVG RENDERING
18
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
19
+
20
+ describe('SVG Rendering', () => {
21
+ test('should produce valid SVG with label and value', () => {
22
+ const svg = renderBadgeSvg('TAF', '10/10 passing', '#003366');
23
+
24
+ expect(svg).toContain('<svg');
25
+ expect(svg).toContain('</svg>');
26
+ expect(svg).toContain('TAF');
27
+ expect(svg).toContain('10/10 passing');
28
+ });
29
+
30
+ test('should include correct color', () => {
31
+ const svg = renderBadgeSvg('TAF', '10/10 passing', '#003366');
32
+
33
+ expect(svg).toContain('fill="#003366"');
34
+ });
35
+
36
+ test('should include accessibility attributes', () => {
37
+ const svg = renderBadgeSvg('TAF', '5/5 passing', '#003366');
38
+
39
+ expect(svg).toContain('role="img"');
40
+ expect(svg).toContain('aria-label="TAF: 5/5 passing"');
41
+ expect(svg).toContain('<title>TAF: 5/5 passing</title>');
42
+ });
43
+
44
+ test('should use Verdana font', () => {
45
+ const svg = renderBadgeSvg('TAF', 'test', '#555');
46
+
47
+ expect(svg).toContain('Verdana');
48
+ });
49
+
50
+ test('should have 20px height', () => {
51
+ const svg = renderBadgeSvg('TAF', 'test', '#555');
52
+
53
+ expect(svg).toContain('height="20"');
54
+ });
55
+
56
+ test('should have rounded corners via clipPath', () => {
57
+ const svg = renderBadgeSvg('TAF', 'test', '#555');
58
+
59
+ expect(svg).toContain('rx="3"');
60
+ expect(svg).toContain('clip-path="url(#r)"');
61
+ });
62
+
63
+ test('should handle custom label', () => {
64
+ const svg = renderBadgeSvg('tests', '99/100 passing', '#E32400');
65
+
66
+ expect(svg).toContain('tests');
67
+ expect(svg).toContain('99/100 passing');
68
+ expect(svg).toContain('#E32400');
69
+ });
70
+ });
71
+
72
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
73
+ // BADGE GENERATION FROM .taf
74
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
75
+
76
+ describe('Badge Generation', () => {
77
+ let tempDir: string;
78
+
79
+ beforeEach(() => {
80
+ tempDir = createTempDir('badge-test-');
81
+ });
82
+
83
+ afterEach(() => {
84
+ fs.rmSync(tempDir, { recursive: true, force: true });
85
+ });
86
+
87
+ test('should generate navy badge when all tests pass', () => {
88
+ const tafPath = path.join(tempDir, '.taf');
89
+ fs.writeFileSync(tafPath, `format_version: 1.0.0
90
+ project: test
91
+ test_history:
92
+ - timestamp: "2026-01-01T00:00:00Z"
93
+ result: PASSED
94
+ tests:
95
+ total: 38
96
+ passed: 38
97
+ failed: 0
98
+ `);
99
+
100
+ const result = generateBadge({ tafPath });
101
+
102
+ expect(result.success).toBe(true);
103
+ expect(result.svg).toContain('38/38 passing');
104
+ expect(result.svg).toContain('#003366');
105
+ });
106
+
107
+ test('should generate red badge when tests fail', () => {
108
+ const tafPath = path.join(tempDir, '.taf');
109
+ fs.writeFileSync(tafPath, `format_version: 1.0.0
110
+ project: test
111
+ test_history:
112
+ - timestamp: "2026-01-01T00:00:00Z"
113
+ result: FAILED
114
+ tests:
115
+ total: 38
116
+ passed: 35
117
+ failed: 3
118
+ `);
119
+
120
+ const result = generateBadge({ tafPath });
121
+
122
+ expect(result.success).toBe(true);
123
+ expect(result.svg).toContain('35/38 passing');
124
+ expect(result.svg).toContain('#E32400');
125
+ });
126
+
127
+ test('should generate gray badge when .taf is missing', () => {
128
+ const tafPath = path.join(tempDir, '.taf');
129
+ // Don't create the file
130
+
131
+ const result = generateBadge({ tafPath });
132
+
133
+ expect(result.success).toBe(true);
134
+ expect(result.svg).toContain('no data');
135
+ expect(result.svg).toContain('#9F9F9F');
136
+ });
137
+
138
+ test('should generate gray badge when test_history is empty', () => {
139
+ const tafPath = path.join(tempDir, '.taf');
140
+ fs.writeFileSync(tafPath, `format_version: 1.0.0
141
+ project: test
142
+ test_history: []
143
+ `);
144
+
145
+ const result = generateBadge({ tafPath });
146
+
147
+ expect(result.success).toBe(true);
148
+ expect(result.svg).toContain('no data');
149
+ expect(result.svg).toContain('#9F9F9F');
150
+ });
151
+
152
+ test('should use latest run from multiple entries', () => {
153
+ const tafPath = path.join(tempDir, '.taf');
154
+ fs.writeFileSync(tafPath, `format_version: 1.0.0
155
+ project: test
156
+ test_history:
157
+ - timestamp: "2026-01-01T00:00:00Z"
158
+ result: FAILED
159
+ tests:
160
+ total: 10
161
+ passed: 7
162
+ failed: 3
163
+ - timestamp: "2026-01-02T00:00:00Z"
164
+ result: PASSED
165
+ tests:
166
+ total: 10
167
+ passed: 10
168
+ failed: 0
169
+ `);
170
+
171
+ const result = generateBadge({ tafPath });
172
+
173
+ expect(result.success).toBe(true);
174
+ expect(result.svg).toContain('10/10 passing');
175
+ expect(result.svg).toContain('#003366');
176
+ });
177
+
178
+ test('should use custom label', () => {
179
+ const tafPath = path.join(tempDir, '.taf');
180
+ fs.writeFileSync(tafPath, `format_version: 1.0.0
181
+ project: test
182
+ test_history:
183
+ - timestamp: "2026-01-01T00:00:00Z"
184
+ result: PASSED
185
+ tests:
186
+ total: 5
187
+ passed: 5
188
+ failed: 0
189
+ `);
190
+
191
+ const result = generateBadge({ tafPath, label: 'tests' });
192
+
193
+ expect(result.success).toBe(true);
194
+ expect(result.svg).toContain('tests');
195
+ expect(result.svg).not.toContain('>TAF<');
196
+ });
197
+
198
+ test('should handle single test run', () => {
199
+ const tafPath = path.join(tempDir, '.taf');
200
+ fs.writeFileSync(tafPath, `format_version: 1.0.0
201
+ project: test
202
+ test_history:
203
+ - timestamp: "2026-01-01T00:00:00Z"
204
+ result: PASSED
205
+ tests:
206
+ total: 1
207
+ passed: 1
208
+ failed: 0
209
+ `);
210
+
211
+ const result = generateBadge({ tafPath });
212
+
213
+ expect(result.success).toBe(true);
214
+ expect(result.svg).toContain('1/1 passing');
215
+ });
216
+
217
+ test('should handle large test counts', () => {
218
+ const tafPath = path.join(tempDir, '.taf');
219
+ fs.writeFileSync(tafPath, `format_version: 1.0.0
220
+ project: test
221
+ test_history:
222
+ - timestamp: "2026-01-01T00:00:00Z"
223
+ result: PASSED
224
+ tests:
225
+ total: 10000
226
+ passed: 10000
227
+ failed: 0
228
+ `);
229
+
230
+ const result = generateBadge({ tafPath });
231
+
232
+ expect(result.success).toBe(true);
233
+ expect(result.svg).toContain('10000/10000 passing');
234
+ });
235
+
236
+ test('should handle invalid YAML gracefully', () => {
237
+ const tafPath = path.join(tempDir, '.taf');
238
+ fs.writeFileSync(tafPath, '{{{{ not yaml at all');
239
+
240
+ const result = generateBadge({ tafPath });
241
+
242
+ // Should either return no-data badge or error, but never throw
243
+ expect(result).toBeDefined();
244
+ });
245
+
246
+ test('should handle .taf with missing tests field', () => {
247
+ const tafPath = path.join(tempDir, '.taf');
248
+ fs.writeFileSync(tafPath, `format_version: 1.0.0
249
+ project: test
250
+ test_history:
251
+ - timestamp: "2026-01-01T00:00:00Z"
252
+ result: PASSED
253
+ `);
254
+
255
+ const result = generateBadge({ tafPath });
256
+
257
+ expect(result.success).toBe(true);
258
+ expect(result.svg).toContain('no data');
259
+ });
260
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Tests for Bun output parser
3
+ */
4
+
5
+ import { parseBunOutput } from '../src/parsers/bun';
6
+
7
+ describe('Bun Parser', () => {
8
+ describe('parseBunOutput', () => {
9
+ it('should parse all passing tests (minimal output)', () => {
10
+ const output = `
11
+ 173 pass
12
+ 0 fail
13
+ 612 expect() calls
14
+ Ran 173 tests across 8 files. [4.2s]
15
+ `;
16
+
17
+ const result = parseBunOutput(output);
18
+
19
+ expect(result).not.toBeNull();
20
+ expect(result?.total).toBe(173);
21
+ expect(result?.passed).toBe(173);
22
+ expect(result?.failed).toBe(0);
23
+ expect(result?.skipped).toBeUndefined();
24
+ expect(result?.result).toBe('PASSED');
25
+ });
26
+
27
+ it('should parse the grok-faf-mcp 1.4.9 shape (pass + skip + todo + fail=0)', () => {
28
+ const output = `
29
+ 232 pass
30
+ 1 skip
31
+ 1 todo
32
+ 0 fail
33
+ 848 expect() calls
34
+ Ran 234 tests across 9 files. [21.53s]
35
+ `;
36
+
37
+ const result = parseBunOutput(output);
38
+
39
+ expect(result).not.toBeNull();
40
+ expect(result?.total).toBe(234);
41
+ expect(result?.passed).toBe(232);
42
+ expect(result?.failed).toBe(0);
43
+ // skipped aggregates skip + todo (parity with vitest convention)
44
+ expect(result?.skipped).toBe(2);
45
+ expect(result?.result).toBe('PASSED');
46
+ });
47
+
48
+ it('should mark result FAILED when any tests fail', () => {
49
+ const output = `
50
+ 170 pass
51
+ 3 fail
52
+ 612 expect() calls
53
+ Ran 173 tests across 8 files. [5.1s]
54
+ `;
55
+
56
+ const result = parseBunOutput(output);
57
+
58
+ expect(result).not.toBeNull();
59
+ expect(result?.total).toBe(173);
60
+ expect(result?.passed).toBe(170);
61
+ expect(result?.failed).toBe(3);
62
+ expect(result?.result).toBe('FAILED');
63
+ });
64
+
65
+ it('should handle skip-only output', () => {
66
+ const output = `
67
+ 170 pass
68
+ 3 skip
69
+ 0 fail
70
+ 612 expect() calls
71
+ Ran 173 tests across 8 files. [5.0s]
72
+ `;
73
+
74
+ const result = parseBunOutput(output);
75
+
76
+ expect(result?.total).toBe(173);
77
+ expect(result?.passed).toBe(170);
78
+ expect(result?.skipped).toBe(3);
79
+ expect(result?.result).toBe('PASSED');
80
+ });
81
+
82
+ it('should strip ANSI color codes', () => {
83
+ const output =
84
+ '\x1b[32m 232 pass\x1b[0m\n' +
85
+ '\x1b[33m 1 skip\x1b[0m\n' +
86
+ '\x1b[33m 1 todo\x1b[0m\n' +
87
+ '\x1b[31m 0 fail\x1b[0m\n' +
88
+ ' 848 expect() calls\n' +
89
+ 'Ran 234 tests across 9 files. [21.53s]\n';
90
+
91
+ const result = parseBunOutput(output);
92
+
93
+ expect(result?.total).toBe(234);
94
+ expect(result?.passed).toBe(232);
95
+ expect(result?.skipped).toBe(2);
96
+ });
97
+
98
+ it('should handle CRLF line endings', () => {
99
+ const output =
100
+ ' 173 pass\r\n 0 fail\r\nRan 173 tests across 8 files. [4.2s]\r\n';
101
+
102
+ const result = parseBunOutput(output);
103
+
104
+ expect(result?.total).toBe(173);
105
+ expect(result?.passed).toBe(173);
106
+ });
107
+
108
+ it('should ignore an appended jest-shim "Tests:" line (legacy ci.yml conversion)', () => {
109
+ // Some consumer CIs append a jest-shaped summary so the legacy jest
110
+ // parser could read bun output. With bun support, the appended line
111
+ // should be ignored — the canonical Ran-line wins.
112
+ const output = `
113
+ 232 pass
114
+ 1 skip
115
+ 1 todo
116
+ 0 fail
117
+ 848 expect() calls
118
+ Ran 234 tests across 9 files. [21.53s]
119
+ Tests: 232 passed, 232 total
120
+ `;
121
+
122
+ const result = parseBunOutput(output);
123
+
124
+ // Truthful total from "Ran 234" — NOT 232 from the jest-shim line.
125
+ expect(result?.total).toBe(234);
126
+ expect(result?.passed).toBe(232);
127
+ expect(result?.skipped).toBe(2);
128
+ });
129
+
130
+ it('should return null when no "Ran N tests" line is present (non-bun output)', () => {
131
+ const output = `
132
+ Test Suites: 18 passed, 18 total
133
+ Tests: 173 passed, 173 total
134
+ Snapshots: 0 total
135
+ Time: 2.5s
136
+ `;
137
+
138
+ const result = parseBunOutput(output);
139
+
140
+ // Defers to the next parser in the dispatcher chain.
141
+ expect(result).toBeNull();
142
+ });
143
+
144
+ it('should return null on empty input', () => {
145
+ expect(parseBunOutput('')).toBeNull();
146
+ });
147
+
148
+ it('should return null when "Ran 0 tests" (nothing actually ran)', () => {
149
+ const output = `
150
+ 0 pass
151
+ 0 fail
152
+ Ran 0 tests across 0 files. [0.1s]
153
+ `;
154
+
155
+ const result = parseBunOutput(output);
156
+
157
+ expect(result).toBeNull();
158
+ });
159
+
160
+ it('should handle singular forms gracefully (1 test)', () => {
161
+ const output = `
162
+ 1 pass
163
+ 0 fail
164
+ Ran 1 test across 1 file. [0.5s]
165
+ `;
166
+
167
+ const result = parseBunOutput(output);
168
+
169
+ expect(result?.total).toBe(1);
170
+ expect(result?.passed).toBe(1);
171
+ });
172
+ });
173
+ });