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:
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user