package buildstep // The failure contract of a build step's test level (novox/hq issue 1901, its thorough fix): what a build module says of // each test that failed, the same for every language. Each build module used to parse its own test runner's output, and // a fix in one never reached the others: a failed Go test kept a t.Logf line, a failed node --test run kept a passing // '✔' line of its first file. The parsers that fill the contract live here (gotest.go, tap.go) and the conformance kit // (package conformance) holds every build module to them with captured runner output. import ( "fmt" "strings" "git.novox.be/novox/mesh-sdk/go/sdkversion" ) // Failure is one test that failed, or a package or file that failed with no test to name (a build that failed, a test // program that panicked, a test file that did not load). The JSON names are the ones mesh-build-go and the checker have // always used; File is new and an older reader ignores it. type Failure struct { // Package is the Go package's import path, or the TypeScript package's directory in the workspace. Package string `json:"package"` // Test names the failing test, or the tests that were running when the program stopped; "" when none can be named. Test string `json:"test,omitempty"` // File is where the failing test is (a Go test's file and line is in its Cause; node names the test's file:line:col). File string `json:"file,omitempty"` // Cause is the one line that says why: the assertion's or the error's first line, a "panic:" line, a build's first // error, or words saying how the program stopped. **Never empty, and never a passing line** (PassingLine). Cause string `json:"cause,omitempty"` // Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads // only its first line. Output string `json:"output,omitempty"` } // OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long // assertion message and stays far under a bus message's or a verdict's bound. const ( OutputBytes = 4096 LineBytes = 1024 ) // By names a build module and the SDK it was built with, for a result's "by": the checker names it when a failure hides // its cause ("the build module hid the failure ()"). func By(module string) string { return module + " with mesh-sdk " + sdkversion.Version() } // PassingLine says a line is a test runner's word for a test or a package that passed: node's '✔', TAP's "ok N" (at any // indent), go test's "ok \t", "PASS" and "--- PASS". A line of a test's own that starts "ok " is none of these. Such a line is never a failure's cause, nor part of a failure's output. func PassingLine(l string) bool { if strings.HasPrefix(l, "ok \t") { // go test's package line, at column 0 return true } t := strings.TrimSpace(l) switch { case t == "PASS", strings.HasPrefix(t, "✔"), strings.HasPrefix(t, "--- PASS"): return true } return tapOk(t) } // Hidden says how a failure hides why it failed, or "" when it does not: it has no cause, or its cause is a passing line. // The checker says it loudly (issue 1901): a build module that sends such a failure does not keep the contract. func Hidden(f Failure) string { c := strings.TrimSpace(f.Cause) switch { case c == "": return "it sent no cause" case PassingLine(c): return fmt.Sprintf("its cause is a passing line (%q)", CutLine(c)) } return "" } // Clip joins the last lines of a failure's output that fit OutputBytes, each cut to LineBytes, and says how many earlier // lines it left out. **Its first line is the cause**, put ahead when the kept lines do not start with it, or, with no // cause and lines left out, the first non-empty line, then the marker: an older checker reads only the first line. // Passing lines are dropped. func Clip(lines []string, cause string) string { own := make([]string, 0, len(lines)) for _, l := range lines { if !PassingLine(l) { own = append(own, l) } } lines = own size, from := 0, len(lines) for from > 0 { n := len(CutLine(lines[from-1])) + 1 if size+n > OutputBytes && from < len(lines) { break } size += n from-- } kept := make([]string, 0, len(lines)-from+2) head := strings.TrimSpace(cause) for i := 0; head == "" && i < from; i++ { head = strings.TrimSpace(lines[i]) } if head != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != head) { kept = append(kept, CutLine(head)) } if from > 0 { kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from)) } for _, l := range lines[from:] { kept = append(kept, CutLine(l)) } return strings.Join(kept, "\n") } // CutLine cuts a line to LineBytes, on a character boundary. func CutLine(l string) string { if len(l) <= LineBytes { return l } return strings.ToValidUTF8(l[:LineBytes], "") + "..." } // ConsoleFailure is the failure of a command that is no test runner (go vet, gofmt, tsc, a build script): its cause is the // first line that names an error (tsc's "error TS", go vet's "file.go:12:3: ..."), else the first line that is not a // heading ("# ", npm's "> script"), and its output the command's lines without passing ones. func ConsoleFailure(pkg, test, out string) Failure { var lines []string for _, l := range strings.Split(strings.ReplaceAll(stripANSI(out), "\r\n", "\n"), "\n") { if t := strings.TrimSpace(l); t != "" && !PassingLine(t) { lines = append(lines, strings.TrimRight(l, " \t")) } } cause := "" for _, l := range lines { if t := strings.TrimSpace(l); strings.Contains(t, "error") || strings.Contains(t, "Error") || goPosition.MatchString(t) { cause = t break } } for _, l := range lines { if t := strings.TrimSpace(l); cause == "" && !strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "> ") && !strings.HasPrefix(t, "npm ") { cause = t } } if cause == "" { cause = test + " failed and wrote nothing that says why" } cause = CutLine(cause) return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)} }