193 lines
6.5 KiB
Go
193 lines
6.5 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")
|
|
var cancelled []Failure // failed because a suite around them did: said after the failure that carries the cause
|
|
prev := 0 // the line after the last test block, passing or not: what the run wrote since belongs to the next test
|
|
for i := 0; i < len(all); i++ {
|
|
name, ok := tapNotOk(all[i])
|
|
if !ok {
|
|
if tapOk(all[i]) {
|
|
i = tapBlockEnd(all, i) - 1
|
|
prev = i + 1
|
|
}
|
|
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, at := prev, i
|
|
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:at] {
|
|
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, "/")+"/")
|
|
}
|
|
f := Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause)}
|
|
if fields["failureType"] == "cancelledByParent" {
|
|
cancelled = append(cancelled, f)
|
|
continue
|
|
}
|
|
failed = append(failed, f)
|
|
}
|
|
if failed = append(failed, cancelled...); 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
|
|
}
|
|
|
|
// tapOk says a line is a passing test's "ok N - name", indented or not.
|
|
func tapOk(l string) bool {
|
|
rest, ok := strings.CutPrefix(strings.TrimSpace(l), "ok ")
|
|
return ok && rest != "" && rest[0] >= '0' && rest[0] <= '9'
|
|
}
|
|
|
|
// tapBlockEnd is the line after the test line at i and its YAML block ("---" to "...").
|
|
func tapBlockEnd(all []string, i int) int {
|
|
j := i + 1
|
|
if j >= len(all) || strings.TrimSpace(all[j]) != "---" {
|
|
return j
|
|
}
|
|
for j++; j < len(all); j++ {
|
|
if strings.TrimSpace(all[j]) == "..." {
|
|
return j + 1
|
|
}
|
|
}
|
|
return j
|
|
}
|
|
|
|
// 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
|
|
}
|