package fiesta import ( "encoding/json" "go/ast" "go/parser" "go/token" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "testing" "unicode" "unicode/utf8" ) // This file is rastrillo's amadan_ci_test.go, aimed at this repo's own // tree. .amadan/ci and .amadan/ci.d/ are what a step-reporting runner // executes, and nothing else gates them: a lost exec bit, or a ci.d // step that runs its own commands instead of naming a Makefile target, // resolves amadan's runner to "skipped", which renders as a pass. That // defect shipped five times in rastrillo before these checks existed; // Fiesta carries them from its first commit. // amadanCIDStepFiles lists .amadan/ci.d's entries, sorted, so every // check below walks the same list. func amadanCIDStepFiles(t *testing.T) []string { t.Helper() entries, err := os.ReadDir(".amadan/ci.d") if err != nil { t.Fatalf("reading .amadan/ci.d: %v", err) } var names []string for _, e := range entries { if e.IsDir() { continue } names = append(names, e.Name()) } sort.Strings(names) if len(names) == 0 { t.Fatal(".amadan/ci.d has no step files") } return names } // TestAmadanFilesAreExecutableInGit checks the executable bit the way // amadan's runner sees it: through git's index, not the working tree. // A filesystem-only check (os.Stat) would pass on a machine where the // bit is set locally but was never committed — the exact gap that lets // a non-executable step ship and resolve "skipped" (a pass) on every // other checkout. func TestAmadanFilesAreExecutableInGit(t *testing.T) { out, err := exec.Command("git", "ls-files", "-s", ".amadan/").Output() if err != nil { t.Fatalf("git ls-files -s .amadan/: %v", err) } lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") if len(lines) == 0 || lines[0] == "" { t.Fatal("git ls-files -s .amadan/ reported nothing tracked") } seen := map[string]bool{} for _, line := range lines { fields := strings.Fields(line) if len(fields) < 4 { t.Fatalf("unexpected `git ls-files -s` line: %q", line) } mode, path := fields[0], fields[3] seen[path] = true if mode != "100755" { t.Errorf("%s is committed with mode %s, not 100755 — a runner reading the index (not the working tree) would resolve this job \"skipped\", which renders as a pass", path, mode) } } want := []string{".amadan/ci"} for _, name := range amadanCIDStepFiles(t) { want = append(want, filepath.Join(".amadan/ci.d", name)) } for _, w := range want { if !seen[w] { t.Errorf("%s is not tracked in git's index at all", w) } } } // execMakePattern anchors on the exact shape a ci.d step is allowed to // take: nothing but a call into the Makefile target of the same name. var execMakePattern = regexp.MustCompile(`^exec make ([A-Za-z0-9_-]+)$`) // amadanCIDTargets reads every .amadan/ci.d step, asserts each is a // bare `exec make ` (the Makefile's ci comment: "ci.d/ ... // never keeps its own copy of a command"), and returns the // step-file-name -> target map. func amadanCIDTargets(t *testing.T) map[string]string { t.Helper() targets := map[string]string{} for _, name := range amadanCIDStepFiles(t) { path := filepath.Join(".amadan/ci.d", name) b, err := os.ReadFile(path) if err != nil { t.Fatalf("reading %s: %v", path, err) } var nonBlank []string for _, l := range strings.Split(string(b), "\n") { if strings.TrimSpace(l) != "" { nonBlank = append(nonBlank, l) } } if len(nonBlank) != 2 || nonBlank[0] != "#!/bin/sh" { t.Errorf("%s must be exactly a `#!/bin/sh` shebang followed by one `exec make ` line; got:\n%s", path, b) continue } m := execMakePattern.FindStringSubmatch(nonBlank[1]) if m == nil { t.Errorf("%s's step is %q, not a bare `exec make ` — a step that runs its own command instead of a Makefile target is exactly the copy the Makefile says never to keep, and it can drift from what `make ci` actually runs without anything noticing", path, nonBlank[1]) continue } targets[name] = m[1] } return targets } // TestAmadanStepsAreBareExecMake is that assertion on its own, so a // broken step file fails here with the specific file and line named, // rather than only showing up as a set mismatch in the test below. func TestAmadanStepsAreBareExecMake(t *testing.T) { amadanCIDTargets(t) } // ciPrereqPattern pulls `ci:`'s prerequisite list out of the Makefile, // following GNU Make's backslash-newline continuation so the // multi-line target list is captured whole. var ciPrereqPattern = regexp.MustCompile(`(?m)^ci:[ \t]*((?:.*\\\n)*.*)$`) // TestAmadanCITargetsMatchStepFiles enforces the promise the Makefile // makes and nothing else checks: add to `ci:` and to ci.d/ together, // or a step-reporting runner silently skips what you added. A target // added to `ci:` with no matching ci.d step never runs under amadan's // runner even though `make ci` covers it; a ci.d step naming a target // dropped from (or never added to) `ci:` reports a job that `make ci` // — the one gate — no longer executes. Either drift is silent without // this. func TestAmadanCITargetsMatchStepFiles(t *testing.T) { mk, err := os.ReadFile("Makefile") if err != nil { t.Fatalf("reading Makefile: %v", err) } m := ciPrereqPattern.FindStringSubmatch(string(mk)) if m == nil { t.Fatal("Makefile has no `ci:` target; `make ci` is the one gate every step file execs into") } joined := strings.ReplaceAll(m[1], "\\\n", " ") ciTargets := map[string]bool{} for _, f := range strings.Fields(joined) { ciTargets[f] = true } if len(ciTargets) == 0 { t.Fatal("Makefile's `ci:` target has no prerequisites") } stepTargets := map[string]bool{} for step, target := range amadanCIDTargets(t) { stepTargets[target] = true if !ciTargets[target] { t.Errorf(".amadan/ci.d/%s execs `make %s`, but %q is not one of ci:'s prerequisites in the Makefile — this step now reports a job `make ci` does not run", step, target, target) } } for target := range ciTargets { if !stepTargets[target] { t.Errorf("Makefile's `ci:` target depends on %q, but no .amadan/ci.d/* step execs `make %s` — amadan's runner silently skips this half of `make ci`", target, target) } } } // TestAmadanSuiteShardsMatchRoles pins the suite step's @N to the // Makefile's SUITE. amadan starts N shards and each runs the role at // its index, so the two numbers are one fact written twice: a role // added without raising N runs nowhere while every shard reports ok. // hack/suite.sh refuses the mismatch at run time; this says so before // a runner is involved. It also refuses a role that is a ci // prerequisite as well, which would run it twice. func TestAmadanSuiteShardsMatchRoles(t *testing.T) { if _, err := exec.LookPath("make"); err != nil { t.Skip("make is not installed; the gate itself runs under make") } var step, shards string for name, target := range amadanCIDTargets(t) { if target != "suite" { continue } if step != "" { t.Fatalf("two .amadan/ci.d steps exec `make suite` (%s, %s); each would run every role", step, name) } step = name if i := strings.LastIndex(name, "@"); i >= 0 { shards = name[i+1:] } } if step == "" { t.Fatal("no .amadan/ci.d step execs `make suite`, so the suite's roles run nowhere under amadan") } out, err := exec.Command("make", "-s", "--no-print-directory", "suite-roles").Output() if err != nil { t.Fatalf("make suite-roles: %v", err) } roles := strings.Fields(string(out)) if len(roles) == 0 { t.Fatal("make suite-roles printed no roles") } if want := strconv.Itoa(len(roles)); shards != want { t.Errorf(".amadan/ci.d/%s runs as %q shards, but the Makefile's SUITE has %d roles; rename it to end in @%s", step, shards, len(roles), want) } mk, err := os.ReadFile("Makefile") if err != nil { t.Fatalf("reading Makefile: %v", err) } m := ciPrereqPattern.FindStringSubmatch(string(mk)) if m == nil { t.Fatal("Makefile has no `ci:` target") } ci := map[string]bool{} for _, f := range strings.Fields(strings.ReplaceAll(m[1], "\\\n", " ")) { ci[f] = true } seen := map[string]bool{} for _, r := range roles { if seen[r] { t.Errorf("SUITE lists %q twice; two shards would run it", r) } seen[r] = true if ci[r] { t.Errorf("%q is both a suite role and a prerequisite of ci, so make ci runs it twice", r) } } } // sliceRolePattern reads a sliced role: .~of, mode // browser or test. var sliceRolePattern = regexp.MustCompile(`^(browser|test)\.(.+)~([0-9]+)of([0-9]+)$`) // TestSlicesPartitionTheirPackages is what lets a package run under // -run at all: a filter that matches nothing exits 0, and a slice is a // -run too, so this proves the slices cannot leave a test out. Every // slice 1..n of a package is a suite role, and hack/gotest.sh's deal // puts each name in exactly one slice and leaves no slice empty. The // names are every weighted one plus synthetic ones, so this compiles // nothing; the real list is dealt by the same function at run time. // It skips while no package is sliced. func TestSlicesPartitionTheirPackages(t *testing.T) { if _, err := exec.LookPath("make"); err != nil { t.Skip("make is not installed; the gate itself runs under make") } out, err := exec.Command("make", "-s", "--no-print-directory", "suite-roles").Output() if err != nil { t.Fatalf("make suite-roles: %v", err) } type sliced struct{ mode, pkg string } slices := map[sliced]map[int]bool{} counts := map[sliced]int{} for _, r := range strings.Fields(string(out)) { m := sliceRolePattern.FindStringSubmatch(r) if m == nil { continue } k, _ := strconv.Atoi(m[3]) n, _ := strconv.Atoi(m[4]) key := sliced{m[1], strings.ReplaceAll(m[2], ".", "/")} if prev, ok := counts[key]; ok && prev != n { t.Fatalf("%s %s is sliced both %d and %d ways", key.mode, key.pkg, prev, n) } counts[key] = n if slices[key] == nil { slices[key] = map[int]bool{} } slices[key][k] = true } if len(counts) == 0 { t.Skip("no package is sliced") } weights, err := os.ReadFile("hack/test-weights.txt") if err != nil { t.Fatal(err) } for key, n := range counts { name := key.mode + " " + key.pkg for k := 1; k <= n; k++ { if !slices[key][k] { t.Errorf("%s is sliced %d ways but slice %d is no suite role, so its tests run nowhere", name, n, k) } } var names []string for _, l := range strings.Split(string(weights), "\n") { if f := strings.Fields(l); len(f) == 4 && f[0] == key.mode && f[1] == key.pkg { names = append(names, f[2]) } } for i := 0; i < 5*n; i++ { names = append(names, "TestUnweighted"+strconv.Itoa(i)) } deal := exec.Command("sh", "hack/gotest.sh", "--deal", key.mode, key.pkg, strconv.Itoa(n)) deal.Stdin = strings.NewReader(strings.Join(names, "\n") + "\n") got, err := deal.Output() if err != nil { t.Fatalf("dealing %s: %v", name, err) } dealt := map[string]int{} used := map[int]bool{} for _, l := range strings.Split(strings.TrimSpace(string(got)), "\n") { f := strings.Fields(l) if len(f) != 2 { t.Fatalf("deal line %q is not ` `", l) } k, err := strconv.Atoi(f[0]) if err != nil || k < 1 || k > n { t.Errorf("%s: %s was dealt to slice %q, outside 1..%d", name, f[1], f[0], n) } used[k] = true dealt[f[1]]++ } for _, nm := range names { if dealt[nm] != 1 { t.Errorf("%s: %s was dealt %d times, not once", name, nm, dealt[nm]) } } if len(dealt) != len(names) { t.Errorf("%s: dealt %d names from %d", name, len(dealt), len(names)) } for k := 1; k <= n; k++ { if !used[k] { t.Errorf("%s: slice %d of %d was dealt nothing from %d names", name, k, n, len(names)) } } } } // testNames reads the tests, examples and fuzz targets go test would // find in each package of ./..., built with tags (none, or browser): // import path to the set of names. It parses the files go list says // the build includes rather than compiling anything, so it costs a // second, not a build of every test binary. func testNames(t *testing.T, tags string) map[string]map[string]bool { t.Helper() args := []string{"list", "-e", "-json"} if tags != "" { args = append(args, "-tags", tags) } out, err := exec.Command("go", append(args, "./...")...).Output() if err != nil { t.Fatalf("go %s ./...: %v", strings.Join(args, " "), err) } names := map[string]map[string]bool{} dec := json.NewDecoder(strings.NewReader(string(out))) for dec.More() { var pkg struct { ImportPath, Dir string TestGoFiles, XTestGoFiles []string Error *struct{ Err string } } if err := dec.Decode(&pkg); err != nil { t.Fatal(err) } // A package whose files the tags exclude entirely is not a // package of this build; any other error is a broken tree. if pkg.Error != nil { if strings.Contains(pkg.Error.Err, "build constraints exclude all Go files") { continue } t.Fatalf("go list %s: %s", pkg.ImportPath, pkg.Error.Err) } set := map[string]bool{} for _, f := range append(pkg.TestGoFiles, pkg.XTestGoFiles...) { file, err := parser.ParseFile(token.NewFileSet(), filepath.Join(pkg.Dir, f), nil, parser.SkipObjectResolution) if err != nil { t.Fatal(err) } for _, d := range file.Decls { fn, ok := d.(*ast.FuncDecl) if !ok || fn.Recv != nil { continue } for _, prefix := range []string{"Test", "Example", "Fuzz"} { rest, ok := strings.CutPrefix(fn.Name.Name, prefix) if r, _ := utf8.DecodeRuneInString(rest); ok && (rest == "" || !unicode.IsLower(r)) && fn.Name.Name != "TestMain" { set[fn.Name.Name] = true } } } } names[pkg.ImportPath] = set } return names } // TestEveryTestRunsInExactlyOneRole holds the suite's roles to running // each test once. A package's plain tests (those the untagged build // has) run in root, or in its test. roles where ROOT_SPLIT names // it; its browser tests (those only the browser build has) run in its // browser roles, which is hack/gotest.sh's rule for a browser role. So // every package with a browser test must be a browser package, every // browser package must have one, every split package must exist, and // root must be a role. That a sliced package's slices partition it is // TestSlicesPartitionTheirPackages. func TestEveryTestRunsInExactlyOneRole(t *testing.T) { if _, err := exec.LookPath("make"); err != nil { t.Skip("make is not installed; the gate itself runs under make") } out, err := exec.Command("make", "-s", "--no-print-directory", "suite-roles").Output() if err != nil { t.Fatalf("make suite-roles: %v", err) } modPath := "amadan.net/rastrillo/fiesta" pathOf := func(role, mode string) string { rel := strings.TrimPrefix(role, mode+".") rel, _, _ = strings.Cut(rel, "~") return modPath + "/" + strings.ReplaceAll(rel, ".", "/") } root := false browserRoles, testRoles := map[string]bool{}, map[string]bool{} for _, r := range strings.Fields(string(out)) { switch { case r == "root": root = true case strings.HasPrefix(r, "browser."): browserRoles[pathOf(r, "browser")] = true case strings.HasPrefix(r, "test."): testRoles[pathOf(r, "test")] = true } } if !root { t.Fatal("the suite has no root role, so the plain tests of every unsplit package run nowhere") } plain, tagged := testNames(t, ""), testNames(t, "browser") for pkg := range testRoles { if _, ok := plain[pkg]; !ok { t.Errorf("%s has test roles but the untagged build has no such package", pkg) } } pkgs := map[string]bool{} for pkg := range plain { pkgs[pkg] = true } for pkg := range tagged { pkgs[pkg] = true } ran := 0 for pkg := range pkgs { browserOnly := 0 names := map[string]bool{} for n := range plain[pkg] { names[n] = true } for n := range tagged[pkg] { names[n] = true } for n := range names { roles := 0 if plain[pkg][n] { roles++ // root, or the package's test roles } if tagged[pkg][n] && !plain[pkg][n] { browserOnly++ if browserRoles[pkg] { roles++ } } if roles != 1 { t.Errorf("%s %s runs in %d roles, not one", pkg, n, roles) } ran += roles } if browserRoles[pkg] && browserOnly == 0 { t.Errorf("%s has browser roles but no test only the browser build has; its roles would run nothing", pkg) } } for pkg := range browserRoles { if !pkgs[pkg] { t.Errorf("%s has browser roles but no such package", pkg) } } if ran < 500 { t.Errorf("only %d tests found across the suite; the reading has stopped seeing them", ran) } t.Logf("%d tests, each in one role", ran) }