Skip to content

Commit 91e06c4

Browse files
committed
feat: add JUnit XML output format for CI integration
Enable `--report junit` to produce JUnit XML, natively consumed by GitHub Actions, GitLab CI, and Jenkins test result viewers. - internal/report/junit.go: XML struct types + WriteJUnitReport - internal/report/junit_test.go: 10 test cases covering all paths - mdproof.go: facade export - cmd/mdproof/main.go: CLI wiring for --report junit and -o dispatch - runbooks/06-output-formats-proof.md: add JUnit steps - docs updates: README, SKILL.md, advanced-features.md
1 parent 5c8e67a commit 91e06c4

8 files changed

Lines changed: 472 additions & 17 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ mdproof sandbox api-proof.md # auto-provisions a container
128128
**For AI Agents**
129129
- Markdown is native — no framework API to learn
130130
- Self-contained — one file = commands + assertions
131-
- JSON output — `--report json` for programmatic parsing
131+
- JSON / JUnit XML output — `--report json` or `--report junit` for programmatic parsing
132132
- Built-in skill — `skills/SKILL.md` teaches your agent the full syntax
133133
- Debuggable — agent reads step, sees output, fixes it
134134

cmd/mdproof/main.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,16 @@ func main() {
3030
verbose countFlag
3131
)
3232

33-
flag.StringVar(&reportFmt, "report", "", "output format: json")
33+
flag.StringVar(&reportFmt, "report", "", "output format: json, junit")
3434
flag.BoolVar(&dryRun, "dry-run", false, "parse and classify only, don't execute")
3535
flag.BoolVar(&showVersion, "version", false, "print version and exit")
3636
flag.DurationVar(&timeout, "timeout", 0, "per-step timeout (default: 2m, or from mdproof.json)")
3737
flag.StringVar(&cliBuild, "build", "", "command to run once before all runbooks")
3838
flag.StringVar(&cliSetup, "setup", "", "command to run before each runbook")
3939
flag.StringVar(&cliTeardown, "teardown", "", "command to run after each runbook")
4040
flag.BoolVar(&failFast, "fail-fast", false, "stop after first failed step")
41-
flag.StringVar(&outputFile, "output", "", "write JSON report to file")
42-
flag.StringVar(&outputFile, "o", "", "write JSON report to file (shorthand)")
41+
flag.StringVar(&outputFile, "output", "", "write report to file")
42+
flag.StringVar(&outputFile, "o", "", "write report to file (shorthand)")
4343
flag.Var(&verbose, "v", "verbosity level (-v or -v -v)")
4444

4545
var (
@@ -250,17 +250,22 @@ func main() {
250250
}
251251
}
252252

