Merge pull request 'The checker keeps a failed test's whole message with its cause first, and its status names the cause (issue 1901, with mesh-catalog)' (#30) from fix/1901-rebased into main
Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -523,13 +523,35 @@ func failedName(f Failure) string {
|
|||||||
return f.Package + "." + f.Test
|
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 {
|
func firstLine(s string) string {
|
||||||
for _, l := range strings.Split(s, "\n") {
|
for _, l := range strings.Split(s, "\n") {
|
||||||
if l = strings.TrimSpace(l); l != "" {
|
if l = strings.TrimSpace(l); l != "" {
|
||||||
if len(l) > 120 {
|
|
||||||
l = l[:120]
|
|
||||||
}
|
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,7 +568,7 @@ func levelFailure(t *target, level string, res Tested) *delivery.Verdict {
|
|||||||
if t.module == buildrun.NodeBuildModule && f.Package == "" {
|
if t.module == buildrun.NodeBuildModule && f.Package == "" {
|
||||||
name = res.Module
|
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:
|
default:
|
||||||
why := res.Why
|
why := res.Why
|
||||||
if why == "" {
|
if why == "" {
|
||||||
|
|||||||
@@ -37,18 +37,21 @@ func firstFailed(v delivery.Verdict) string {
|
|||||||
f := v.Failed
|
f := v.Failed
|
||||||
switch {
|
switch {
|
||||||
case f.Test != "":
|
case f.Test != "":
|
||||||
if f.Words != "" {
|
if w := firstWordsLine(f.Words); w != "" {
|
||||||
return f.Test + ": " + f.Words
|
return f.Test + ": " + w
|
||||||
}
|
}
|
||||||
return f.Test
|
return f.Test
|
||||||
case f.Machine != "" && f.Words != "":
|
case f.Machine != "" && firstWordsLine(f.Words) != "":
|
||||||
return f.Machine + ": " + f.Words
|
return f.Machine + ": " + firstWordsLine(f.Words)
|
||||||
case f.Machine != "":
|
case f.Machine != "":
|
||||||
return 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.
|
// pieces are the parts of a line, from the most to the least needed.
|
||||||
type pieces struct {
|
type pieces struct {
|
||||||
head, commits, counts, shortCounts, times, shortTimes, over, builds, composition, shortComposition, group string
|
head, commits, counts, shortCounts, times, shortTimes, over, builds, composition, shortComposition, group string
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ type Failure struct {
|
|||||||
Package string `json:"package"`
|
Package string `json:"package"`
|
||||||
Test string `json:"test,omitempty"`
|
Test string `json:"test,omitempty"`
|
||||||
Output string `json:"output,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.
|
// TestTime is one test and the seconds it took.
|
||||||
|
|||||||
Reference in New Issue
Block a user