@robhowley/pi-structured-return 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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/extensions/structured-return/examples/.pi/machine-readable.json +18 -0
  4. package/extensions/structured-return/examples/.pi/parsers/foo-cli.ts +25 -0
  5. package/extensions/structured-return/src/.tmp/vitest-report.xml +55 -0
  6. package/extensions/structured-return/src/config/project-config.ts +10 -0
  7. package/extensions/structured-return/src/config/registry.ts +89 -0
  8. package/extensions/structured-return/src/index.test.ts +98 -0
  9. package/extensions/structured-return/src/index.ts +161 -0
  10. package/extensions/structured-return/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/extensions/structured-return/src/parsers/eslint-json.test.ts +60 -0
  12. package/extensions/structured-return/src/parsers/eslint-json.ts +33 -0
  13. package/extensions/structured-return/src/parsers/junit-xml.test.ts +169 -0
  14. package/extensions/structured-return/src/parsers/junit-xml.ts +97 -0
  15. package/extensions/structured-return/src/parsers/minitest-text.test.ts +110 -0
  16. package/extensions/structured-return/src/parsers/minitest-text.ts +99 -0
  17. package/extensions/structured-return/src/parsers/pytest-json-report.test.ts +76 -0
  18. package/extensions/structured-return/src/parsers/pytest-json-report.ts +41 -0
  19. package/extensions/structured-return/src/parsers/rspec-json.test.ts +126 -0
  20. package/extensions/structured-return/src/parsers/rspec-json.ts +76 -0
  21. package/extensions/structured-return/src/parsers/ruff-json.test.ts +57 -0
  22. package/extensions/structured-return/src/parsers/ruff-json.ts +37 -0
  23. package/extensions/structured-return/src/parsers/tail-fallback.test.ts +41 -0
  24. package/extensions/structured-return/src/parsers/tail-fallback.ts +20 -0
  25. package/extensions/structured-return/src/parsers/vitest-json.test.ts +118 -0
  26. package/extensions/structured-return/src/parsers/vitest-json.ts +60 -0
  27. package/extensions/structured-return/src/storage/log-store.ts +23 -0
  28. package/extensions/structured-return/src/types.ts +55 -0
  29. package/extensions/structured-return/src/ui/widget.ts +3 -0
  30. package/extensions/structured-return/tsconfig.json +11 -0
  31. package/extensions/structured-return/vitest.config.ts +9 -0
  32. package/package.json +62 -0
  33. package/skills/structured-return/SKILL.md +67 -0
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, it, expect } from "vitest";
5
+ import parser from "./eslint-json";
6
+ import type { RunContext } from "../types";
7
+
8
+ function makeCtx(stdout: string, cwd = "/project"): RunContext {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "eslint-test-"));
10
+ const stdoutPath = path.join(dir, "stdout");
11
+ fs.writeFileSync(stdoutPath, stdout);
12
+ return {
13
+ command: "eslint . -f json",
14
+ argv: ["eslint", ".", "-f", "json"],
15
+ cwd,
16
+ artifactPaths: [],
17
+ stdoutPath,
18
+ stderrPath: path.join(dir, "stderr"),
19
+ logPath: path.join(dir, "log"),
20
+ };
21
+ }
22
+
23
+ describe("eslint-json parser", () => {
24
+ it("multiple errors across multiple files → correct relative paths, correct failure count, status fail", async () => {
25
+ const cwd = "/project";
26
+ const stdout = JSON.stringify([
27
+ {
28
+ filePath: "/project/src/foo.ts",
29
+ messages: [
30
+ { line: 10, ruleId: "@typescript-eslint/no-explicit-any", message: "Unexpected any." },
31
+ { line: 12, ruleId: "@typescript-eslint/no-explicit-any", message: "Unexpected any." },
32
+ ],
33
+ },
34
+ {
35
+ filePath: "/project/src/bar.ts",
36
+ messages: [{ line: 3, ruleId: "no-unused-vars", message: "x is defined but never used." }],
37
+ },
38
+ ]);
39
+ const result = await parser.parse(makeCtx(stdout, cwd));
40
+ expect(result.status).toBe("fail");
41
+ expect(result.failures).toHaveLength(3);
42
+ expect(result.failures![0].file).toBe("src/foo.ts");
43
+ expect(result.failures![1].file).toBe("src/foo.ts");
44
+ expect(result.failures![2].file).toBe("src/bar.ts");
45
+ expect(result.failures![0].file).not.toContain("/project");
46
+ });
47
+
48
+ it("no errors → empty failures, status pass", async () => {
49
+ const stdout = JSON.stringify([{ filePath: "/project/src/foo.ts", messages: [] }]);
50
+ const result = await parser.parse(makeCtx(stdout));
51
+ expect(result.status).toBe("pass");
52
+ expect(result.failures).toHaveLength(0);
53
+ });
54
+
55
+ it("empty stdout → no crash, status pass", async () => {
56
+ const result = await parser.parse(makeCtx(""));
57
+ expect(result.status).toBe("pass");
58
+ expect(result.failures).toHaveLength(0);
59
+ });
60
+ });
@@ -0,0 +1,33 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { ParserModule } from "../types";
4
+
5
+ const parser: ParserModule = {
6
+ id: "eslint-json",
7
+ async parse(ctx) {
8
+ const stdout = fs.readFileSync(ctx.stdoutPath, "utf8").trim();
9
+ const files = stdout ? JSON.parse(stdout) : [];
10
+ const failures = [] as Array<{ id: string; file?: string; line?: number; message?: string; rule?: string }>;
11
+ for (const file of Array.isArray(files) ? files : []) {
12
+ const relPath = path.relative(ctx.cwd, file.filePath);
13
+ for (const msg of file.messages ?? []) {
14
+ failures.push({
15
+ id: `${relPath}:${msg.line}:${msg.ruleId ?? "unknown"}`,
16
+ file: relPath,
17
+ line: msg.line,
18
+ message: msg.message,
19
+ rule: msg.ruleId ?? undefined,
20
+ });
21
+ }
22
+ }
23
+ return {
24
+ tool: "eslint",
25
+ status: failures.length > 0 ? "fail" : "pass",
26
+ summary: failures.length > 0 ? `${failures.length} lint errors` : "no lint errors",
27
+ failures,
28
+ logPath: ctx.logPath,
29
+ };
30
+ },
31
+ };
32
+
33
+ export default parser;
@@ -0,0 +1,169 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, it, expect } from "vitest";
5
+ import parser from "./junit-xml";
6
+ import type { RunContext } from "../types";
7
+
8
+ function makeCtx(xml: string, cwd = "/project"): RunContext {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "junit-test-"));
10
+ const artifactPath = path.join(dir, "report.xml");
11
+ fs.writeFileSync(artifactPath, xml);
12
+ return {
13
+ command: "gradle test",
14
+ argv: ["gradle", "test"],
15
+ cwd,
16
+ artifactPaths: [artifactPath],
17
+ stdoutPath: path.join(dir, "stdout"),
18
+ stderrPath: path.join(dir, "stderr"),
19
+ logPath: path.join(dir, "log"),
20
+ };
21
+ }
22
+
23
+ const PASSING = (name: string, classname = "com.example.MyTest") =>
24
+ `<testcase name="${name}" classname="${classname}" time="0.001"/>`;
25
+
26
+ const FAILING = (
27
+ name: string,
28
+ classname: string,
29
+ message: string,
30
+ type = "AssertionError",
31
+ file?: string,
32
+ line?: number
33
+ ) => {
34
+ const fileAttr = file ? ` file="${file}"` : "";
35
+ const lineAttr = line !== undefined ? ` line="${line}"` : "";
36
+ return `<testcase name="${name}" classname="${classname}"${fileAttr}${lineAttr} time="0.001">
37
+ <failure message="${message}" type="${type}">full details here</failure>
38
+ </testcase>`;
39
+ };
40
+
41
+ const ERROR = (name: string, classname: string, message: string, type = "RuntimeError") =>
42
+ `<testcase name="${name}" classname="${classname}" time="0.001">
43
+ <error message="${message}" type="${type}">stack trace</error>
44
+ </testcase>`;
45
+
46
+ describe("junit-xml parser", () => {
47
+ describe("testsuites wrapper (multi-suite)", () => {
48
+ it("mix of passed and failed → status fail, correct counts", async () => {
49
+ const xml = `<?xml version="1.0"?>
50
+ <testsuites>
51
+ <testsuite name="suite1" tests="2" failures="1" errors="0">
52
+ ${PASSING("test_a")}
53
+ ${FAILING("test_b", "com.example.MyTest", "expected 2 but was 1")}
54
+ </testsuite>
55
+ <testsuite name="suite2" tests="2" failures="0" errors="0">
56
+ ${PASSING("test_c")}
57
+ ${PASSING("test_d")}
58
+ </testsuite>
59
+ </testsuites>`;
60
+ const result = await parser.parse(makeCtx(xml));
61
+ expect(result.status).toBe("fail");
62
+ expect(result.summary).toBe("1 failed, 3 passed");
63
+ expect(result.failures).toHaveLength(1);
64
+ });
65
+
66
+ it("all passing → status pass", async () => {
67
+ const xml = `<testsuites>
68
+ <testsuite name="suite" tests="2" failures="0" errors="0">
69
+ ${PASSING("test_a")}
70
+ ${PASSING("test_b")}
71
+ </testsuite>
72
+ </testsuites>`;
73
+ const result = await parser.parse(makeCtx(xml));
74
+ expect(result.status).toBe("pass");
75
+ expect(result.summary).toBe("2 passed");
76
+ expect(result.failures).toHaveLength(0);
77
+ });
78
+ });
79
+
80
+ describe("bare testsuite (no wrapper)", () => {
81
+ it("single testsuite at root → parsed correctly", async () => {
82
+ const xml = `<testsuite name="suite" tests="2" failures="1" errors="0">
83
+ ${PASSING("test_a")}
84
+ ${FAILING("test_b", "com.example.MyTest", "assert failed")}
85
+ </testsuite>`;
86
+ const result = await parser.parse(makeCtx(xml));
87
+ expect(result.status).toBe("fail");
88
+ expect(result.summary).toBe("1 failed, 1 passed");
89
+ });
90
+ });
91
+
92
+ describe("error elements", () => {
93
+ it("error counts as failure, message surfaced", async () => {
94
+ const xml = `<testsuite name="suite" tests="2" failures="0" errors="1">
95
+ ${PASSING("test_a")}
96
+ ${ERROR("test_b", "com.example.MyTest", "NullPointerException", "java.lang.NullPointerException")}
97
+ </testsuite>`;
98
+ const result = await parser.parse(makeCtx(xml));
99
+ expect(result.status).toBe("fail");
100
+ expect(result.summary).toBe("1 failed, 1 passed");
101
+ expect(result.failures![0].message).toBe("NullPointerException");
102
+ expect(result.failures![0].rule).toBe("java.lang.NullPointerException");
103
+ });
104
+ });
105
+
106
+ describe("file and line resolution", () => {
107
+ it("file on testcase → relativized to cwd", async () => {
108
+ const xml = `<testsuite name="suite" tests="1" failures="1" errors="0">
109
+ ${FAILING("test_b", "MyTest", "oops", "AssertionError", "/project/src/test/MyTest.java", 42)}
110
+ </testsuite>`;
111
+ const result = await parser.parse(makeCtx(xml, "/project"));
112
+ expect(result.failures![0].file).toBe("src/test/MyTest.java");
113
+ expect(result.failures![0].line).toBe(42);
114
+ });
115
+
116
+ it("file on testsuite (not testcase) → used as fallback", async () => {
117
+ const xml = `<testsuite name="suite" tests="1" failures="1" errors="0" file="src/spec/foo_spec.rb">
118
+ ${FAILING("test_b", "MyTest", "oops")}
119
+ </testsuite>`;
120
+ const result = await parser.parse(makeCtx(xml, "/project"));
121
+ expect(result.failures![0].file).toBe("src/spec/foo_spec.rb");
122
+ });
123
+
124
+ it("no file attr → classname converted to java path", async () => {
125
+ const xml = `<testsuite name="suite" tests="1" failures="1" errors="0">
126
+ ${FAILING("test_b", "com.example.service.MyTest", "oops")}
127
+ </testsuite>`;
128
+ const result = await parser.parse(makeCtx(xml));
129
+ expect(result.failures![0].file).toBe("com/example/service/MyTest.java");
130
+ });
131
+ });
132
+
133
+ describe("failure message", () => {
134
+ it("message attr surfaced directly", async () => {
135
+ const xml = `<testsuite name="suite" tests="1" failures="1" errors="0">
136
+ ${FAILING("test_b", "MyTest", "expected: 99 but was: 12")}
137
+ </testsuite>`;
138
+ const result = await parser.parse(makeCtx(xml));
139
+ expect(result.failures![0].message).toBe("expected: 99 but was: 12");
140
+ });
141
+
142
+ it("failure type surfaced as rule", async () => {
143
+ const xml = `<testsuite name="suite" tests="1" failures="1" errors="0">
144
+ ${FAILING("test_b", "MyTest", "oops", "org.junit.ComparisonFailure")}
145
+ </testsuite>`;
146
+ const result = await parser.parse(makeCtx(xml));
147
+ expect(result.failures![0].rule).toBe("org.junit.ComparisonFailure");
148
+ });
149
+ });
150
+
151
+ describe("multi-suite totals", () => {
152
+ it("failures and errors summed across suites", async () => {
153
+ const xml = `<testsuites>
154
+ <testsuite name="s1" tests="2" failures="1" errors="0">
155
+ ${PASSING("a")}
156
+ ${FAILING("b", "Foo", "oops")}
157
+ </testsuite>
158
+ <testsuite name="s2" tests="2" failures="0" errors="1">
159
+ ${PASSING("c")}
160
+ ${ERROR("d", "Bar", "boom")}
161
+ </testsuite>
162
+ </testsuites>`;
163
+ const result = await parser.parse(makeCtx(xml));
164
+ expect(result.status).toBe("fail");
165
+ expect(result.summary).toBe("2 failed, 2 passed");
166
+ expect(result.failures).toHaveLength(2);
167
+ });
168
+ });
169
+ });
@@ -0,0 +1,97 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { XMLParser } from "fast-xml-parser";
4
+ import type { ParserModule, ParsedFailure } from "../types";
5
+
6
+ interface JUnitFailureOrError {
7
+ message?: string;
8
+ type?: string;
9
+ "#text"?: string;
10
+ }
11
+
12
+ interface JUnitTestCase {
13
+ name?: string;
14
+ classname?: string;
15
+ file?: string;
16
+ line?: string | number;
17
+ failure?: JUnitFailureOrError;
18
+ error?: JUnitFailureOrError;
19
+ }
20
+
21
+ interface JUnitTestSuite {
22
+ name?: string;
23
+ file?: string;
24
+ tests?: string | number;
25
+ failures?: string | number;
26
+ errors?: string | number;
27
+ testcase?: JUnitTestCase[];
28
+ }
29
+
30
+ interface JUnitDocument {
31
+ testsuites?: { testsuite?: JUnitTestSuite[] };
32
+ testsuite?: JUnitTestSuite[];
33
+ }
34
+
35
+ const xmlParser = new XMLParser({
36
+ ignoreAttributes: false,
37
+ attributeNamePrefix: "",
38
+ isArray: (name) => ["testsuite", "testcase"].includes(name),
39
+ });
40
+
41
+ function resolveFile(tc: JUnitTestCase, suite: JUnitTestSuite, cwd: string): string | undefined {
42
+ const raw = tc.file ?? suite.file;
43
+ if (raw) return path.relative(cwd, path.resolve(cwd, raw));
44
+ // Java/JVM: classname like "com.example.MyTest" → "com/example/MyTest.java"
45
+ if (tc.classname) return tc.classname.replace(/\./g, "/") + ".java";
46
+ return undefined;
47
+ }
48
+
49
+ const parser: ParserModule = {
50
+ id: "junit-xml",
51
+ async parse(ctx) {
52
+ const artifactPath = ctx.artifactPaths[0] ?? ctx.stdoutPath;
53
+ const xml = fs.readFileSync(artifactPath, "utf8");
54
+ const doc = xmlParser.parse(xml) as JUnitDocument;
55
+
56
+ const suites: JUnitTestSuite[] = doc.testsuites?.testsuite ?? doc.testsuite ?? [];
57
+
58
+ let totalTests = 0;
59
+ let totalFailed = 0;
60
+ const failures: ParsedFailure[] = [];
61
+
62
+ for (const suite of suites) {
63
+ totalTests += Number(suite.tests ?? 0);
64
+ totalFailed += Number(suite.failures ?? 0) + Number(suite.errors ?? 0);
65
+
66
+ for (const tc of suite.testcase ?? []) {
67
+ const problem = tc.failure ?? tc.error;
68
+ if (!problem) continue;
69
+
70
+ const file = resolveFile(tc, suite, ctx.cwd);
71
+ const line = tc.line !== undefined ? Number(tc.line) : undefined;
72
+ const id = [file, line, tc.name].filter(Boolean).join(":");
73
+
74
+ failures.push({
75
+ id: id || String(failures.length),
76
+ file,
77
+ line: Number.isNaN(line) ? undefined : line,
78
+ message: problem.message ?? problem["#text"]?.trim().split("\n")[0],
79
+ rule: problem.type,
80
+ });
81
+ }
82
+ }
83
+
84
+ const passed = totalTests - totalFailed;
85
+
86
+ return {
87
+ tool: "junit",
88
+ status: totalFailed > 0 ? "fail" : "pass",
89
+ summary: totalFailed > 0 ? `${totalFailed} failed, ${passed} passed` : `${passed} passed`,
90
+ failures,
91
+ artifact: ctx.artifactPaths[0],
92
+ logPath: ctx.logPath,
93
+ };
94
+ },
95
+ };
96
+
97
+ export default parser;
@@ -0,0 +1,110 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, it, expect } from "vitest";
5
+ import parser from "./minitest-text";
6
+ import type { RunContext } from "../types";
7
+
8
+ function makeCtx(stdout: string, cwd = "/project"): RunContext {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "minitest-text-test-"));
10
+ const stdoutPath = path.join(dir, "stdout");
11
+ fs.writeFileSync(stdoutPath, stdout);
12
+ return {
13
+ command: "ruby test/math_test.rb",
14
+ argv: ["ruby", "test/math_test.rb"],
15
+ cwd,
16
+ artifactPaths: [],
17
+ stdoutPath,
18
+ stderrPath: path.join(dir, "stderr"),
19
+ logPath: path.join(dir, "log"),
20
+ };
21
+ }
22
+
23
+ const PASSING_OUTPUT = `Run options: --seed 12345
24
+
25
+ # Running:
26
+
27
+ ...
28
+
29
+ Finished in 0.001s, 3000.0 runs/s, 3000.0 assertions/s.
30
+
31
+ 3 runs, 3 assertions, 0 failures, 0 errors, 0 skips
32
+ `;
33
+
34
+ const MIXED_OUTPUT = `Run options: --seed 12345
35
+
36
+ # Running:
37
+
38
+ EF.
39
+
40
+ Finished in 0.001s, 3000.0 runs/s, 2000.0 assertions/s.
41
+
42
+ 1) Error:
43
+ MathTest#test_does_not_divide_by_zero:
44
+ ZeroDivisionError: divided by 0
45
+ /project/test/math_test.rb:13:in 'Integer#/'
46
+ /project/test/math_test.rb:13:in 'MathTest#test_does_not_divide_by_zero'
47
+
48
+ 2) Failure:
49
+ MathTest#test_multiplies_two_numbers_correctly [/project/test/math_test.rb:9]:
50
+ Expected: 99
51
+ Actual: 12
52
+
53
+ 3 runs, 2 assertions, 1 failures, 1 errors, 0 skips
54
+ `;
55
+
56
+ describe("minitest-text parser", () => {
57
+ it("all passing → status pass, summary reflects passed count", async () => {
58
+ const result = await parser.parse(makeCtx(PASSING_OUTPUT));
59
+ expect(result.status).toBe("pass");
60
+ expect(result.summary).toBe("3 passed");
61
+ expect(result.failures).toHaveLength(0);
62
+ });
63
+
64
+ it("mix of failure and error → status fail, correct counts", async () => {
65
+ const result = await parser.parse(makeCtx(MIXED_OUTPUT, "/project"));
66
+ expect(result.status).toBe("fail");
67
+ expect(result.summary).toBe("2 failed, 1 passed");
68
+ expect(result.failures).toHaveLength(2);
69
+ });
70
+
71
+ it("assertion failure → file, line, and expected/actual message", async () => {
72
+ const result = await parser.parse(makeCtx(MIXED_OUTPUT, "/project"));
73
+ const failure = result.failures!.find((f) => f.id?.includes("multiplies"));
74
+ expect(failure?.file).toBe("test/math_test.rb");
75
+ expect(failure?.line).toBe(9);
76
+ expect(failure?.message).toBe("Expected: 99 / Actual: 12");
77
+ });
78
+
79
+ it("unexpected error → file, line from backtrace, exception message only", async () => {
80
+ const result = await parser.parse(makeCtx(MIXED_OUTPUT, "/project"));
81
+ const error = result.failures!.find((f) => f.id?.includes("divide_by_zero"));
82
+ expect(error?.file).toBe("test/math_test.rb");
83
+ expect(error?.line).toBe(13);
84
+ expect(error?.message).toBe("divided by 0");
85
+ });
86
+
87
+ it("absolute paths in output → made relative to cwd", async () => {
88
+ const result = await parser.parse(makeCtx(MIXED_OUTPUT, "/project"));
89
+ for (const f of result.failures!) {
90
+ expect(f.file).not.toContain("/project");
91
+ }
92
+ });
93
+
94
+ it("relative paths in output → kept relative", async () => {
95
+ const output = MIXED_OUTPUT.replace(/\/project\/test\//g, "./test/").replace(/\/project\/test\//g, "./test/");
96
+ const result = await parser.parse(makeCtx(output, "/project"));
97
+ const failure = result.failures!.find((f) => f.id?.includes("multiplies"));
98
+ expect(failure?.file).toBe("test/math_test.rb");
99
+ });
100
+
101
+ it("no minitest output → status error, no crash", async () => {
102
+ const result = await parser.parse(makeCtx("something completely different"));
103
+ expect(result.status).toBe("error");
104
+ });
105
+
106
+ it("empty stdout → status error, no crash", async () => {
107
+ const result = await parser.parse(makeCtx(""));
108
+ expect(result.status).toBe("error");
109
+ });
110
+ });
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { ParserModule, ParsedFailure } from "../types";
4
+
5
+ /**
6
+ * Parses minitest's default text output. No flags or reporters required —
7
+ * works with plain `ruby test/my_test.rb` or `bundle exec ruby test/my_test.rb`.
8
+ */
9
+ const parser: ParserModule = {
10
+ id: "minitest-text",
11
+ async parse(ctx) {
12
+ const output = fs.readFileSync(ctx.stdoutPath, "utf8");
13
+
14
+ // "3 runs, 2 assertions, 1 failures, 1 errors, 0 skips"
15
+ const summaryMatch = output.match(/(\d+) runs?, \d+ assertions?, (\d+) failures?, (\d+) errors?, (\d+) skips?/);
16
+ if (!summaryMatch) {
17
+ return { tool: "minitest", status: "error", summary: "no minitest output found", logPath: ctx.logPath };
18
+ }
19
+
20
+ const totalRuns = parseInt(summaryMatch[1], 10);
21
+ const failureCount = parseInt(summaryMatch[2], 10);
22
+ const errorCount = parseInt(summaryMatch[3], 10);
23
+ const skipCount = parseInt(summaryMatch[4], 10);
24
+ const totalFailed = failureCount + errorCount;
25
+ const passed = totalRuns - totalFailed - skipCount;
26
+
27
+ const failures = parseBlocks(output, ctx.cwd);
28
+
29
+ return {
30
+ tool: "minitest",
31
+ status: totalFailed > 0 ? "fail" : "pass",
32
+ summary: totalFailed > 0 ? `${totalFailed} failed, ${passed} passed` : `${passed} passed`,
33
+ failures,
34
+ logPath: ctx.logPath,
35
+ };
36
+ },
37
+ };
38
+
39
+ export default parser;
40
+
41
+ function parseBlocks(output: string, cwd: string): ParsedFailure[] {
42
+ const failures: ParsedFailure[] = [];
43
+ const lines = output.split("\n");
44
+ let i = 0;
45
+
46
+ while (i < lines.length) {
47
+ const headerMatch = lines[i].match(/^\s+\d+\) (Failure|Error):$/);
48
+ if (!headerMatch) {
49
+ i++;
50
+ continue;
51
+ }
52
+
53
+ const type = headerMatch[1];
54
+ i++;
55
+
56
+ if (type === "Failure") {
57
+ // "ClassName#method [file:line]:"
58
+ const nameMatch = lines[i]?.match(/^(.+?)\s+\[(.+):(\d+)\]:$/);
59
+ if (nameMatch) {
60
+ const [, id, rawFile, lineStr] = nameMatch;
61
+ i++;
62
+ // Message lines until blank line
63
+ const msgLines: string[] = [];
64
+ while (i < lines.length && lines[i].trim() !== "") {
65
+ msgLines.push(lines[i].trim());
66
+ i++;
67
+ }
68
+ failures.push({
69
+ id,
70
+ file: path.relative(cwd, path.resolve(cwd, rawFile)),
71
+ line: parseInt(lineStr, 10),
72
+ message: msgLines.filter(Boolean).join(" / ") || undefined,
73
+ });
74
+ }
75
+ } else {
76
+ // Error — "ClassName#method:"
77
+ const nameMatch = lines[i]?.match(/^(.+):$/);
78
+ if (nameMatch) {
79
+ const id = nameMatch[1];
80
+ i++;
81
+ // "ExceptionClass: message"
82
+ const exceptionLine = lines[i]?.trim() ?? "";
83
+ const colonIdx = exceptionLine.indexOf(": ");
84
+ const message = colonIdx !== -1 ? exceptionLine.slice(colonIdx + 2) : exceptionLine;
85
+ i++;
86
+ // First backtrace line: " file.rb:line:in 'method'"
87
+ const backtraceMatch = lines[i]?.match(/^\s+(.+\.rb):(\d+):/);
88
+ failures.push({
89
+ id,
90
+ file: backtraceMatch ? path.relative(cwd, path.resolve(cwd, backtraceMatch[1])) : undefined,
91
+ line: backtraceMatch ? parseInt(backtraceMatch[2], 10) : undefined,
92
+ message: message || undefined,
93
+ });
94
+ }
95
+ }
96
+ }
97
+
98
+ return failures;
99
+ }
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, it, expect } from "vitest";
5
+ import parser from "./pytest-json-report";
6
+ import type { RunContext } from "../types";
7
+
8
+ function makeCtx(report: object | null): RunContext {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pytest-test-"));
10
+ const artifactPath = path.join(dir, "report.json");
11
+ if (report !== null) {
12
+ fs.writeFileSync(artifactPath, JSON.stringify(report));
13
+ }
14
+ return {
15
+ command: "pytest --json-report",
16
+ argv: ["pytest", "--json-report"],
17
+ cwd: "/project",
18
+ artifactPaths: [artifactPath],
19
+ stdoutPath: path.join(dir, "stdout"),
20
+ stderrPath: path.join(dir, "stderr"),
21
+ logPath: path.join(dir, "log"),
22
+ };
23
+ }
24
+
25
+ describe("pytest-json-report parser", () => {
26
+ it("mix of passed and failed tests → correct counts, status fail", async () => {
27
+ const report = {
28
+ summary: { passed: 3 },
29
+ tests: [
30
+ { nodeid: "test_foo.py::test_a", outcome: "passed" },
31
+ { nodeid: "test_foo.py::test_b", outcome: "failed", longrepr: "AssertionError: assert False" },
32
+ { nodeid: "test_foo.py::test_c", outcome: "failed", longrepr: "AssertionError: assert 1 == 2" },
33
+ ],
34
+ };
35
+ const result = await parser.parse(makeCtx(report));
36
+ expect(result.status).toBe("fail");
37
+ expect(result.failures).toHaveLength(2);
38
+ expect(result.summary).toBe("2 failed, 3 passed");
39
+ });
40
+
41
+ it("all passing → status pass, summary reflects passed count", async () => {
42
+ const report = {
43
+ summary: { passed: 5 },
44
+ tests: [
45
+ { nodeid: "test_foo.py::test_a", outcome: "passed" },
46
+ { nodeid: "test_foo.py::test_b", outcome: "passed" },
47
+ ],
48
+ };
49
+ const result = await parser.parse(makeCtx(report));
50
+ expect(result.status).toBe("pass");
51
+ expect(result.failures).toHaveLength(0);
52
+ expect(result.summary).toBe("5 passed");
53
+ });
54
+
55
+ it("failed test with longrepr → first line surfaced as message", async () => {
56
+ const report = {
57
+ summary: { passed: 0 },
58
+ tests: [
59
+ {
60
+ nodeid: "test_foo.py::test_a",
61
+ outcome: "failed",
62
+ longrepr: "AssertionError: assert False\nfull traceback line 2\nfull traceback line 3",
63
+ },
64
+ ],
65
+ };
66
+ const result = await parser.parse(makeCtx(report));
67
+ expect(result.failures![0].message).toBe("AssertionError: assert False");
68
+ });
69
+
70
+ it("missing artifact file → throws", async () => {
71
+ const ctx = makeCtx(null);
72
+ // point artifact at a file that doesn't exist
73
+ ctx.artifactPaths = ["/nonexistent/path/report.json"];
74
+ await expect(parser.parse(ctx)).rejects.toThrow();
75
+ });
76
+ });
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs";
2
+ import type { ParserModule } from "../types";
3
+
4
+ interface PytestTest {
5
+ nodeid: string;
6
+ outcome: string;
7
+ longrepr?: string;
8
+ }
9
+
10
+ interface PytestReport {
11
+ tests?: PytestTest[];
12
+ summary?: { passed?: number };
13
+ }
14
+
15
+ const parser: ParserModule = {
16
+ id: "pytest-json-report",
17
+ async parse(ctx) {
18
+ const artifact = ctx.artifactPaths[0];
19
+ const data = JSON.parse(fs.readFileSync(artifact, "utf8")) as PytestReport;
20
+ const tests = Array.isArray(data.tests) ? data.tests : [];
21
+ const failures = tests
22
+ .filter((t) => t.outcome === "failed")
23
+ .map((t) => ({
24
+ id: t.nodeid,
25
+ file: String(t.nodeid || "").split("::")[0] || undefined,
26
+ message: typeof t.longrepr === "string" ? t.longrepr.split("\n")[0] : "test failed",
27
+ }));
28
+ const failed = failures.length;
29
+ const passed = Number(data.summary?.passed ?? 0);
30
+ return {
31
+ tool: "pytest",
32
+ status: failed > 0 ? "fail" : "pass",
33
+ summary: failed > 0 ? `${failed} failed, ${passed} passed` : `${passed} passed`,
34
+ failures,
35
+ artifact,
36
+ logPath: ctx.logPath,
37
+ };
38
+ },
39
+ };
40
+
41
+ export default parser;