253-
// Write JSON report to file if --output is specified.
253+
// Write report to file if --output is specified.
254254
if outputFile != "" && len(reports) > 0 {
255255
outF, err := os.Create(outputFile)
256256
if err != nil {
257257
fmt.Fprintf(os.Stderr, "error: cannot write output file: %v\n", err)
258258
os.Exit(1)
259259
}
260-
if len(reports) == 1 {
261-
mdproof.WriteJSONReport(outF, reports[0])
262-
} else {
263-
mdproof.WriteJSONReports(outF, reports)
260+
switch reportFmt {
261+
case "junit":
262+
mdproof.WriteJUnitReport(outF, reports)
263+
default:
264+
if len(reports) == 1 {
265+
mdproof.WriteJSONReport(outF, reports[0])
266+
} else {
267+
mdproof.WriteJSONReports(outF, reports)
268+
}
264269
}
265270
if err := outF.Close(); err != nil {
266271
fmt.Fprintf(os.Stderr, "error: close output file: %v\n", err)
@@ -368,7 +373,9 @@ func runAllAndReport(files []string, dryRun bool, timeout time.Duration, cfg mdp
368373
}
369374
}
370375

371-
if reportFmt != "json" && len(reports) > 0 {
376+
if reportFmt == "junit" && len(reports) > 0 {
377+
mdproof.WriteJUnitReport(os.Stdout, reports)
378+
} else if reportFmt != "json" && len(reports) > 0 {
372379
if len(reports) > 1 {
373380
mdproof.WritePlainSummary(os.Stdout, reports, verbosity)
374381
} else {

internal/report/junit.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package report
2+
3+
import (
4+
"encoding/xml"
5+
"fmt"
6+
"io"
7+
"strings"
8+
9+
"github.com/runkids/mdproof/internal/core"
10+
)
11+
12+
type junitTestsuites struct {
13+
XMLName xml.Name `xml:"testsuites"`
14+
Tests int `xml:"tests,attr"`
15+
Failures int `xml:"failures,attr"`
16+
Errors int `xml:"errors,attr"`
17+
Time string `xml:"time,attr"`
18+
Testsuites []junitTestsuite `xml:"testsuite"`
19+
}
20+
21+
type junitTestsuite struct {
22+
XMLName xml.Name `xml:"testsuite"`
23+
Name string `xml:"name,attr"`
24+
Tests int `xml:"tests,attr"`
25+
Failures int `xml:"failures,attr"`
26+
Errors int `xml:"errors,attr"`
27+
Skipped int `xml:"skipped,attr"`
28+
Time string `xml:"time,attr"`
29+
Testcases []junitTestcase `xml:"testcase"`
30+
}
31+
32+
type junitTestcase struct {
33+
XMLName xml.Name `xml:"testcase"`
34+
Name string `xml:"name,attr"`
35+
Classname string `xml:"classname,attr"`
36+
Time string `xml:"time,attr"`
37+
Failure *junitFailure `xml:"failure,omitempty"`
38+
Skipped *junitSkipped `xml:"skipped,omitempty"`
39+
SystemOut string `xml:"system-out,omitempty"`
40+
}
41+
42+
type junitFailure struct {
43+
Message string `xml:"message,attr"`
44+
Type string `xml:"type,attr"`
45+
Body string `xml:",chardata"`
46+
}
47+
48+
type junitSkipped struct {
49+
Message string `xml:"message,attr,omitempty"`
50+
}
51+
52+
// WriteJUnitReport writes the reports as JUnit XML.
53+
func WriteJUnitReport(w io.Writer, reports []core.Report) error {
54+
var totalTests, totalFailures int
55+
var totalMs int64
56+
57+
suites := make([]junitTestsuite, 0, len(reports))
58+
for _, r := range reports {
59+
suite := junitTestsuite{
60+
Name: r.Runbook,
61+
Tests: r.Summary.Total,
62+
Failures: r.Summary.Failed,
63+
Skipped: r.Summary.Skipped,
64+
Time: msToSeconds(r.DurationMs),
65+
}
66+
67+
for _, sr := range r.Steps {
68+
tc := junitTestcase{
69+
Name: sr.Step.Title,
70+
Classname: r.Runbook,
71+
Time: msToSeconds(sr.DurationMs),
72+
}
73+
74+
switch sr.Status {
75+
case core.StatusFailed:
76+
tc.Failure = buildFailure(sr)
77+
case core.StatusSkipped:
78+
tc.Skipped = &junitSkipped{}
79+
}
80+
81+
if sr.Stdout != "" {
82+
tc.SystemOut = sr.Stdout
83+
}
84+
85+
suite.Testcases = append(suite.Testcases, tc)
86+
}
87+
88+
totalTests += r.Summary.Total
89+
totalFailures += r.Summary.Failed
90+
totalMs += r.DurationMs
91+
suites = append(suites, suite)
92+
}
93+
94+
root := junitTestsuites{
95+
Tests: totalTests,
96+
Failures: totalFailures,
97+
Time: msToSeconds(totalMs),
98+
Testsuites: suites,
99+
}
100+
101+
io.WriteString(w, xml.Header)
102+
out, err := xml.MarshalIndent(root, "", " ")
103+
if err != nil {
104+
return err
105+
}
106+
_, err = w.Write(out)
107+
if err != nil {
108+
return err
109+
}
110+
_, err = io.WriteString(w, "\n")
111+
return err
112+
}
113+
114+
func buildFailure(sr core.StepResult) *junitFailure {
115+
var msg, typ string
116+
var details []string
117+
118+
for _, a := range sr.Assertions {
119+
if !a.Matched {
120+
if msg == "" {
121+
msg = a.Pattern
122+
typ = a.Type
123+
if typ == "" {
124+
typ = "AssertionError"
125+
}
126+
}
127+
line := a.Pattern
128+
if a.Detail != "" {
129+
line += " (" + a.Detail + ")"
130+
}
131+
details = append(details, line)
132+
}
133+
}
134+
135+
if msg == "" {
136+
msg = core.StepFailReason(sr)
137+
typ = "AssertionError"
138+
}
139+
140+
return &junitFailure{
141+
Message: msg,
142+
Type: typ,
143+
Body: strings.Join(details, "\n"),
144+
}
145+
}
146+
147+
func msToSeconds(ms int64) string {
148+
return fmt.Sprintf("%.3f", float64(ms)/1000)
149+
}

0 commit comments

Comments
 (0)