@robhowley/pi-structured-return 1.0.2 → 1.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/README.md +13 -6
- package/extensions/structured-return/src/config/registry.ts +6 -6
- package/extensions/structured-return/src/index.ts +3 -5
- package/extensions/structured-return/src/parsers/junit-xml.test.ts +58 -0
- package/extensions/structured-return/src/parsers/junit-xml.ts +50 -3
- package/package.json +1 -1
- package/skills/structured-return/SKILL.md +3 -3
- package/extensions/structured-return/src/parsers/pytest-json-report.test.ts +0 -76
- package/extensions/structured-return/src/parsers/pytest-json-report.ts +0 -41
package/README.md
CHANGED
|
@@ -15,10 +15,11 @@ Linters: 1 unused variable warning in a single file.
|
|
|
15
15
|
|
|
16
16
|
| Parser | Raw (tokens) | Structured (tokens) | Reduction | Notes |
|
|
17
17
|
|---|---|---|---|---|
|
|
18
|
-
| `
|
|
18
|
+
| `junit-xml` (go) | 446 | 58 | **87%** | verbose output with full stack trace per failure |
|
|
19
|
+
| `junit-xml` (pytest) | 446 | 71 | **84%** | verbose output with source snippets and summary footer |
|
|
19
20
|
| `vitest-json` | 348 | 75 | **78%** | source diff with inline arrows and ANSI color codes per failure |
|
|
20
21
|
| `rspec-json` | 212 | 55 | **74%** | default output with backtrace |
|
|
21
|
-
| `junit-xml` | 263 | 81 | **69%** | gradle console output with build lifecycle noise |
|
|
22
|
+
| `junit-xml` (gradle) | 263 | 81 | **69%** | gradle console output with build lifecycle noise |
|
|
22
23
|
| `minitest-text` | 168 | 59 | **65%** | default output with backtrace |
|
|
23
24
|
| `ruff-json` | 107 | 52 | **51%** | source context + help text per error |
|
|
24
25
|
| `eslint-json` | 64 | 59 | **8%** | already compact formatter |
|
|
@@ -26,13 +27,13 @@ Linters: 1 unused variable warning in a single file.
|
|
|
26
27
|
Tokens counted with `cl100k_base` (tiktoken). Linter output is more compact than test runner output to begin with, so the baseline reduction is lower. The numbers above are measured against a single file with a single error — a conservative lower bound. Both ruff and eslint repeat absolute file paths per error in their raw output, so reduction grows as violations spread across more files.
|
|
27
28
|
|
|
28
29
|
## Built-in parsers
|
|
29
|
-
- `pytest-
|
|
30
|
-
- `ruff-json` (`ruff check` only — `ruff format` has no json support)
|
|
31
|
-
- `eslint-json`
|
|
30
|
+
- `junit-xml` (JUnit XML — covers pytest `--junitxml`, Gradle, Maven, Jest with `jest-junit`, Go with `go-junit-report`, and any other tool that emits the JUnit XML schema)
|
|
32
31
|
- `vitest-json`
|
|
33
32
|
- `rspec-json`
|
|
34
33
|
- `minitest-text` (parses default minitest output — no flags or reporters needed)
|
|
35
|
-
- `
|
|
34
|
+
- `ruff-json` (`ruff check` only — `ruff format` has no json support)
|
|
35
|
+
- `eslint-json`
|
|
36
|
+
|
|
36
37
|
|
|
37
38
|
## Before / after
|
|
38
39
|
|
|
@@ -76,6 +77,12 @@ pytest test_math.py --json-report ... → cwd: project
|
|
|
76
77
|
test_math.py ZeroDivisionError: division by zero
|
|
77
78
|
```
|
|
78
79
|
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pi install npm:@robhowley/pi-structured-return
|
|
84
|
+
```
|
|
85
|
+
|
|
79
86
|
## How it works
|
|
80
87
|
|
|
81
88
|
1. The agent runs commands through `structured_return` instead of `bash`.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { ParserModule, ParserRegistration } from "../types";
|
|
3
|
-
import pytestJsonReport from "../parsers/pytest-json-report";
|
|
4
3
|
import ruffJson from "../parsers/ruff-json";
|
|
5
4
|
import eslintJson from "../parsers/eslint-json";
|
|
6
5
|
import vitestJson from "../parsers/vitest-json";
|
|
@@ -10,7 +9,6 @@ import junitXml from "../parsers/junit-xml";
|
|
|
10
9
|
import tailFallback from "../parsers/tail-fallback";
|
|
11
10
|
|
|
12
11
|
const builtIns: Record<string, ParserModule> = {
|
|
13
|
-
"pytest-json-report": pytestJsonReport,
|
|
14
12
|
"ruff-json": ruffJson,
|
|
15
13
|
"eslint-json": eslintJson,
|
|
16
14
|
"vitest-json": vitestJson,
|
|
@@ -33,10 +31,6 @@ const AUTO_DETECT: Array<{ parserId: string; detect: (argv: string[]) => boolean
|
|
|
33
31
|
argv.some((a) => a === "--output-format=json" || a === "json") &&
|
|
34
32
|
(argv.includes("--output-format=json") || argv.includes("--output-format")),
|
|
35
33
|
},
|
|
36
|
-
{
|
|
37
|
-
parserId: "pytest-json-report",
|
|
38
|
-
detect: (argv) => argv.includes("pytest") && argv.includes("--json-report"),
|
|
39
|
-
},
|
|
40
34
|
{
|
|
41
35
|
parserId: "vitest-json",
|
|
42
36
|
detect: (argv) => argv.includes("vitest") && argv.includes("--reporter=json"),
|
|
@@ -54,6 +48,12 @@ const AUTO_DETECT: Array<{ parserId: string; detect: (argv: string[]) => boolean
|
|
|
54
48
|
},
|
|
55
49
|
];
|
|
56
50
|
|
|
51
|
+
export function listParsers(): { id: string; autoDetect: boolean }[] {
|
|
52
|
+
const builtInIds = Object.keys(builtIns).filter((id) => id !== "tail-fallback");
|
|
53
|
+
const autoDetectIds = new Set(AUTO_DETECT.map((d) => d.parserId));
|
|
54
|
+
return builtInIds.map((id) => ({ id, autoDetect: autoDetectIds.has(id) }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
57
|
export async function resolveParser(opts: {
|
|
58
58
|
cwd: string;
|
|
59
59
|
parseAs?: string;
|
|
@@ -7,9 +7,7 @@ import { Text } from "@mariozechner/pi-tui";
|
|
|
7
7
|
import type { ObservedRunArgs, ParsedResult, RunContext } from "./types";
|
|
8
8
|
import { ensureRunDir, writeRunArtifacts } from "./storage/log-store";
|
|
9
9
|
import { loadProjectConfig } from "./config/project-config";
|
|
10
|
-
import { resolveParser } from "./config/registry";
|
|
11
|
-
|
|
12
|
-
const BUILT_IN_PARSER_IDS = ["pytest-json-report", "ruff-json", "eslint-json", "vitest-json", "tail-fallback"];
|
|
10
|
+
import { resolveParser, listParsers } from "./config/registry";
|
|
13
11
|
|
|
14
12
|
export default function structuredReturn(pi: ExtensionAPI) {
|
|
15
13
|
pi.registerCommand("sr-parsers", {
|
|
@@ -18,8 +16,8 @@ export default function structuredReturn(pi: ExtensionAPI) {
|
|
|
18
16
|
const lines: string[] = ["structured-return parsers", ""];
|
|
19
17
|
|
|
20
18
|
lines.push("built-in:");
|
|
21
|
-
for (const id of
|
|
22
|
-
lines.push(` ${id}`);
|
|
19
|
+
for (const { id, autoDetect } of listParsers()) {
|
|
20
|
+
lines.push(` ${id}${autoDetect ? " (auto-detect)" : ""}`);
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
const projectRegistrations = loadProjectConfig(ctx.cwd);
|
|
@@ -148,6 +148,64 @@ describe("junit-xml parser", () => {
|
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
describe("go-junit-report output", () => {
|
|
152
|
+
it("extracts panic message and file:line from system-out when failure body is empty", async () => {
|
|
153
|
+
const xml = `<testsuites tests="2" failures="1">
|
|
154
|
+
<testsuite name="math-benchmark" tests="2" failures="1" errors="0">
|
|
155
|
+
<testcase name="TestAddsTwoNumbersCorrectly" classname="math-benchmark" time="0.000"/>
|
|
156
|
+
<testcase name="TestDoesNotPanic" classname="math-benchmark" time="0.000">
|
|
157
|
+
<failure message="Failed"></failure>
|
|
158
|
+
</testcase>
|
|
159
|
+
<system-out><![CDATA[panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked]
|
|
160
|
+
goroutine 23 [running]:
|
|
161
|
+
math-benchmark.TestDoesNotPanic(0x123)
|
|
162
|
+
/project/math_test.go:22 +0x4
|
|
163
|
+
]]></system-out>
|
|
164
|
+
</testsuite>
|
|
165
|
+
</testsuites>`;
|
|
166
|
+
const result = await parser.parse(makeCtx(xml, "/project"));
|
|
167
|
+
expect(result.failures![0].message).toBe("runtime error: invalid memory address or nil pointer dereference");
|
|
168
|
+
expect(result.failures![0].file).toBe("math_test.go");
|
|
169
|
+
expect(result.failures![0].line).toBe(22);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("extracts file, line, and message from Go failure body", async () => {
|
|
173
|
+
const xml = `<testsuites tests="2" failures="1">
|
|
174
|
+
<testsuite name="math-benchmark" tests="2" failures="1" errors="0">
|
|
175
|
+
<testcase name="TestAddsTwoNumbersCorrectly" classname="math-benchmark" time="0.000"/>
|
|
176
|
+
<testcase name="TestMultipliesTwoNumbersCorrectly" classname="math-benchmark" time="0.000">
|
|
177
|
+
<failure message="Failed"><![CDATA[ math_test.go:16: expected 99, got 12]]></failure>
|
|
178
|
+
</testcase>
|
|
179
|
+
</testsuite>
|
|
180
|
+
</testsuites>`;
|
|
181
|
+
const result = await parser.parse(makeCtx(xml, "/project"));
|
|
182
|
+
expect(result.failures![0].file).toBe("math_test.go");
|
|
183
|
+
expect(result.failures![0].line).toBe(16);
|
|
184
|
+
expect(result.failures![0].message).toBe("expected 99, got 12");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("pytest --junitxml output", () => {
|
|
189
|
+
it("extracts file and line from failure body when no file attr present", async () => {
|
|
190
|
+
const xml = `<?xml version="1.0" encoding="utf-8"?>
|
|
191
|
+
<testsuites name="pytest tests">
|
|
192
|
+
<testsuite name="pytest" errors="0" failures="1" skipped="0" tests="2">
|
|
193
|
+
<testcase classname="tests.test_math" name="test_adds" time="0.000"/>
|
|
194
|
+
<testcase classname="tests.test_math" name="test_multiplies" time="0.000">
|
|
195
|
+
<failure message="assert (3 * 4) == 99">def test_multiplies():
|
|
196
|
+
> assert 3 * 4 == 99
|
|
197
|
+
E assert (3 * 4) == 99
|
|
198
|
+
|
|
199
|
+
tests/test_math.py:5: AssertionError</failure>
|
|
200
|
+
</testcase>
|
|
201
|
+
</testsuite>
|
|
202
|
+
</testsuites>`;
|
|
203
|
+
const result = await parser.parse(makeCtx(xml, "/project"));
|
|
204
|
+
expect(result.failures![0].file).toBe("tests/test_math.py");
|
|
205
|
+
expect(result.failures![0].line).toBe(5);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
151
209
|
describe("multi-suite totals", () => {
|
|
152
210
|
it("failures and errors summed across suites", async () => {
|
|
153
211
|
const xml = `<testsuites>
|
|
@@ -25,6 +25,7 @@ interface JUnitTestSuite {
|
|
|
25
25
|
failures?: string | number;
|
|
26
26
|
errors?: string | number;
|
|
27
27
|
testcase?: JUnitTestCase[];
|
|
28
|
+
"system-out"?: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
interface JUnitDocument {
|
|
@@ -38,6 +39,45 @@ const xmlParser = new XMLParser({
|
|
|
38
39
|
isArray: (name) => ["testsuite", "testcase"].includes(name),
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
/** Extract file, line, and message from failure body text.
|
|
43
|
+
* Handles pytest-style (last line: "file.py:line: ExceptionType")
|
|
44
|
+
* and Go-style (any line: " file.go:line: message"). */
|
|
45
|
+
function parseBodyLocation(text: string): { file: string; line: number; message?: string } | undefined {
|
|
46
|
+
const lines = text
|
|
47
|
+
.split("\n")
|
|
48
|
+
.map((l) => l.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
const m = line.match(/^([^:\s]+\.\w+):(\d+):\s*(.*)/);
|
|
52
|
+
if (m) return { file: m[1], line: Number(m[2]), message: m[3] || undefined };
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract panic message and file:line for a specific test from Go's system-out. */
|
|
58
|
+
function parsePanicInfo(
|
|
59
|
+
systemOut: string,
|
|
60
|
+
testName: string
|
|
61
|
+
): { message?: string; file?: string; line?: number } | undefined {
|
|
62
|
+
const lines = systemOut.split("\n");
|
|
63
|
+
const panicLine = lines.find((l) => l.trim().startsWith("panic:"));
|
|
64
|
+
if (!panicLine) return undefined;
|
|
65
|
+
const message = panicLine
|
|
66
|
+
.trim()
|
|
67
|
+
.replace(/^panic:\s*/, "")
|
|
68
|
+
.replace(/\s*\[.*\]$/, "")
|
|
69
|
+
.trim();
|
|
70
|
+
|
|
71
|
+
// Find the stack frame for this test function, then grab the file:line on the next line
|
|
72
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
73
|
+
if (lines[i].includes(`.${testName}(`)) {
|
|
74
|
+
const m = lines[i + 1].trim().match(/^([^:]+\.go):(\d+)/);
|
|
75
|
+
if (m) return { message, file: m[1].split("/").pop(), line: Number(m[2]) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { message };
|
|
79
|
+
}
|
|
80
|
+
|
|
41
81
|
function resolveFile(tc: JUnitTestCase, suite: JUnitTestSuite, cwd: string): string | undefined {
|
|
42
82
|
const raw = tc.file ?? suite.file;
|
|
43
83
|
if (raw) return path.relative(cwd, path.resolve(cwd, raw));
|
|
@@ -67,15 +107,22 @@ const parser: ParserModule = {
|
|
|
67
107
|
const problem = tc.failure ?? tc.error;
|
|
68
108
|
if (!problem) continue;
|
|
69
109
|
|
|
70
|
-
const
|
|
71
|
-
const
|
|
110
|
+
const bodyLocation = problem["#text"] ? parseBodyLocation(problem["#text"]) : undefined;
|
|
111
|
+
const panicInfo =
|
|
112
|
+
!bodyLocation && suite["system-out"] && tc.name ? parsePanicInfo(suite["system-out"], tc.name) : undefined;
|
|
113
|
+
const file =
|
|
114
|
+
(tc.file ?? suite.file)
|
|
115
|
+
? resolveFile(tc, suite, ctx.cwd)
|
|
116
|
+
: (bodyLocation?.file ?? panicInfo?.file ?? resolveFile(tc, suite, ctx.cwd));
|
|
117
|
+
const line = tc.line !== undefined ? Number(tc.line) : (bodyLocation?.line ?? panicInfo?.line);
|
|
72
118
|
const id = [file, line, tc.name].filter(Boolean).join(":");
|
|
73
119
|
|
|
74
120
|
failures.push({
|
|
75
121
|
id: id || String(failures.length),
|
|
76
122
|
file,
|
|
77
123
|
line: Number.isNaN(line) ? undefined : line,
|
|
78
|
-
message:
|
|
124
|
+
message:
|
|
125
|
+
bodyLocation?.message ?? panicInfo?.message ?? problem.message ?? problem["#text"]?.trim().split("\n")[0],
|
|
79
126
|
rule: problem.type,
|
|
80
127
|
});
|
|
81
128
|
}
|
package/package.json
CHANGED
|
@@ -25,8 +25,7 @@ Prefer better output at the source.
|
|
|
25
25
|
## Examples
|
|
26
26
|
|
|
27
27
|
### pytest
|
|
28
|
-
- `structured_return({ command: "pytest [any pytest args] --
|
|
29
|
-
- `structured_return({ command: "pytest -q" })` — fallback when json-report is unavailable
|
|
28
|
+
- `structured_return({ command: "pytest [any pytest args] --junitxml=.tmp/report.xml", parseAs: "junit-xml", artifactPaths: [".tmp/report.xml"] })` — `--junitxml` is built into pytest, no extra dependencies needed; scope, filters, markers, etc. go in `[any pytest args]`
|
|
30
29
|
|
|
31
30
|
### ruff check
|
|
32
31
|
- `structured_return({ command: "ruff check [any ruff args] --output-format=json", parseAs: "ruff-json" })` — scope, selects, ignores, etc. go in `[any ruff args]`
|
|
@@ -49,7 +48,8 @@ Prefer better output at the source.
|
|
|
49
48
|
### junit-xml
|
|
50
49
|
JUnit XML is the de facto standard output format across the JVM ecosystem and many others — Maven, Gradle, pytest (`--junitxml`), Go (`go-junit-report`), .NET (`--logger trx` with conversion), Jest (`jest-junit`), and more. If a tool can emit JUnit XML, `junit-xml` covers it without a custom parser.
|
|
51
50
|
|
|
52
|
-
- `structured_return({ command: "[tool] [args] --junitxml=.tmp/report.xml", parseAs: "junit-xml", artifactPaths: [".tmp/report.xml"] })` — pytest, nose2, and
|
|
51
|
+
- `structured_return({ command: "[tool] [args] --junitxml=.tmp/report.xml", parseAs: "junit-xml", artifactPaths: [".tmp/report.xml"] })` — pytest, nose2, and any other tool that writes to a file via `--junitxml`
|
|
52
|
+
- `structured_return({ command: "go test [any go test args] 2>&1 | go-junit-report > .tmp/report.xml", parseAs: "junit-xml", artifactPaths: [".tmp/report.xml"] })` — Go requires `go-junit-report` (`go install github.com/jstemmer/go-junit-report/v2@latest`); pipe `go test -v` output through it
|
|
53
53
|
- `structured_return({ command: "gradle test", parseAs: "junit-xml", artifactPaths: ["build/test-results/test/TEST-*.xml"] })` — Gradle writes one XML per test class; pass all matching paths
|
|
54
54
|
- `structured_return({ command: "mvn test", parseAs: "junit-xml", artifactPaths: ["target/surefire-reports/TEST-*.xml"] })` — Maven surefire same pattern
|
|
55
55
|
|
|
@@ -1,76 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
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;
|