You've already forked agentic-coding-workflow
309 lines
8.9 KiB
Go
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
|
|
}
|