From 86d79f7872c6c1d3c184ef122bd0682f7b3f40b0 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 20:53:50 +0000 Subject: [PATCH] Add the Go build module's digest step, its tests and the level judging (issues 1291, 1290, 1486), part 10 of 20 --- .../mesh-build-go/cmd/mesh-build-go/digest.go | 278 ++++++++++++++++++ .../cmd/mesh-build-go/digest_test.go | 195 ++++++++++++ .../mesh-build-go/cmd/mesh-build-go/levels.go | 259 ++++++++++++++++ 3 files changed, 732 insertions(+) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/digest.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/digest_test.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/levels.go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/digest.go b/modules/mesh-build-go/cmd/mesh-build-go/digest.go new file mode 100644 index 00000000..0adff4f4 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/digest.go @@ -0,0 +1,278 @@ +package main + +// The digest step (novox/hq ADR 0445 §5, ADR 0444; issue 1291): for each package of a workspace that has tests at a +// level, one digest of everything those tests read. The checker keys the verdict it keeps by it, so a package whose +// digest is unchanged is not run again. The tool answers in its call (it is short, ADR 0469 §2). + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// DigestVersion is the form of the digest's text: changed whenever what the digest hashes changes in kind. +const DigestVersion = "1" + +// goPackage is the part of `go list -json` a digest reads. +type goPackage struct { + ImportPath string + Dir string + ForTest string + Standard bool + DepOnly bool + Deps []string + Module *goModule + // The files a package is built and tested from. + GoFiles, CgoFiles, CFiles, CXXFiles, MFiles, HFiles, FFiles, SFiles, SwigFiles, SwigCXXFiles, SysoFiles []string + TestGoFiles, XTestGoFiles []string + EmbedFiles, TestEmbedFiles, XTestEmbedFiles []string +} + +type goModule struct { + Path, Version, Sum string + Main bool + Replace *goModule + Dir string + GoVersion string +} + +func (p *goPackage) files() []string { + var all []string + for _, l := range [][]string{p.GoFiles, p.CgoFiles, p.CFiles, p.CXXFiles, p.MFiles, p.HFiles, p.FFiles, p.SFiles, p.SwigFiles, + p.SwigCXXFiles, p.SysoFiles, p.TestGoFiles, p.XTestGoFiles, p.EmbedFiles, p.TestEmbedFiles, p.XTestEmbedFiles} { + all = append(all, l...) + } + sort.Strings(all) + return slicesCompact(all) +} + +func slicesCompact(s []string) []string { + out := s[:0:0] + for i, v := range s { + if i == 0 || v != s[i-1] { + out = append(out, v) + } + } + return out +} + +// hasTests says whether the package has test files at the level go list was asked for. +func (p *goPackage) hasTests() bool { return len(p.TestGoFiles)+len(p.XTestGoFiles) > 0 } + +// DigestResult is what a digest step answers: one digest per package that has tests at the level, and the packages +// that have none. +type DigestResult struct { + Level string `json:"level"` + Packages map[string]string `json:"packages"` + WithoutTests []string `json:"without_tests,omitempty"` +} + +func (m *Module) digestTool() StepTool { + return StepTool{ + Name: "build_go_digest", + Description: "One digest per package of a fetched workspace, for one level, of everything that package's tests read: " + + "the files of each package the tests reach in the module's own tree, the version and checksum of each module " + + "outside it, the package's test data and embedded files, the Go version, the build tags and, at the " + + "integration level, the postgres major and nats-server versions. Answers in the call.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"}, + "level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"}, + }, + "required": []string{"workspace", "level"}, + }, + Run: m.digest, + } +} + +func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) { + workspace, _ := args["workspace"].(string) + level, _ := args["level"].(string) + if !workspaceOK.MatchString(workspace) { + return nil, fmt.Errorf("%q is not a workspace id", workspace) + } + tree := filepath.Join(m.Work, workspace, "tree") + if _, err := os.Stat(tree); err != nil { + return nil, fmt.Errorf("no workspace %s: it was never fetched here, or it is older than a day and was removed", workspace) + } + var tags []string + header := []string{"digest " + DigestVersion, "level " + level, "go " + m.Recipe.Go} + switch level { + case LevelUnit: + case LevelIntegration: + if _, err := m.checkPostgres(ctx); err != nil { + return nil, err + } + tags = []string{"integration"} + header = append(header, "postgres "+strconv.Itoa(m.Recipe.PostgresMajor), "nats-server "+m.Recipe.NatsServer) + default: + return nil, fmt.Errorf("the level %q is neither unit nor integration", level) + } + header = append(header, "tags "+strings.Join(tags, ",")) + + dirs, err := m.goModDirs(ctx, tree) + if err != nil { + return nil, err + } + res := DigestResult{Level: level, Packages: map[string]string{}} + for _, d := range dirs { + pkgs, err := m.listPackages(ctx, tree, d, tags) + if err != nil { + return nil, err + } + if err := digestModule(&res, pkgs, tree, header); err != nil { + return nil, err + } + } + sort.Strings(res.WithoutTests) + return res, nil +} + +// listPackages is `go list -test -deps -json` of the directory's packages, by import path. +func (m *Module) listPackages(ctx context.Context, tree, dir string, tags []string) (map[string]*goPackage, error) { + abs := filepath.Join(tree, dir) + args := []string{"-C", abs, "list", "-test", "-deps", "-json"} + if len(tags) > 0 { + args = append(args, "-tags", strings.Join(tags, ",")) + } + args = append(args, "./...") + r := m.Run(ctx, m.goEnvFor(abs), "go", args...) + if r.Status != 0 || r.Err != "" { + return nil, fmt.Errorf("go list in %s: %s", dir, firstLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout)) + } + return parsePackages(r.Stdout) +} + +// parsePackages reads `go list -json`'s stream of objects. +func parsePackages(out string) (map[string]*goPackage, error) { + pkgs := map[string]*goPackage{} + dec := json.NewDecoder(strings.NewReader(out)) + for { + var p goPackage + if err := dec.Decode(&p); err == io.EOF { + return pkgs, nil + } else if err != nil { + return nil, fmt.Errorf("go list answered something that is not its JSON: %w", err) + } + pkgs[p.ImportPath] = &p + } +} + +// digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main +// module that is listed, not only a dependency, and not a variant of another. +func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header []string) error { + roots := []string{} + for path, p := range pkgs { + if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main { + roots = append(roots, path) + } + } + sort.Strings(roots) + for _, path := range roots { + root := pkgs[path] + if !root.hasTests() { + res.WithoutTests = append(res.WithoutTests, path) + continue + } + testMain := pkgs[path+".test"] + if testMain == nil { + return fmt.Errorf("go list gave no test binary for %s", path) + } + d, err := packageDigest(root, testMain, pkgs, tree, header) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + res.Packages[path] = d + } + return nil +} + +// packageDigest is the digest of one package: a hash over a sorted text of the header, the files of every package its +// tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of +// every module outside it, and the package's own test data. A package of the standard library is the Go version's. +func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header []string) (string, error) { + reached := map[string]*goPackage{root.ImportPath: root} + for _, dep := range testMain.Deps { + if p := pkgs[dep]; p != nil { + reached[dep] = p + } + } + lines := []string{} + for _, p := range reached { + if p.Standard || p.Module == nil { + continue + } + mod := p.Module + if mod.Replace != nil { + mod = mod.Replace + } + if !mod.Main && mod.Version != "" { + lines = append(lines, "module "+mod.Path+"@"+mod.Version+" "+mod.Sum) + continue + } + for _, f := range p.files() { + l, err := fileLine(tree, p, f) + if err != nil { + return "", err + } + lines = append(lines, l) + } + } + data := filepath.Join(root.Dir, "testdata") + if _, err := os.Stat(data); err == nil { + err := filepath.WalkDir(data, func(path string, e fs.DirEntry, err error) error { + if err != nil || e.IsDir() { + return err + } + l, err := contentLine(tree, root, path) + lines = append(lines, l) + return err + }) + if err != nil { + return "", err + } + } + sort.Strings(lines) + h := sha256.New() + for _, l := range append(append([]string{}, header...), lines...) { + h.Write([]byte(l + "\n")) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func fileLine(tree string, p *goPackage, name string) (string, error) { + return contentLine(tree, p, filepath.Join(p.Dir, name)) +} + +// contentLine is "file ": the path is the file's in the tree (the import path and the name when it lies +// outside, as a local replacement does), so a digest does not depend on where the workspace is. +func contentLine(tree string, p *goPackage, path string) (string, error) { + label := p.ImportPath + "/" + filepath.Base(path) + if rel, err := filepath.Rel(tree, path); err == nil && !strings.HasPrefix(rel, "..") { + label = filepath.ToSlash(rel) + } + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + to, _ := os.Readlink(path) + return "link " + label + " " + to, nil + } + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return "file " + label + " " + hex.EncodeToString(sum[:]), nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go new file mode 100644 index 00000000..60a4efe4 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// Issue 1291: the digests of a fixture module (testdata/repos/digest): a's tests import b and read a/testdata, c's +// tests read an embedded file, d is reached by no test. They run the real `go list` (it loads packages, it builds +// none), through the step runner; only the listing of the tree's go.mod files and postgres's version are answered. + +// digestModule is a module over a copy of the fixture, in a workspace of its own. +func digestFixture(t *testing.T) (*Module, string) { + t.Helper() + work := t.TempDir() + tree := filepath.Join(work, "ws1", "tree") + if err := os.MkdirAll(tree, 0o755); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("cp", "-r", filepath.Join("testdata", "repos", "digest")+"/.", tree).CombinedOutput(); err != nil { + t.Fatalf("%v %s", err, out) + } + m := moduleWith(newStates(), "go1.27.1") + m.Home, m.Work, m.Cache = t.TempDir(), work, t.TempDir() + real := StepRunner(m.Home) + m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + switch name { + case "git": + return forgekey.Ran{Stdout: "go.mod\n"} + case "postgres": + return forgekey.Ran{Stdout: "postgres (PostgreSQL) " + strconv.Itoa(m.Recipe.PostgresMajor) + ".1\n"} + } + return real(ctx, env, name, args...) + } + return m, tree +} + +func digests(t *testing.T, m *Module, level string) DigestResult { + t.Helper() + out, err := m.digest(context.Background(), map[string]any{"workspace": "ws1", "level": level}) + if err != nil { + t.Fatal(err) + } + return out.(DigestResult) +} + +func edit(t *testing.T, tree, name, text string) { + t.Helper() + if err := os.WriteFile(filepath.Join(tree, name), []byte(text), 0o644); err != nil { + t.Fatal(err) + } +} + +// 1291/AC1: a file of b changes: a's digest changes (its tests import b), c's does not. +func TestADependencysFileChangesTheDigestOfThePackageWhoseTestsImportIt(t *testing.T) { + t.Parallel() + m, tree := digestFixture(t) + before := digests(t, m, "unit") + for _, p := range []string{"fixture/digest/a", "fixture/digest/c"} { + if len(before.Packages[p]) != 64 { + t.Fatalf("no digest for %s: %+v", p, before) + } + } + edit(t, tree, "b/b.go", "package b\n\n// B is reached by a's tests.\nfunc B() int { return 2 + 0 }\n") + after := digests(t, m, "unit") + if after.Packages["fixture/digest/a"] == before.Packages["fixture/digest/a"] { + t.Errorf("a's digest did not change when b did") + } + if after.Packages["fixture/digest/c"] != before.Packages["fixture/digest/c"] { + t.Errorf("c's digest changed when b did") + } + if again := digests(t, m, "unit"); again.Packages["fixture/digest/a"] != after.Packages["fixture/digest/a"] { + t.Errorf("one tree has two digests") + } +} + +// 1291/AC2: a file under a's testdata/ changes: a's digest changes. An embedded file does the same for c. +func TestATestdataFileAndAnEmbeddedFileChangeTheDigest(t *testing.T) { + t.Parallel() + m, tree := digestFixture(t) + before := digests(t, m, "unit") + edit(t, tree, "a/testdata/x.txt", "y\n") + after := digests(t, m, "unit") + if after.Packages["fixture/digest/a"] == before.Packages["fixture/digest/a"] { + t.Errorf("a's digest did not change with its testdata") + } + if after.Packages["fixture/digest/c"] != before.Packages["fixture/digest/c"] { + t.Errorf("c's digest changed with a's testdata") + } + edit(t, tree, "c/note.txt", "another note\n") + if again := digests(t, m, "unit"); again.Packages["fixture/digest/c"] == after.Packages["fixture/digest/c"] { + t.Errorf("c's digest did not change with its embedded file") + } +} + +// 1291/AC3: one tree at the unit and the integration level: the digests differ; at the integration level a change of the +// recipe's postgres major (or of the nats-server version) changes it, at the unit level it does not. +func TestTheLevelTheStoreAndTheBusAreInTheDigest(t *testing.T) { + t.Parallel() + m, _ := digestFixture(t) + unit, integration := digests(t, m, "unit"), digests(t, m, "integration") + a := "fixture/digest/a" + if unit.Packages[a] == integration.Packages[a] { + t.Errorf("the unit and the integration digest of a are one") + } + m.Recipe.PostgresMajor++ + if got := digests(t, m, "integration"); got.Packages[a] == integration.Packages[a] { + t.Errorf("a new postgres major left the integration digest as it was") + } + if got := digests(t, m, "unit"); got.Packages[a] != unit.Packages[a] { + t.Errorf("a new postgres major moved the unit digest") + } + m.Recipe.PostgresMajor-- + m.Recipe.NatsServer = "9.9.9" + if got := digests(t, m, "integration"); got.Packages[a] == integration.Packages[a] { + t.Errorf("a new nats-server version left the integration digest as it was") + } + m.Recipe.NatsServer = "2.11.17" + m.Recipe.Go = "go1.27.2" + if got := digests(t, m, "unit"); got.Packages[a] == unit.Packages[a] { + t.Errorf("a new Go version left the unit digest as it was") + } +} + +// 1291/AC4: a change in a package no test of a reaches leaves a's digest as it was; d, with no tests, has none. +func TestAPackageNoTestReachesChangesNoDigest(t *testing.T) { + t.Parallel() + m, tree := digestFixture(t) + before := digests(t, m, "unit") + edit(t, tree, "d/d.go", "package d\n\n// D is reached by no test.\nfunc D() int { return 5 }\n") + after := digests(t, m, "unit") + for p, d := range before.Packages { + if after.Packages[p] != d { + t.Errorf("the digest of %s changed with a package no test of it reaches", p) + } + } + if _, has := after.Packages["fixture/digest/d"]; has || strings.Join(after.WithoutTests, " ") != "fixture/digest/b fixture/digest/d" { + t.Errorf("without tests = %v, packages = %v", after.WithoutTests, after.Packages) + } +} + +// The digest does not depend on where the workspace lies. +func TestTheDigestIsTheSameInAnotherWorkspace(t *testing.T) { + t.Parallel() + m1, _ := digestFixture(t) + m2, _ := digestFixture(t) + if d1, d2 := digests(t, m1, "unit"), digests(t, m2, "unit"); d1.Packages["fixture/digest/a"] != d2.Packages["fixture/digest/a"] { + t.Errorf("one tree in two places has two digests") + } +} + +// A module outside the tree counts by its version and checksum, not by its files: the package of mesh-sdk, as go list +// gave it for this module's own dependency (testdata/captured/golist-external.json, which README describes). +func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "captured", "golist-external.json")) + if err != nil { + t.Fatal(err) + } + pkgs, err := parsePackages(string(raw)) + if err != nil { + t.Fatal(err) + } + ext := pkgs["git.novox.be/novox/mesh-sdk/go/buildstep"] + if ext == nil || ext.Module == nil || ext.Module.Sum == "" || ext.Module.Version == "" || ext.Module.Main { + t.Fatalf("the capture holds no external package with a version and a checksum: %+v", ext) + } + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "r.go"), []byte("package r\n"), 0o644) + root := &goPackage{ImportPath: "x/r", Dir: dir, Module: &goModule{Path: "x", Main: true}, GoFiles: []string{"r.go"}, TestGoFiles: []string{"r.go"}} + testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}} + pkgs[root.ImportPath] = root + digest := func() string { + d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}) + if err != nil { + t.Fatal(err) + } + return d + } + d1 := digest() + ext.Module.Sum = "h1:changed=" + if d2 := digest(); d1 == d2 { + t.Errorf("a new checksum of a module outside the tree left the digest as it was") + } + ext.Module.Sum, ext.Module.Version = "h1:changed=", "v9.9.9" + if d3 := digest(); d3 == d1 { + t.Errorf("a new version left the digest as it was") + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/levels.go b/modules/mesh-build-go/cmd/mesh-build-go/levels.go new file mode 100644 index 00000000..8c24cead --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/levels.go @@ -0,0 +1,259 @@ +package main + +// The judging of a test level (novox/hq ADR 0445 §2 §3; issues 1290 and 1486): `go test -json` read into a result +// that says, per package, what ran and for how long, which tests failed and why, and whether the level kept its +// bound. A level that failed is still a finished step: the verdict is in the result, and a step that ends failed is a +// step that could not run. + +import ( + "bufio" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// The two levels (ADR 0445 §2). +const ( + LevelUnit = "unit" + LevelIntegration = "integration" +) + +// UnitBound is the unit level's one bound: its running time, five seconds for the level, no setup (ADR 0445 §3). The +// level's time is that of its slowest package, as packages run side by side, the compile not counted. +const UnitBound = 5 * time.Second + +// Bounds are the bounds a level is judged by: Setup is the time of everything before the tests run (compiling, +// starting the store and the bus; the unit level has none) and Run the tests' own time. +type Bounds struct { + Setup, Run time.Duration +} + +// PackageResult is one package's part of a level. +type PackageResult struct { + Dir string `json:"dir"` + Package string `json:"package"` + Passed bool `json:"passed"` + Elapsed float64 `json:"elapsed"` + Tests int `json:"tests"` + // NoTests is a package with no test files (at this level: a file with the other level's build tag is none). + NoTests bool `json:"no_tests,omitempty"` +} + +// TestTime is one test and the time it took. +type TestTime struct { + Package string `json:"package"` + Test string `json:"test"` + Elapsed float64 `json:"elapsed"` +} + +// Failure is a test that failed, or a package that failed with no test to name (a build that failed, a TestMain that +// panicked); Output is the first lines it wrote. +type Failure struct { + Package string `json:"package"` + Test string `json:"test,omitempty"` + Output string `json:"output,omitempty"` +} + +// TestResult is what a test step passes with. +type TestResult struct { + Level string `json:"level"` + Passed bool `json:"passed"` + // Why says, in words, why the level failed, or that it has no tests. + Why string `json:"why,omitempty"` + NoTests bool `json:"no_tests,omitempty"` + Tests int `json:"tests"` + Skipped int `json:"skipped"` + Packages []PackageResult `json:"packages"` + // Elapsed is the level's running time in seconds: the slowest package's. + Elapsed float64 `json:"elapsed"` + // Setup is the level's setup time in seconds, when it has a setup. + Setup float64 `json:"setup,omitempty"` + // Bound and SetupBound are the bounds the level was judged by, in seconds. + Bound float64 `json:"bound"` + SetupBound float64 `json:"setup_bound,omitempty"` + Slowest []TestTime `json:"slowest,omitempty"` + Failed []Failure `json:"failed,omitempty"` + // Store and Bus are what the integration level ran against: the postgres major version and the nats-server + // version (ADR 0470 §4). + Store string `json:"store,omitempty"` + Bus string `json:"bus,omitempty"` +} + +// event is one line of `go test -json` (test2json, and the build events go test adds). +type event struct { + Action string + Package string + Test string + Output string + ImportPath string + FailedBuild string + Elapsed float64 +} + +// outputLines is how many lines of output a failure keeps. +const outputLines = 15 + +// reader collects the events of one go.mod directory's `go test -json` into a result. +type reader struct { + res *TestResult + dir string + pkgs map[string]*PackageResult + order []string + builds map[string][]string // build output by import path + pkgOut map[string][]string + testOut map[string][]string // by package + "\x00" + test + times []TestTime + failedIn map[string]bool // packages with a failed test +} + +func newReader(res *TestResult, dir string) *reader { + return &reader{res: res, dir: dir, pkgs: map[string]*PackageResult{}, builds: map[string][]string{}, + pkgOut: map[string][]string{}, testOut: map[string][]string{}, failedIn: map[string]bool{}} +} + +// read takes go test's output. Lines that are not events are kept for a failure that has nothing else to say. +func (r *reader) read(out string) (noise []string) { + sc := bufio.NewScanner(strings.NewReader(out)) + sc.Buffer(make([]byte, 1<<20), 1<<26) + for sc.Scan() { + var ev event + if err := json.Unmarshal(sc.Bytes(), &ev); err != nil { + if l := strings.TrimSpace(sc.Text()); l != "" { + noise = append(noise, l) + } + continue + } + r.event(ev) + } + return noise +} + +func (r *reader) pkg(name string) *PackageResult { + p := r.pkgs[name] + if p == nil { + p = &PackageResult{Dir: r.dir, Package: name} + r.pkgs[name] = p + r.order = append(r.order, name) + } + return p +} + +func keep(lines []string, out string) []string { + if t := strings.TrimRight(out, "\r\n"); t != "" && !strings.HasPrefix(t, "=== ") { + lines = append(lines, t) + } + return lines +} + +func (r *reader) event(ev event) { + switch { + case ev.Package == "" && ev.ImportPath != "": + if ev.Action == "build-output" { + r.builds[ev.ImportPath] = keep(r.builds[ev.ImportPath], ev.Output) + } + case ev.Package == "": + case ev.Test == "": + p := r.pkg(ev.Package) + switch ev.Action { + case "output": + r.pkgOut[ev.Package] = keep(r.pkgOut[ev.Package], ev.Output) + case "pass": + p.Passed, p.Elapsed = true, ev.Elapsed + case "fail": + p.Passed, p.Elapsed = false, ev.Elapsed + if ev.FailedBuild != "" { + r.pkgOut[ev.Package] = append(r.builds[ev.FailedBuild], r.pkgOut[ev.Package]...) + } + case "skip": + p.Passed, p.NoTests = true, true + } + default: + key := ev.Package + "\x00" + ev.Test + switch ev.Action { + case "output": + r.testOut[key] = keep(r.testOut[key], ev.Output) + case "pass", "fail", "skip": + p := r.pkg(ev.Package) + p.Tests++ + r.res.Tests++ + if ev.Action == "skip" { + r.res.Skipped++ + } + r.times = append(r.times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed}) + if ev.Action == "fail" { + r.failedIn[ev.Package] = true + r.res.Failed = append(r.res.Failed, Failure{Package: ev.Package, Test: ev.Test, Output: clip(r.testOut[key])}) + } + } + } +} + +func clip(lines []string) string { + if len(lines) > outputLines { + lines = append(lines[:outputLines:outputLines], fmt.Sprintf("... (%d more lines)", len(lines)-outputLines)) + } + return strings.Join(lines, "\n") +} + +// finish moves what was read into the result: the packages in order, a failure for each failed package that has no +// failed test to name it, and the tests' times. +func (r *reader) finish() { + for _, name := range r.order { + p := r.pkgs[name] + if !p.Passed && !r.failedIn[name] { + r.res.Failed = append(r.res.Failed, Failure{Package: name, Output: clip(r.pkgOut[name])}) + } + if !p.NoTests && p.Elapsed > r.res.Elapsed { + r.res.Elapsed = p.Elapsed + } + r.res.Packages = append(r.res.Packages, *p) + } + r.res.Slowest = append(r.res.Slowest, r.times...) +} + +// judge sets Passed and Why from what was read and the bounds. A level with no failure, over no bound, passes; with no +// test at all it says the repository has none at this level, which is neither a failure nor a warning (ADR 0445 §1). +func (res *TestResult) judge(b Bounds) { + sort.SliceStable(res.Slowest, func(i, j int) bool { return res.Slowest[i].Elapsed > res.Slowest[j].Elapsed }) + if len(res.Slowest) > 5 { + res.Slowest = res.Slowest[:5] + } + res.Bound, res.SetupBound = b.Run.Seconds(), b.Setup.Seconds() + var why []string + if n := len(res.Failed); n > 0 { + names := []string{} + for _, f := range res.Failed { + names = append(names, strings.TrimSpace(f.Package+" "+f.Test)) + } + why = append(why, fmt.Sprintf("%d failed: %s", n, strings.Join(names, ", "))) + } + if b.Setup > 0 && res.Setup > b.Setup.Seconds() { + why = append(why, fmt.Sprintf("the %s level's setup took %.2f s, over its setup bound of %s", res.Level, res.Setup, seconds(b.Setup))) + } + if b.Run > 0 && res.Elapsed > b.Run.Seconds() { + slow := []string{} + for _, s := range res.Slowest { + slow = append(slow, fmt.Sprintf("%s %s (%.2f s)", s.Package, s.Test, s.Elapsed)) + } + why = append(why, fmt.Sprintf("the %s level took %.2f s, over its bound of %s; the slowest tests: %s", res.Level, res.Elapsed, seconds(b.Run), strings.Join(slow, ", "))) + } + res.NoTests = res.Tests == 0 && len(res.Failed) == 0 + switch { + case len(why) > 0: + res.Why = strings.Join(why, "; ") + case res.NoTests: + res.Passed, res.Why = true, "the repository has no tests at the "+res.Level+" level" + return + default: + res.Passed = true + return + } + res.Passed = false +} + +// seconds is a duration as seconds, without trailing zeros: "5 s", "0.01 s". +func seconds(d time.Duration) string { + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", d.Seconds()), "0"), ".") + " s" +}