diff --git a/modules/mesh-delivery-checker/internal/check/failure_words_test.go b/modules/mesh-delivery-checker/internal/check/failure_words_test.go new file mode 100644 index 0000000..ef95b47 --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/failure_words_test.go @@ -0,0 +1,73 @@ +package check + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The failure the build module reported for group r4g on 2026-10-23: the test logged a line, then failed with a multi-line +// t.Fatalf. Its output is what the build module now sends (its last lines, with the error line as Cause). +func fatalAfterLog() Failure { + return Failure{ + Package: "github.com/novox/mesh-controller/internal/broker", Test: "TestTheCataloguesDerivedSubjectsMeet", + Cause: "3 subscription(s) derive to a subject no emitter owns:", + Output: " agreement_catalogue_test.go:130: judged against /var/lib/mesh-build-go-work/id/beside/mesh-catalog as cloned\n" + + " agreement_catalogue_test.go:186: 3 subscription(s) derive to a subject no emitter owns:\n" + + " a.b.c\n d.e.f\n g.h.i\n--- FAIL: TestTheCataloguesDerivedSubjectsMeet (0.00s)", + } +} + +func TestAFailedTestsVerdictKeepsItsWholeFailureMessage(t *testing.T) { // 1901/AC1 + v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}}) + for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} { + if !strings.Contains(v.Failed.Words, want) { + t.Errorf("words lack %q: %q", want, v.Failed.Words) + } + } + if len(v.Failed.Words) > MaxWords+200 { + t.Errorf("words are %d bytes, bound %d", len(v.Failed.Words), MaxWords) + } +} + +func TestTheStatusLineNamesTheFailureNotTheFirstLoggedLine(t *testing.T) { // 1901/AC2 + v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}}) + got := firstFailed(*v) + if !strings.Contains(got, "3 subscription(s) derive to a subject no emitter owns:") || strings.Contains(got, "judged against") || strings.Contains(got, "\n") { + t.Errorf("firstFailed = %q", got) + } + // With no cause told apart (an older build module), the words still start at the first line, as before. + f := fatalAfterLog() + f.Cause = "" + v = levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}}) + if !strings.HasPrefix(v.Failed.Words, "agreement_catalogue_test.go:130") { + t.Errorf("words = %q", v.Failed.Words) + } +} + +func TestWordsLongerThanTheBoundKeepTheirLastLines(t *testing.T) { // 1901/AC1 + f := Failure{Output: strings.Repeat("log line of some length\n", 500) + "the cause is here"} + v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}}) + if !strings.HasSuffix(v.Failed.Words, "the cause is here") || len(v.Failed.Words) > MaxWords+200 { + t.Errorf("%d bytes, ends %q", len(v.Failed.Words), v.Failed.Words[len(v.Failed.Words)-30:]) + } + _ = delivery.VerdictFail +} + +// An older build module sends no "cause": its answer is still accepted and the verdict's words are its output, as before. +func TestAnOlderBuildModulesFailureIsAcceptedWithItsWholeOutput(t *testing.T) { // 1901/AC3 + old := `{"package":"p","test":"TestX","output":"x_test.go:1: logged\nx_test.go:2: the old kept lines"}` + var f Failure + if err := json.Unmarshal([]byte(old), &f); err != nil || f.Cause != "" { + t.Fatalf("1901/AC3: err = %v, cause = %q", err, f.Cause) + } + v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}}) + if v.Failed.Words != "x_test.go:1: logged\nx_test.go:2: the old kept lines" { + t.Errorf("1901/AC3: words = %q", v.Failed.Words) + } + if got := firstFailed(*v); !strings.Contains(got, "logged") || strings.Contains(got, "\n") { + t.Errorf("1901/AC3: status = %q", got) + } +} diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index a6f134c..079d688 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -525,13 +525,35 @@ func failedName(f Failure) string { return f.Package + "." + f.Test } -// firstLine is the first non-empty line of a failure's output, shortened. +// MaxWords is the most a failed test's words in a verdict hold (issue 1901). **The words keep the failure's last lines**, +// where a test says why it failed: the first line is often something it logged before (the group r4g verdict kept a +// "judged against" line and lost the t.Fatalf after it), and the 120 characters kept before cut a docker command's echo +// before docker's own error. Four kilobytes holds a long message and is small beside a verdict's other parts. +const MaxWords = 4096 + +// failureWords is a failure's words: its output's last lines up to MaxWords, with the line the build module told apart as +// the test's error first when the output does not start with it, so the first line of the words is the cause and the +// status line, which is cut short, names it. +func failureWords(f Failure) string { + out := strings.TrimSpace(f.Output) + if len(out) > MaxWords { + out = out[len(out)-MaxWords:] + // Start on a whole line, and a whole character. + if i := strings.IndexByte(out, '\n'); i >= 0 { + out = "... " + strings.TrimLeft(out[i:], "\n") + } + out = strings.ToValidUTF8(out, "") + } + if c := strings.TrimSpace(f.Cause); c != "" && !strings.HasPrefix(firstLine(out), c) { + out = c + "\n" + out + } + return out +} + +// firstLine is the first non-empty line of a failure's words. func firstLine(s string) string { for _, l := range strings.Split(s, "\n") { if l = strings.TrimSpace(l); l != "" { - if len(l) > 120 { - l = l[:120] - } return l } } @@ -548,7 +570,7 @@ func levelFailure(t *target, level string, res Tested) *delivery.Verdict { if t.module == buildrun.NodeBuildModule && f.Package == "" { name = res.Module } - v.Failed = delivery.FailedFirst{Test: level + ": " + name, Words: firstLine(f.Output)} + v.Failed = delivery.FailedFirst{Test: level + ": " + name, Words: failureWords(f)} default: why := res.Why if why == "" { diff --git a/modules/mesh-delivery-checker/internal/check/line.go b/modules/mesh-delivery-checker/internal/check/line.go index 3e5afc0..f847823 100644 --- a/modules/mesh-delivery-checker/internal/check/line.go +++ b/modules/mesh-delivery-checker/internal/check/line.go @@ -37,18 +37,21 @@ func firstFailed(v delivery.Verdict) string { f := v.Failed switch { case f.Test != "": - if f.Words != "" { - return f.Test + ": " + f.Words + if w := firstWordsLine(f.Words); w != "" { + return f.Test + ": " + w } return f.Test - case f.Machine != "" && f.Words != "": - return f.Machine + ": " + f.Words + case f.Machine != "" && firstWordsLine(f.Words) != "": + return f.Machine + ": " + firstWordsLine(f.Words) case f.Machine != "": return f.Machine } - return f.Words + return firstWordsLine(f.Words) } +// firstWordsLine is the first line of a failure's words: the words are several lines (issue 1901) and the status line one. +func firstWordsLine(w string) string { return firstLine(w) } + // pieces are the parts of a line, from the most to the least needed. type pieces struct { head, commits, counts, shortCounts, times, shortTimes, over, builds, composition, shortComposition, group string diff --git a/modules/mesh-delivery-checker/internal/check/results.go b/modules/mesh-delivery-checker/internal/check/results.go index 8336247..275e821 100644 --- a/modules/mesh-delivery-checker/internal/check/results.go +++ b/modules/mesh-delivery-checker/internal/check/results.go @@ -59,6 +59,8 @@ type Failure struct { Package string `json:"package"` Test string `json:"test,omitempty"` Output string `json:"output,omitempty"` + // Cause is the first line of what the test reported as its error, when the build module told it apart (issue 1901). + Cause string `json:"cause,omitempty"` } // TestTime is one test and the seconds it took.