You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user