Files
mesh-sdk/go/buildstep/tap.go
T
jschoubben e40dd0f56a One failure contract for a build step's failed test, its parsers and a conformance kit every build module runs (issue 1901)
Each build module parsed its own test runner's output, so a fix in one never reached the others: a failed node --test
run was worded by a passing line of its first file. buildstep.Failure names the test, its file, its cause and its own
output; ReadGoTest (moved from mesh-build-go) and ReadTAP fill it; package conformance holds every build module to it
with runner output captured with the build nodes' own toolchains.
2026-10-26 19:45:35 +01:00

163 lines
5.6 KiB
Go

package buildstep
// The reader of `node --test` under its TAP reporter (issue 1901's thorough fix; first written in mesh-build-node by
// fix-1901ts): each failing test with its name, its file, its cause and its own block, never a passing line of an
// earlier file, which is what node's default reporter left the first fifteen lines of.
import (
"regexp"
"strings"
)
// NodeTestOptions is the NODE_OPTIONS a build module runs a package's test script with: node's TAP reporter, written to
// the file dest, so whatever the script prints on its own output the report is whole and machine-readable. A script
// that names a reporter of its own makes node refuse the run loudly ("--test-reporter must match the number of
// --test-reporter-destination"), never quietly.
func NodeTestOptions(dest string) string {
return "--test-reporter=tap --test-reporter-destination=" + dest
}
var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") }
// ReadTAP reads the failing tests of a `node --test` run: report is what the TAP reporter wrote (NodeTestOptions'
// file), console the script's own stdout and stderr, script the name of the script that ran and dir the package's
// directory, which a test's file is made relative to. A suite that failed only because a test inside it did is not
// reported twice. A test file that did not load (a syntax error, a crash, an error after its test ended) takes its
// cause from the lines node wrote before it. When the report holds no failing test (node itself failed, or the script
// failed before its tests ran: a build, a reporter refused) the failure is the console's (ConsoleFailure).
func ReadTAP(pkg, script, dir, report, console string) []Failure {
var failed []Failure
all := strings.Split(stripANSI(strings.ReplaceAll(report, "\r\n", "\n")), "\n")
prev := 0 // the line after the last test block: what the run wrote since belongs to the next test
for i := 0; i < len(all); i++ {
name, ok := tapNotOk(all[i])
if !ok {
continue
}
indent := len(all[i]) - len(strings.TrimLeft(all[i], " "))
block := []string{strings.TrimSpace(all[i])}
fields := map[string]string{}
var key string
inYAML := false
j := i + 1
for ; j < len(all); j++ {
t := strings.TrimSpace(all[j])
if !inYAML {
if t == "---" {
inYAML = true
continue
}
break
}
if t == "..." {
j++
break
}
block = append(block, strings.TrimRight(all[j], " \t"))
if k, v, found := tapKey(all[j], indent); found {
key = k
if v != "|-" && v != "|" {
fields[k] = strings.Trim(v, "'")
}
} else if key != "" && fields[key] == "" && t != "" {
fields[key] = t // the first line of a block scalar
}
}
from := prev
i, prev = j-1, j
if fields["failureType"] == "subtestsFailed" {
continue
}
cause := fields["error"]
if cause == "test failed" || cause == "" {
// a file that did not load, or an error after a test ended: node says only "test failed" and wrote the
// error as comment lines before it
var own []string
for _, l := range all[from:i] {
if t := strings.TrimSpace(l); strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "# Subtest") {
own = append(own, strings.TrimPrefix(t, "# "))
}
}
for _, l := range own {
if strings.Contains(l, "Error") {
cause = l
break
}
}
if cause == "" && len(own) > 0 {
cause = own[0]
}
block = append(own, block...)
}
if cause == "" {
cause = fields["failureType"]
}
if cause == "" {
cause = "the test failed and node said no error"
}
cause = CutLine(cause)
file := strings.TrimPrefix(fields["location"], "file://")
if dir != "" {
file = strings.TrimPrefix(file, strings.TrimSuffix(dir, "/")+"/")
}
failed = append(failed, Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause)})
}
if len(failed) > 0 {
return failed
}
var comments []string
for _, l := range all {
if t := strings.TrimSpace(l); strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "# Subtest") && !tapCount(t) {
comments = append(comments, strings.TrimPrefix(t, "# "))
}
}
return []Failure{ConsoleFailure(pkg, script, strings.Join(comments, "\n")+"\n"+console)}
}
// tapCount says a comment is TAP's closing count ("# tests 3", "# pass 2", "# duration_ms 1.2").
func tapCount(t string) bool {
for _, w := range []string{"tests", "suites", "pass", "fail", "cancelled", "skipped", "todo", "duration_ms"} {
if strings.HasPrefix(t, "# "+w+" ") {
return true
}
}
return false
}
// tapNotOk is the test name of a "not ok N - name" line, indented or not; a TODO or SKIP directive fails nothing.
func tapNotOk(l string) (string, bool) {
t := strings.TrimSpace(l)
rest, ok := strings.CutPrefix(t, "not ok ")
if !ok {
return "", false
}
if u := strings.ToUpper(rest); strings.Contains(u, " # TODO") || strings.Contains(u, " # SKIP") {
return "", false
}
if _, after, found := strings.Cut(rest, " - "); found {
rest = after
}
return strings.TrimSpace(rest), true
}
// tapKey is "key: value" of a field of a TAP block, whose lines sit two spaces deeper than their "not ok" line; a deeper
// line is part of a block scalar (an error's lines, a stack).
func tapKey(l string, indent int) (key, val string, ok bool) {
t := strings.TrimLeft(l, " ")
if len(l)-len(t) != indent+2 {
return "", "", false
}
k, v, found := strings.Cut(t, ": ")
if !found {
if k, found = strings.CutSuffix(t, ":"); !found {
return "", "", false
}
}
if k == "" || strings.ContainsAny(k, " ()/") {
return "", "", false
}
return k, strings.TrimSpace(v), true
}