Skip to content

Commit 8402b50

Browse files
Shahinyanmshahinyanm
andauthored
fix(test-summary): parse node:test (node --test) TAP output (#52)
detectRunner did not recognise `node --test`, so its output fell through to the generic parser. node:test emits a TAP footer ("# pass N" / "# fail N", numbers after the word) and "ok N - name" points (ok before the number), which the generic "<N> passed" regex never matches — so a green 2/2 run reported 0 passed. Add a 'node' runner: detect it by command (`node --test` / `node:test`) or by the TAP footer, and parse pass/fail/skipped/tests from the footer with a fallback to counting ok/not ok lines. Failure names come from "not ok N - name". Purely additive — no existing parser is touched. Co-authored-by: shahinyanm <mher.shahinyan@12go.asia>
1 parent fba0fea commit 8402b50

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

src/handlers/test-summary.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export function detectRunner(command: string, output: string): string {
101101
if (cmd.includes('go test')) return 'go';
102102
if (cmd.includes('rspec')) return 'rspec';
103103
if (cmd.includes('mocha')) return 'mocha';
104+
if (cmd.includes('node --test') || cmd.includes('node:test')) return 'node';
104105

105106
// Detect from output
106107
const lower = output.toLowerCase();
@@ -109,6 +110,8 @@ export function detectRunner(command: string, output: string): string {
109110
if (lower.includes('pytest') || (lower.includes('=== ') && lower.includes(' passed'))) return 'pytest';
110111
if (lower.includes('phpunit')) return 'phpunit';
111112
if (lower.includes('--- fail:') || lower.includes('--- pass:') || lower.includes('ok \t')) return 'go';
113+
// node:test prints a TAP summary footer: "# tests N" + "# pass N" + "# fail N".
114+
if (/^#\s*tests\s+\d+/m.test(output) && /^#\s*pass\s+\d+/m.test(output)) return 'node';
112115

113116
return 'generic';
114117
}
@@ -130,6 +133,8 @@ export function parseTestOutput(output: string, runner: string): TestResult {
130133
return parseGoTest(output);
131134
case 'cargo':
132135
return parseCargoTest(output);
136+
case 'node':
137+
return parseNodeTest(output);
133138
default:
134139
return parseGeneric(output);
135140
}
@@ -293,6 +298,45 @@ function parseGoTest(output: string): TestResult {
293298
return result;
294299
}
295300

301+
function parseNodeTest(output: string): TestResult {
302+
const result: TestResult = { total: 0, passed: 0, failed: 0, skipped: 0, failures: [] };
303+
304+
// node:test (`node --test`) prints a TAP summary footer:
305+
// # tests 2
306+
// # pass 2
307+
// # fail 0
308+
// # skipped 0
309+
const num = (re: RegExp): number | null => {
310+
const m = output.match(re);
311+
return m ? parseInt(m[1], 10) : null;
312+
};
313+
const pass = num(/^#\s*pass\s+(\d+)/m);
314+
const fail = num(/^#\s*fail\s+(\d+)/m);
315+
const skip = num(/^#\s*skipped\s+(\d+)/m);
316+
const tests = num(/^#\s*tests\s+(\d+)/m);
317+
318+
if (pass !== null || fail !== null) {
319+
result.passed = pass ?? 0;
320+
result.failed = fail ?? 0;
321+
result.skipped = skip ?? 0;
322+
result.total = tests ?? result.passed + result.failed + result.skipped;
323+
} else {
324+
// No footer (truncated output) — count the TAP point lines instead.
325+
result.passed = (output.match(/^ok\s+\d+/gm) ?? []).length;
326+
result.failed = (output.match(/^not ok\s+\d+/gm) ?? []).length;
327+
result.total = result.passed + result.failed + result.skipped;
328+
}
329+
330+
// Failure names come from the TAP point: "not ok 3 - the test name".
331+
const failPattern = /^not ok\s+\d+\s*-\s*(.+)$/gm;
332+
let match: RegExpExecArray | null;
333+
while ((match = failPattern.exec(output)) !== null) {
334+
result.failures.push({ name: match[1].trim(), error: '' });
335+
}
336+
337+
return result;
338+
}
339+
296340
function parseCargoTest(output: string): TestResult {
297341
const result: TestResult = { total: 0, passed: 0, failed: 0, skipped: 0, failures: [] };
298342

tests/handlers/test-summary.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,52 @@ describe('handleTestSummary', () => {
182182
expect(text).toContain('boom');
183183
});
184184
});
185+
186+
describe('parseTestOutput — node:test (TAP)', () => {
187+
// Real `node --test` output footer.
188+
const passing = [
189+
'ok 1 - a',
190+
'ok 2 - b',
191+
'1..2',
192+
'# tests 2',
193+
'# suites 0',
194+
'# pass 2',
195+
'# fail 0',
196+
'# cancelled 0',
197+
'# skipped 0',
198+
'# todo 0',
199+
'# duration_ms 41.305043',
200+
].join('\n');
201+
202+
it('detects node:test from command', () => {
203+
expect(detectRunner('node --test', '')).toBe('node');
204+
expect(detectRunner('node --test test/*.mjs', '')).toBe('node');
205+
});
206+
207+
it('detects node:test from the TAP footer when the command is generic', () => {
208+
expect(detectRunner('npm test', passing)).toBe('node');
209+
});
210+
211+
it('parses all-passing node:test output (regression: was 0/2)', () => {
212+
const r = parseTestOutput(passing, 'node');
213+
expect(r.passed).toBe(2);
214+
expect(r.failed).toBe(0);
215+
expect(r.total).toBe(2);
216+
});
217+
218+
it('parses failures with their names', () => {
219+
const failing = [
220+
'ok 1 - a',
221+
'not ok 2 - b',
222+
'1..2',
223+
'# tests 2',
224+
'# pass 1',
225+
'# fail 1',
226+
'# skipped 0',
227+
].join('\n');
228+
const r = parseTestOutput(failing, 'node');
229+
expect(r.passed).toBe(1);
230+
expect(r.failed).toBe(1);
231+
expect(r.failures.map((f) => f.name)).toContain('b');
232+
});
233+
});

0 commit comments

Comments
 (0)