Files
agentic-coding-workflow/internal/run/exec_parsers.go
T
2026-06-22 08:55:57 +08:00

309 lines
8.9 KiB
Go

package run
import (
"bufio"
"encoding/json"
"encoding/xml"
"errors"
"strings"
)
// exec_parsers.go 把常见测试输出(go test -json / JUnit XML / vitest --reporter=json)
// 归一化为 TestSummary。所有解析器都是防御性的:永不 panic;无法解析时返回 error,
// 由 RunTests 折成 ParseError(始终保留原始输出)。
// parseTestOutput 按 format 分派;format=="" 或 "auto" 时按内容嗅探。
func parseTestOutput(format, stdout, stderr string) (TestSummary, error) {
combined := stdout
if strings.TrimSpace(combined) == "" {
combined = stderr
}
switch strings.ToLower(strings.TrimSpace(format)) {
case "gotest":
return parseGoTestJSON([]byte(combined))
case "junit":
return parseJUnitXML([]byte(combined))
case "vitest":
return parseVitestJSON([]byte(combined))
case "", "auto":
return sniffAndParse(combined)
default:
return TestSummary{}, errors.New("unknown test format: " + format)
}
}
// sniffAndParse 按首个非空 token 推断框架:JSON 行 + Action 字段 → gotest;
// JSON 对象含 numPassedTests → vitest;'<testsuite' → junit。
func sniffAndParse(s string) (TestSummary, error) {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return TestSummary{}, errors.New("empty test output")
}
switch trimmed[0] {
case '<':
if strings.Contains(trimmed, "<testsuite") {
return parseJUnitXML([]byte(s))
}
case '{', '[':
// vitest 是单个 JSON 对象(含 numPassedTests);go test -json 是 JSONL(每行含 Action)。
if strings.Contains(trimmed, "numPassedTests") || strings.Contains(trimmed, "testResults") {
return parseVitestJSON([]byte(s))
}
if strings.Contains(trimmed, "\"Action\"") {
return parseGoTestJSON([]byte(s))
}
// 退而求其次:单对象当 vitest,多行当 gotest。
if strings.HasPrefix(trimmed, "{\"Time\"") || strings.HasPrefix(trimmed, "{\"Action\"") {
return parseGoTestJSON([]byte(s))
}
return parseVitestJSON([]byte(s))
}
return TestSummary{}, errors.New("could not sniff test output format")
}
// ===== go test -json =====
type goTestEvent struct {
Action string `json:"Action"`
Package string `json:"Package"`
Test string `json:"Test"`
Output string `json:"Output"`
Elapsed float64 `json:"Elapsed"`
}
// parseGoTestJSON 解析 `go test -json` 的 JSONL 流。只统计具体 test(Test 非空)的
// pass/fail/skip;包级 action 不计入用例数。失败用例汇集其 output 作为 message。
func parseGoTestJSON(data []byte) (TestSummary, error) {
if len(strings.TrimSpace(string(data))) == 0 {
return TestSummary{}, errors.New("empty go test output")
}
sum := TestSummary{Framework: "gotest"}
type key struct{ pkg, test string }
outputs := map[key][]string{}
var totalElapsed float64
var anyEvent bool
sc := bufio.NewScanner(strings.NewReader(string(data)))
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || line[0] != '{' {
continue
}
var ev goTestEvent
if err := json.Unmarshal([]byte(line), &ev); err != nil {
continue // 非 JSON 行(如裸 build 错误)跳过,保持防御性。
}
anyEvent = true
if ev.Test == "" {
// 包级事件:累计耗时。
if ev.Action == "pass" || ev.Action == "fail" {
totalElapsed += ev.Elapsed
}
continue
}
k := key{ev.Package, ev.Test}
switch ev.Action {
case "output":
outputs[k] = append(outputs[k], ev.Output)
case "pass":
sum.Passed++
case "skip":
sum.Skipped++
case "fail":
sum.Failed++
sum.Failures = append(sum.Failures, TestFailure{
Name: ev.Test,
Package: ev.Package,
Message: strings.TrimSpace(strings.Join(outputs[k], "")),
})
}
}
if err := sc.Err(); err != nil {
return sum, err
}
if !anyEvent {
return sum, errors.New("no go test -json events found")
}
sum.Total = sum.Passed + sum.Failed + sum.Skipped
sum.DurationMs = int64(totalElapsed * 1000)
return sum, nil
}
// ===== JUnit XML =====
type junitTestSuites struct {
XMLName xml.Name `xml:"testsuites"`
Suites []junitTestSuite `xml:"testsuite"`
}
type junitTestSuite struct {
XMLName xml.Name `xml:"testsuite"`
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Errors int `xml:"errors,attr"`
Skipped int `xml:"skipped,attr"`
Time float64 `xml:"time,attr"`
TestCases []junitTestCase `xml:"testcase"`
// 嵌套 testsuite(部分工具输出 testsuites>testsuite>testsuite)。
Nested []junitTestSuite `xml:"testsuite"`
}
type junitTestCase struct {
Name string `xml:"name,attr"`
ClassName string `xml:"classname,attr"`
Time float64 `xml:"time,attr"`
Failure *junitDetail `xml:"failure"`
Error *junitDetail `xml:"error"`
Skipped *junitSkipped `xml:"skipped"`
}
type junitDetail struct {
Message string `xml:"message,attr"`
Body string `xml:",chardata"`
}
type junitSkipped struct {
Message string `xml:"message,attr"`
}
// parseJUnitXML 解析 JUnit XML(顶层 testsuites 或裸 testsuite)。按 testcase 逐个
// 统计,failure/error → failed,skipped → skipped,其余 → passed。
func parseJUnitXML(data []byte) (TestSummary, error) {
if strings.TrimSpace(string(data)) == "" {
return TestSummary{}, errors.New("empty junit output")
}
sum := TestSummary{Framework: "junit"}
var suites []junitTestSuite
var root junitTestSuites
if err := xml.Unmarshal(data, &root); err == nil && len(root.Suites) > 0 {
suites = root.Suites
} else {
var single junitTestSuite
if err := xml.Unmarshal(data, &single); err != nil {
return sum, err
}
suites = []junitTestSuite{single}
}
var totalTime float64
var walk func(s junitTestSuite)
walk = func(s junitTestSuite) {
totalTime += s.Time
for _, tc := range s.TestCases {
sum.Total++
pkg := tc.ClassName
switch {
case tc.Failure != nil || tc.Error != nil:
sum.Failed++
d := tc.Failure
if d == nil {
d = tc.Error
}
msg := strings.TrimSpace(d.Message)
if msg == "" {
msg = strings.TrimSpace(d.Body)
}
sum.Failures = append(sum.Failures, TestFailure{Name: tc.Name, Package: pkg, Message: msg})
case tc.Skipped != nil:
sum.Skipped++
default:
sum.Passed++
}
}
for _, n := range s.Nested {
walk(n)
}
}
for _, s := range suites {
walk(s)
}
if sum.Total == 0 {
return sum, errors.New("no junit testcases found")
}
sum.DurationMs = int64(totalTime * 1000)
return sum, nil
}
// ===== vitest --reporter=json =====
type vitestReport struct {
NumTotalTests *int `json:"numTotalTests"`
NumPassedTests *int `json:"numPassedTests"`
NumFailedTests *int `json:"numFailedTests"`
NumPendingTests *int `json:"numPendingTests"`
StartTime *int64 `json:"startTime"`
TestResults []vitestFileResult `json:"testResults"`
}
type vitestFileResult struct {
Name string `json:"name"`
AssertionResults []vitestAssertion `json:"assertionResults"`
}
type vitestAssertion struct {
Title string `json:"title"`
FullName string `json:"fullName"`
Status string `json:"status"` // passed|failed|skipped|pending
FailureMessages []string `json:"failureMessages"`
Duration float64 `json:"duration"`
}
// parseVitestJSON 解析 vitest(jest 兼容)的 JSON 报告。优先使用顶层计数;
// 同时遍历 testResults 收集失败用例名与首条 failureMessage。
func parseVitestJSON(data []byte) (TestSummary, error) {
if strings.TrimSpace(string(data)) == "" {
return TestSummary{}, errors.New("empty vitest output")
}
var rep vitestReport
if err := json.Unmarshal(data, &rep); err != nil {
return TestSummary{}, err
}
sum := TestSummary{Framework: "vitest"}
var durMs float64
for _, fr := range rep.TestResults {
for _, a := range fr.AssertionResults {
switch a.Status {
case "failed":
sum.Failed++
msg := ""
if len(a.FailureMessages) > 0 {
msg = strings.TrimSpace(a.FailureMessages[0])
}
name := a.FullName
if name == "" {
name = a.Title
}
sum.Failures = append(sum.Failures, TestFailure{Name: name, Package: fr.Name, Message: msg})
case "skipped", "pending", "todo":
sum.Skipped++
case "passed":
sum.Passed++
}
durMs += a.Duration
}
}
// 顶层计数优先(更可靠);没有就用遍历得到的计数。
if rep.NumPassedTests != nil {
sum.Passed = *rep.NumPassedTests
}
if rep.NumFailedTests != nil {
sum.Failed = *rep.NumFailedTests
}
if rep.NumPendingTests != nil {
sum.Skipped = *rep.NumPendingTests
}
if rep.NumTotalTests != nil {
sum.Total = *rep.NumTotalTests
} else {
sum.Total = sum.Passed + sum.Failed + sum.Skipped
}
if sum.Total == 0 && len(rep.TestResults) == 0 && rep.NumTotalTests == nil {
return sum, errors.New("not a vitest report")
}
sum.DurationMs = int64(durMs)
return sum, nil
}