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 }