Add the Go build module's digest step, its tests and the level judging (issues 1291, 1290, 1486), part 10 of 20

This commit is contained in:
2026-10-20 20:53:50 +00:00
parent be7f04dbd4
commit 86d79f7872
3 changed files with 732 additions and 0 deletions
@@ -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 <path> <sha256>": 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
}
@@ -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")
}
}
@@ -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"
}