package fiesta import ( "bytes" "crypto/sha256" "fmt" "io/fs" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "testing" "amadan.net/rastrillo/fiesta/internal/build" "amadan.net/rastrillo/fiesta/internal/site" ) // releases/ holds every release Fiesta has cut, one X.Y.Z/ directory // each, written by fiesta release and committed. The site serves them // as they are. These tests hold the directory to what it claims to // be: the current version is there; while it is untagged it is what // the tree builds today; once a version is tagged its directory is // the tag's, file for file; and the site builds from all of them. // releasesDir is the committed release directory, relative to the // module root, where this package's tests run. const releasesDir = "releases" // gitOut runs git in the module root and returns its output, failing // the test if git cannot answer. A check that read nothing would pass // on nothing, so git failing is a failure, never a skip: ci_test.go // already needs git here. func gitOut(t *testing.T, args ...string) []byte { t.Helper() cmd := exec.Command("git", args...) var stderr strings.Builder cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String()) } return out } // requireThisRepository checks that the git the tests ask is this // checkout's: the module root is the working tree's top, so a tag list // is a list of Fiesta's tags, not of whatever repository holds a // scratch copy. func requireThisRepository(t *testing.T) { t.Helper() top := strings.TrimSpace(string(gitOut(t, "rev-parse", "--show-toplevel"))) cwd, err := os.Getwd() if err != nil { t.Fatal(err) } if top, err = filepath.EvalSymlinks(top); err != nil { t.Fatal(err) } if cwd, err = filepath.EvalSymlinks(cwd); err != nil { t.Fatal(err) } if top != cwd { t.Fatalf("the tests run in %s but git's working tree is %s; the tag list below would be another repository's", cwd, top) } } // filesUnder reads every regular file under dir, keyed by its // slash-separated path relative to dir. A missing dir is reported as // such; anything that is not a regular file fails, since fiesta // release writes nothing else. func filesUnder(t *testing.T, dir string) (map[string][]byte, bool) { t.Helper() if _, err := os.Stat(dir); err != nil { if os.IsNotExist(err) { return nil, false } t.Fatal(err) } files := map[string][]byte{} err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } rel, err := filepath.Rel(dir, p) if err != nil { return err } if !d.Type().IsRegular() { t.Errorf("%s is not a regular file, and a release holds nothing else", p) return nil } b, err := os.ReadFile(p) if err != nil { return err } files[filepath.ToSlash(rel)] = b return nil }) if err != nil { t.Fatal(err) } return files, true } // sortedNames is a map's keys in order, so a failure lists files the // same way every run. func sortedNames[V any](m map[string]V) []string { names := make([]string, 0, len(m)) for name := range m { names = append(names, name) } sort.Strings(names) return names } // TestTheCurrentReleaseExists: releases// is there. A tree // at a version with no release directory is a tree whose release was // never cut, and the site would serve nothing under it. func TestTheCurrentReleaseExists(t *testing.T) { dir := filepath.Join(releasesDir, Version) fi, err := os.Stat(dir) if err != nil { t.Fatalf("%s is not there: %v. Cut it with go run ./cmd/fiesta release -version %s", dir, err, Version) } if !fi.IsDir() { t.Fatalf("%s is not a directory", dir) } } // isTagged reports whether v is a tag of this repository. func isTagged(t *testing.T, version string) bool { t.Helper() return strings.TrimSpace(string(gitOut(t, "tag", "-l", "v"+version))) != "" } // requireReleaseTags fails a run that is required to see the release // tags and does not. Both tests below read the tags to decide what to // check, and a clone without them (a runner's one-commit clone, which // git gives no tags) would pass them on nothing: the tag check over no // tag, and the fresh-dist check on a release that is in fact tagged. // FIESTA_TEST_REQUIRE_TAGS is set by the Makefile, as the gate's other // requirements are, and make history fetches the tags into a shallow // clone before the suite runs. Required, git must see a vX.Y.Z tag for // every directory under releases/ but the current version's, which may // be an untagged candidate, and at least one tag in all. func requireReleaseTags(t *testing.T) { t.Helper() if os.Getenv("FIESTA_TEST_REQUIRE_TAGS") == "" { return } tags := map[string]bool{} for _, line := range strings.Split(strings.TrimSpace(string(gitOut(t, "tag", "-l", "v*"))), "\n") { if line != "" { tags[line] = true } } if len(tags) == 0 { t.Fatal("FIESTA_TEST_REQUIRE_TAGS is set and git sees no release tag; fetch them (make history does, in a shallow clone) or unset it to run without") } entries, err := os.ReadDir(releasesDir) if err != nil { t.Fatal(err) } for _, e := range entries { if e.IsDir() && e.Name() != Version && !tags["v"+e.Name()] { t.Errorf("FIESTA_TEST_REQUIRE_TAGS is set and %s/%s has no tag v%s in this clone; fetch the tags", releasesDir, e.Name(), e.Name()) } } } // TestAnUntaggedReleaseIsAFreshDist: until v is tagged, the // release directory is exactly what build.Dist makes from the tree // today, byte for byte, with nothing missing and nothing extra. The // one file not in the dist is fiesta release's own stamp at the top, // which site.Release skips and never publishes. Once the tag exists, // TestPublishedReleasesMatchTheirTags holds the directory to the tag // instead, and a later change to the tree waits for the next version. func TestAnUntaggedReleaseIsAFreshDist(t *testing.T) { requireThisRepository(t) requireReleaseTags(t) if isTagged(t, Version) { t.Skipf("v%s is tagged, so releases/%s is held to its tag by TestPublishedReleasesMatchTheirTags; a change to the tree is the next version's", Version, Version) } want, err := build.Dist(FS(), Version) if err != nil { t.Fatal(err) } dir := filepath.Join(releasesDir, Version) got, ok := filesUnder(t, dir) if !ok { t.Fatalf("%s is not there. Cut it with go run ./cmd/fiesta release -version %s", dir, Version) } delete(got, site.StampName) for _, name := range sortedNames(want) { b, ok := got[name] switch { case !ok: t.Errorf("%s/%s is missing from the committed release", dir, name) case !bytes.Equal(b, want[name]): t.Errorf("%s/%s differs from what the tree builds now; rebuild the candidate with go run ./cmd/fiesta release -version %s -candidate", dir, name, Version) } } for _, name := range sortedNames(got) { if _, ok := want[name]; !ok { t.Errorf("%s/%s is not a file the tree builds", dir, name) } } } // releaseTag is a release tag's name: vX.Y.Z, nothing else. A v* tag // of any other shape is a mistake this repository has no use for. var releaseTag = regexp.MustCompile(`^v([0-9]+\.[0-9]+\.[0-9]+)$`) // TestPublishedReleasesMatchTheirTags: for every tag vX.Y.Z, the // current version included once it is tagged, releases/X.Y.Z/ exists // in the working tree and holds exactly the files the tag holds under // that path, byte for byte, the stamp included (site.TagDifferences). A missing directory, // a missing file, a changed file and an extra file each fail: a // published release never changes, and this is the check that says so // on every push after the tag. // // With no tag yet there is nothing to compare, and the test is honest // about that rather than vacuous: git must answer the tag list from // this repository, or the test fails. func TestPublishedReleasesMatchTheirTags(t *testing.T) { requireThisRepository(t) requireReleaseTags(t) out := gitOut(t, "tag", "-l", "v*") var tags []string for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line != "" { tags = append(tags, line) } } t.Logf("git tag -l 'v*' answered %d tags", len(tags)) for _, tag := range tags { m := releaseTag.FindStringSubmatch(tag) if m == nil { t.Errorf("tag %s is not vX.Y.Z; a release tag is nothing else", tag) continue } version := m[1] // The comparison fiesta site -published makes before it lets // browsers keep a release for a year, so the two cannot drift. diffs, err := site.TagDifferences(".", version, filepath.Join(releasesDir, version)) if err != nil { t.Fatalf("comparing %s with its tag: %v", version, err) } for _, d := range diffs { t.Error(d) } } } // TestTheSiteBuildsFromTheReleases: the committed releases and the // pages assemble into the site, so what make site ships is what this // commit holds, and the current release is served under its exact // version with a /1/ rewrite onto it. func TestTheSiteBuildsFromTheReleases(t *testing.T) { files, err := site.Build(os.DirFS(releasesDir), os.DirFS("site"), site.Rules{}) if err != nil { t.Fatalf("the site does not build from %s/ and site/: %v", releasesDir, err) } if _, ok := files[Version+"/fiesta.css"]; !ok { t.Errorf("the site has no %s/fiesta.css", Version) } major, _, _ := strings.Cut(Version, ".") if rules := string(files["_redirects"]); !strings.Contains(rules, "/"+major+"/* /") { t.Errorf("_redirects has no rule for /%s/*:\n%s", major, rules) } } // oneOhTag is the release the 1.x promise is held to: check's // baseline is its CSS and its examples, and its scripts are the ones // the photographs serve to both sides. const oneOhTag = "v1.0.0" // requireOneOhTag fails a run whose clone cannot answer for v1.0.0: // the two tests below compare against it, and a clone without it would // compare nothing. func requireOneOhTag(t *testing.T) { t.Helper() requireThisRepository(t) requireReleaseTags(t) if !isTagged(t, "1.0.0") { t.Fatalf("git sees no tag %s in this clone; fetch the tags (make history does, in a shallow clone)", oneOhTag) } } // taggedFiles lists the files tag holds under dir/, by path from the // repository root. func taggedFiles(t *testing.T, tag, dir string) []string { t.Helper() var out []string for _, line := range strings.Split(strings.TrimSpace(string(gitOut(t, "ls-tree", "-r", "--name-only", tag, "--", dir+"/"))), "\n") { if line != "" { out = append(out, line) } } if len(out) == 0 { t.Fatalf("%s holds no %s/; the comparison would be over nothing", tag, dir) } return out } // TestTheBaselineIsOneOhOh: check/testdata/seed/ is 1.0.0, byte for // byte. The 1.x gates compare the tree's CSS with this copy and // photograph this copy's examples, so a baseline that drifted would // hold 1.x to something 1.0.0 never shipped, and a change to both at // once would pass. tokens.css and shell.css are the tag's css/, the // three themes its css/themes/, and examples/ the tag's examples/, file // for file, with nothing missing. func TestTheBaselineIsOneOhOh(t *testing.T) { requireOneOhTag(t) const seed = "check/testdata/seed" want := map[string]string{ seed + "/tokens.css": "css/tokens.css", seed + "/shell.css": "css/shell.css", } for _, theme := range []string{"day", "plain", "signal"} { want[seed+"/"+theme+".css"] = "css/themes/" + theme + ".css" } tagged := map[string]bool{} for _, path := range taggedFiles(t, oneOhTag, "examples") { want[seed+"/"+path] = path tagged[path] = true } got, ok := filesUnder(t, filepath.FromSlash(seed+"/examples")) if !ok { t.Fatalf("%s/examples is missing; copy it with git archive %s examples | tar -x -C %s", seed, oneOhTag, seed) } for _, name := range sortedNames(got) { if !tagged["examples/"+name] { t.Errorf("%s/examples/%s is not in %s's examples/; the baseline holds only what 1.0.0 shipped", seed, name, oneOhTag) } } for _, path := range sortedNames(want) { b, err := os.ReadFile(filepath.FromSlash(path)) if err != nil { t.Errorf("%s: %v; %s holds %s", path, err, oneOhTag, want[path]) continue } if !bytes.Equal(b, gitOut(t, "show", oneOhTag+":"+want[path])) { t.Errorf("%s differs from %s:%s; the baseline is 1.0.0's and never changes", path, oneOhTag, want[path]) } } } // changedScript is a 1.0 script a 1.x release changes on purpose, to // fix it: the SHA-256 of the bytes the fix leaves, so the next change // to the file fails here again and is named in its own entry; why; and // the test that holds the fixed behaviour, since the photographs serve // the one js/ to both sides and cannot see a script change. type changedScript struct { Sum, Why, Test string } // changedOneOhScripts is keyed by the script's path. Every entry must // be used: a script with an entry that is 1.0.0's again is an exception // for nothing. var changedOneOhScripts = map[string]changedScript{ "js/shell.js": { Sum: "734771a617f39a340a1ce2ad11e08607aef191434631fbe913fc1d829b149fcd", Why: "1.2.0 takes Rastrillo d9c9ed13, 023740c5, e5e35a3e and " + "6b1c5478: a view transition the browser skips rejects its " + "promises, which logged an uncaught AbortError, so shell.js " + "swallows exactly that rejection and no other", Test: "check/shell_test.go TestShellJSSwallowsOnlyASkippedTransition", }, "js/busy.js": { Sum: "eb7210110c5ba6b23552407953bc97374f834787fcec411e643e7513524f69b7", Why: "1.2.0 takes Rastrillo 8548e455: a data-live-form form is " + "armed only once its server's script has left it to the " + "browser, so busy.js no longer wipes the busy state of a " + "form the script sends itself", Test: "check/liveform_browser_test.go TestBusyLeavesALiveFormItsScriptClaimed", }, } // TestOneOhScriptsAreUnchanged: every script 1.0.0 shipped is in js/ // byte for byte, or is exactly the bytes its changedOneOhScripts entry // names. 1.1 adds a script and changes none, and the 1.0 photographs // serve one js/ to both sides on the strength of that: a changed script // would be photographed on both sides at once and differ from neither, // which is why each fix names the test that holds it. func TestOneOhScriptsAreUnchanged(t *testing.T) { requireOneOhTag(t) used := map[string]bool{} for _, path := range taggedFiles(t, oneOhTag, "js") { b, err := os.ReadFile(filepath.FromSlash(path)) if err != nil { t.Errorf("%s: %v; %s shipped it, and a 1.0 page may load it", path, err, oneOhTag) continue } if bytes.Equal(b, gitOut(t, "show", oneOhTag+":"+path)) { continue } change, ok := changedOneOhScripts[path] switch sum := fmt.Sprintf("%x", sha256.Sum256(b)); { case !ok: t.Errorf("%s differs from %s:%s; a 1.x release changes no 1.0 script but by a changedOneOhScripts entry", path, oneOhTag, path) case sum != change.Sum: t.Errorf("%s differs from %s:%s and is not the fix its changedOneOhScripts entry names (SHA-256 %s, want %s); a further change is a new entry, with its reason and its test", path, oneOhTag, path, sum, change.Sum) default: used[path] = true } } for _, path := range sortedNames(changedOneOhScripts) { if !used[path] { t.Errorf("changedOneOhScripts[%q] is used by no changed script; an exception for nothing is a hole waiting to be used", path) } file, name, _ := strings.Cut(changedOneOhScripts[path].Test, " ") if source, err := os.ReadFile(filepath.FromSlash(file)); err != nil || !bytes.Contains(source, []byte("func "+name+"(t *testing.T)")) { t.Errorf("changedOneOhScripts[%q] names %s, which is not a test in %s; the fix must be held by a test of its own", path, name, file) } } }