| 1 | package fiesta |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/sha256" |
| 6 | "fmt" |
| 7 | "io/fs" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "regexp" |
| 12 | "sort" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | |
| 16 | "amadan.net/rastrillo/fiesta/internal/build" |
| 17 | "amadan.net/rastrillo/fiesta/internal/site" |
| 18 | ) |
| 19 | |
| 20 | // releases/ holds every release Fiesta has cut, one X.Y.Z/ directory |
| 21 | // each, written by fiesta release and committed. The site serves them |
| 22 | // as they are. These tests hold the directory to what it claims to |
| 23 | // be: the current version is there; while it is untagged it is what |
| 24 | // the tree builds today; once a version is tagged its directory is |
| 25 | // the tag's, file for file; and the site builds from all of them. |
| 26 | |
| 27 | // releasesDir is the committed release directory, relative to the |
| 28 | // module root, where this package's tests run. |
| 29 | const releasesDir = "releases" |
| 30 | |
| 31 | // gitOut runs git in the module root and returns its output, failing |
| 32 | // the test if git cannot answer. A check that read nothing would pass |
| 33 | // on nothing, so git failing is a failure, never a skip: ci_test.go |
| 34 | // already needs git here. |
| 35 | func gitOut(t *testing.T, args ...string) []byte { |
| 36 | t.Helper() |
| 37 | cmd := exec.Command("git", args...) |
| 38 | var stderr strings.Builder |
| 39 | cmd.Stderr = &stderr |
| 40 | out, err := cmd.Output() |
| 41 | if err != nil { |
| 42 | t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String()) |
| 43 | } |
| 44 | return out |
| 45 | } |
| 46 | |
| 47 | // requireThisRepository checks that the git the tests ask is this |
| 48 | // checkout's: the module root is the working tree's top, so a tag list |
| 49 | // is a list of Fiesta's tags, not of whatever repository holds a |
| 50 | // scratch copy. |
| 51 | func requireThisRepository(t *testing.T) { |
| 52 | t.Helper() |
| 53 | top := strings.TrimSpace(string(gitOut(t, "rev-parse", "--show-toplevel"))) |
| 54 | cwd, err := os.Getwd() |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if top, err = filepath.EvalSymlinks(top); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if cwd, err = filepath.EvalSymlinks(cwd); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | if top != cwd { |
| 65 | 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) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | // filesUnder reads every regular file under dir, keyed by its |
| 70 | // slash-separated path relative to dir. A missing dir is reported as |
| 71 | // such; anything that is not a regular file fails, since fiesta |
| 72 | // release writes nothing else. |
| 73 | func filesUnder(t *testing.T, dir string) (map[string][]byte, bool) { |
| 74 | t.Helper() |
| 75 | if _, err := os.Stat(dir); err != nil { |
| 76 | if os.IsNotExist(err) { |
| 77 | return nil, false |
| 78 | } |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | files := map[string][]byte{} |
| 82 | err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { |
| 83 | if err != nil { |
| 84 | return err |
| 85 | } |
| 86 | if d.IsDir() { |
| 87 | return nil |
| 88 | } |
| 89 | rel, err := filepath.Rel(dir, p) |
| 90 | if err != nil { |
| 91 | return err |
| 92 | } |
| 93 | if !d.Type().IsRegular() { |
| 94 | t.Errorf("%s is not a regular file, and a release holds nothing else", p) |
| 95 | return nil |
| 96 | } |
| 97 | b, err := os.ReadFile(p) |
| 98 | if err != nil { |
| 99 | return err |
| 100 | } |
| 101 | files[filepath.ToSlash(rel)] = b |
| 102 | return nil |
| 103 | }) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | return files, true |
| 108 | } |
| 109 | |
| 110 | // sortedNames is a map's keys in order, so a failure lists files the |
| 111 | // same way every run. |
| 112 | func sortedNames[V any](m map[string]V) []string { |
| 113 | names := make([]string, 0, len(m)) |
| 114 | for name := range m { |
| 115 | names = append(names, name) |
| 116 | } |
| 117 | sort.Strings(names) |
| 118 | return names |
| 119 | } |
| 120 | |
| 121 | // TestTheCurrentReleaseExists: releases/<Version>/ is there. A tree |
| 122 | // at a version with no release directory is a tree whose release was |
| 123 | // never cut, and the site would serve nothing under it. |
| 124 | func TestTheCurrentReleaseExists(t *testing.T) { |
| 125 | dir := filepath.Join(releasesDir, Version) |
| 126 | fi, err := os.Stat(dir) |
| 127 | if err != nil { |
| 128 | t.Fatalf("%s is not there: %v. Cut it with go run ./cmd/fiesta release -version %s", dir, err, Version) |
| 129 | } |
| 130 | if !fi.IsDir() { |
| 131 | t.Fatalf("%s is not a directory", dir) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // isTagged reports whether v<version> is a tag of this repository. |
| 136 | func isTagged(t *testing.T, version string) bool { |
| 137 | t.Helper() |
| 138 | return strings.TrimSpace(string(gitOut(t, "tag", "-l", "v"+version))) != "" |
| 139 | } |
| 140 | |
| 141 | // requireReleaseTags fails a run that is required to see the release |
| 142 | // tags and does not. Both tests below read the tags to decide what to |
| 143 | // check, and a clone without them (a runner's one-commit clone, which |
| 144 | // git gives no tags) would pass them on nothing: the tag check over no |
| 145 | // tag, and the fresh-dist check on a release that is in fact tagged. |
| 146 | // FIESTA_TEST_REQUIRE_TAGS is set by the Makefile, as the gate's other |
| 147 | // requirements are, and make history fetches the tags into a shallow |
| 148 | // clone before the suite runs. Required, git must see a vX.Y.Z tag for |
| 149 | // every directory under releases/ but the current version's, which may |
| 150 | // be an untagged candidate, and at least one tag in all. |
| 151 | func requireReleaseTags(t *testing.T) { |
| 152 | t.Helper() |
| 153 | if os.Getenv("FIESTA_TEST_REQUIRE_TAGS") == "" { |
| 154 | return |
| 155 | } |
| 156 | tags := map[string]bool{} |
| 157 | for _, line := range strings.Split(strings.TrimSpace(string(gitOut(t, "tag", "-l", "v*"))), "\n") { |
| 158 | if line != "" { |
| 159 | tags[line] = true |
| 160 | } |
| 161 | } |
| 162 | if len(tags) == 0 { |
| 163 | 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") |
| 164 | } |
| 165 | entries, err := os.ReadDir(releasesDir) |
| 166 | if err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | for _, e := range entries { |
| 170 | if e.IsDir() && e.Name() != Version && !tags["v"+e.Name()] { |
| 171 | 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()) |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // TestAnUntaggedReleaseIsAFreshDist: until v<Version> is tagged, the |
| 177 | // release directory is exactly what build.Dist makes from the tree |
| 178 | // today, byte for byte, with nothing missing and nothing extra. The |
| 179 | // one file not in the dist is fiesta release's own stamp at the top, |
| 180 | // which site.Release skips and never publishes. Once the tag exists, |
| 181 | // TestPublishedReleasesMatchTheirTags holds the directory to the tag |
| 182 | // instead, and a later change to the tree waits for the next version. |
| 183 | func TestAnUntaggedReleaseIsAFreshDist(t *testing.T) { |
| 184 | requireThisRepository(t) |
| 185 | requireReleaseTags(t) |
| 186 | if isTagged(t, Version) { |
| 187 | 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) |
| 188 | } |
| 189 | want, err := build.Dist(FS(), Version) |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | dir := filepath.Join(releasesDir, Version) |
| 194 | got, ok := filesUnder(t, dir) |
| 195 | if !ok { |
| 196 | t.Fatalf("%s is not there. Cut it with go run ./cmd/fiesta release -version %s", dir, Version) |
| 197 | } |
| 198 | delete(got, site.StampName) |
| 199 | for _, name := range sortedNames(want) { |
| 200 | b, ok := got[name] |
| 201 | switch { |
| 202 | case !ok: |
| 203 | t.Errorf("%s/%s is missing from the committed release", dir, name) |
| 204 | case !bytes.Equal(b, want[name]): |
| 205 | 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) |
| 206 | } |
| 207 | } |
| 208 | for _, name := range sortedNames(got) { |
| 209 | if _, ok := want[name]; !ok { |
| 210 | t.Errorf("%s/%s is not a file the tree builds", dir, name) |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // releaseTag is a release tag's name: vX.Y.Z, nothing else. A v* tag |
| 216 | // of any other shape is a mistake this repository has no use for. |
| 217 | var releaseTag = regexp.MustCompile(`^v([0-9]+\.[0-9]+\.[0-9]+)$`) |
| 218 | |
| 219 | // TestPublishedReleasesMatchTheirTags: for every tag vX.Y.Z, the |
| 220 | // current version included once it is tagged, releases/X.Y.Z/ exists |
| 221 | // in the working tree and holds exactly the files the tag holds under |
| 222 | // that path, byte for byte, the stamp included (site.TagDifferences). A missing directory, |
| 223 | // a missing file, a changed file and an extra file each fail: a |
| 224 | // published release never changes, and this is the check that says so |
| 225 | // on every push after the tag. |
| 226 | // |
| 227 | // With no tag yet there is nothing to compare, and the test is honest |
| 228 | // about that rather than vacuous: git must answer the tag list from |
| 229 | // this repository, or the test fails. |
| 230 | func TestPublishedReleasesMatchTheirTags(t *testing.T) { |
| 231 | requireThisRepository(t) |
| 232 | requireReleaseTags(t) |
| 233 | out := gitOut(t, "tag", "-l", "v*") |
| 234 | var tags []string |
| 235 | for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { |
| 236 | if line != "" { |
| 237 | tags = append(tags, line) |
| 238 | } |
| 239 | } |
| 240 | t.Logf("git tag -l 'v*' answered %d tags", len(tags)) |
| 241 | for _, tag := range tags { |
| 242 | m := releaseTag.FindStringSubmatch(tag) |
| 243 | if m == nil { |
| 244 | t.Errorf("tag %s is not vX.Y.Z; a release tag is nothing else", tag) |
| 245 | continue |
| 246 | } |
| 247 | version := m[1] |
| 248 | // The comparison fiesta site -published makes before it lets |
| 249 | // browsers keep a release for a year, so the two cannot drift. |
| 250 | diffs, err := site.TagDifferences(".", version, filepath.Join(releasesDir, version)) |
| 251 | if err != nil { |
| 252 | t.Fatalf("comparing %s with its tag: %v", version, err) |
| 253 | } |
| 254 | for _, d := range diffs { |
| 255 | t.Error(d) |
| 256 | } |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // TestTheSiteBuildsFromTheReleases: the committed releases and the |
| 261 | // pages assemble into the site, so what make site ships is what this |
| 262 | // commit holds, and the current release is served under its exact |
| 263 | // version with a /1/ rewrite onto it. |
| 264 | func TestTheSiteBuildsFromTheReleases(t *testing.T) { |
| 265 | files, err := site.Build(os.DirFS(releasesDir), os.DirFS("site"), site.Rules{}) |
| 266 | if err != nil { |
| 267 | t.Fatalf("the site does not build from %s/ and site/: %v", releasesDir, err) |
| 268 | } |
| 269 | if _, ok := files[Version+"/fiesta.css"]; !ok { |
| 270 | t.Errorf("the site has no %s/fiesta.css", Version) |
| 271 | } |
| 272 | major, _, _ := strings.Cut(Version, ".") |
| 273 | if rules := string(files["_redirects"]); !strings.Contains(rules, "/"+major+"/* /") { |
| 274 | t.Errorf("_redirects has no rule for /%s/*:\n%s", major, rules) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | // oneOhTag is the release the 1.x promise is held to: check's |
| 279 | // baseline is its CSS and its examples, and its scripts are the ones |
| 280 | // the photographs serve to both sides. |
| 281 | const oneOhTag = "v1.0.0" |
| 282 | |
| 283 | // requireOneOhTag fails a run whose clone cannot answer for v1.0.0: |
| 284 | // the two tests below compare against it, and a clone without it would |
| 285 | // compare nothing. |
| 286 | func requireOneOhTag(t *testing.T) { |
| 287 | t.Helper() |
| 288 | requireThisRepository(t) |
| 289 | requireReleaseTags(t) |
| 290 | if !isTagged(t, "1.0.0") { |
| 291 | t.Fatalf("git sees no tag %s in this clone; fetch the tags (make history does, in a shallow clone)", oneOhTag) |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | // taggedFiles lists the files tag holds under dir/, by path from the |
| 296 | // repository root. |
| 297 | func taggedFiles(t *testing.T, tag, dir string) []string { |
| 298 | t.Helper() |
| 299 | var out []string |
| 300 | for _, line := range strings.Split(strings.TrimSpace(string(gitOut(t, "ls-tree", "-r", "--name-only", tag, "--", dir+"/"))), "\n") { |
| 301 | if line != "" { |
| 302 | out = append(out, line) |
| 303 | } |
| 304 | } |
| 305 | if len(out) == 0 { |
| 306 | t.Fatalf("%s holds no %s/; the comparison would be over nothing", tag, dir) |
| 307 | } |
| 308 | return out |
| 309 | } |
| 310 | |
| 311 | // TestTheBaselineIsOneOhOh: check/testdata/seed/ is 1.0.0, byte for |
| 312 | // byte. The 1.x gates compare the tree's CSS with this copy and |
| 313 | // photograph this copy's examples, so a baseline that drifted would |
| 314 | // hold 1.x to something 1.0.0 never shipped, and a change to both at |
| 315 | // once would pass. tokens.css and shell.css are the tag's css/, the |
| 316 | // three themes its css/themes/, and examples/ the tag's examples/, file |
| 317 | // for file, with nothing missing. |
| 318 | func TestTheBaselineIsOneOhOh(t *testing.T) { |
| 319 | requireOneOhTag(t) |
| 320 | const seed = "check/testdata/seed" |
| 321 | want := map[string]string{ |
| 322 | seed + "/tokens.css": "css/tokens.css", |
| 323 | seed + "/shell.css": "css/shell.css", |
| 324 | } |
| 325 | for _, theme := range []string{"day", "plain", "signal"} { |
| 326 | want[seed+"/"+theme+".css"] = "css/themes/" + theme + ".css" |
| 327 | } |
| 328 | tagged := map[string]bool{} |
| 329 | for _, path := range taggedFiles(t, oneOhTag, "examples") { |
| 330 | want[seed+"/"+path] = path |
| 331 | tagged[path] = true |
| 332 | } |
| 333 | got, ok := filesUnder(t, filepath.FromSlash(seed+"/examples")) |
| 334 | if !ok { |
| 335 | t.Fatalf("%s/examples is missing; copy it with git archive %s examples | tar -x -C %s", seed, oneOhTag, seed) |
| 336 | } |
| 337 | for _, name := range sortedNames(got) { |
| 338 | if !tagged["examples/"+name] { |
| 339 | t.Errorf("%s/examples/%s is not in %s's examples/; the baseline holds only what 1.0.0 shipped", seed, name, oneOhTag) |
| 340 | } |
| 341 | } |
| 342 | for _, path := range sortedNames(want) { |
| 343 | b, err := os.ReadFile(filepath.FromSlash(path)) |
| 344 | if err != nil { |
| 345 | t.Errorf("%s: %v; %s holds %s", path, err, oneOhTag, want[path]) |
| 346 | continue |
| 347 | } |
| 348 | if !bytes.Equal(b, gitOut(t, "show", oneOhTag+":"+want[path])) { |
| 349 | t.Errorf("%s differs from %s:%s; the baseline is 1.0.0's and never changes", path, oneOhTag, want[path]) |
| 350 | } |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | // changedScript is a 1.0 script a 1.x release changes on purpose, to |
| 355 | // fix it: the SHA-256 of the bytes the fix leaves, so the next change |
| 356 | // to the file fails here again and is named in its own entry; why; and |
| 357 | // the test that holds the fixed behaviour, since the photographs serve |
| 358 | // the one js/ to both sides and cannot see a script change. |
| 359 | type changedScript struct { |
| 360 | Sum, Why, Test string |
| 361 | } |
| 362 | |
| 363 | // changedOneOhScripts is keyed by the script's path. Every entry must |
| 364 | // be used: a script with an entry that is 1.0.0's again is an exception |
| 365 | // for nothing. |
| 366 | var changedOneOhScripts = map[string]changedScript{ |
| 367 | "js/shell.js": { |
| 368 | Sum: "734771a617f39a340a1ce2ad11e08607aef191434631fbe913fc1d829b149fcd", |
| 369 | Why: "1.2.0 takes Rastrillo d9c9ed13, 023740c5, e5e35a3e and " + |
| 370 | "6b1c5478: a view transition the browser skips rejects its " + |
| 371 | "promises, which logged an uncaught AbortError, so shell.js " + |
| 372 | "swallows exactly that rejection and no other", |
| 373 | Test: "check/shell_test.go TestShellJSSwallowsOnlyASkippedTransition", |
| 374 | }, |
| 375 | "js/busy.js": { |
| 376 | Sum: "eb7210110c5ba6b23552407953bc97374f834787fcec411e643e7513524f69b7", |
| 377 | Why: "1.2.0 takes Rastrillo 8548e455: a data-live-form form is " + |
| 378 | "armed only once its server's script has left it to the " + |
| 379 | "browser, so busy.js no longer wipes the busy state of a " + |
| 380 | "form the script sends itself", |
| 381 | Test: "check/liveform_browser_test.go TestBusyLeavesALiveFormItsScriptClaimed", |
| 382 | }, |
| 383 | } |
| 384 | |
| 385 | // TestOneOhScriptsAreUnchanged: every script 1.0.0 shipped is in js/ |
| 386 | // byte for byte, or is exactly the bytes its changedOneOhScripts entry |
| 387 | // names. 1.1 adds a script and changes none, and the 1.0 photographs |
| 388 | // serve one js/ to both sides on the strength of that: a changed script |
| 389 | // would be photographed on both sides at once and differ from neither, |
| 390 | // which is why each fix names the test that holds it. |
| 391 | func TestOneOhScriptsAreUnchanged(t *testing.T) { |
| 392 | requireOneOhTag(t) |
| 393 | used := map[string]bool{} |
| 394 | for _, path := range taggedFiles(t, oneOhTag, "js") { |
| 395 | b, err := os.ReadFile(filepath.FromSlash(path)) |
| 396 | if err != nil { |
| 397 | t.Errorf("%s: %v; %s shipped it, and a 1.0 page may load it", path, err, oneOhTag) |
| 398 | continue |
| 399 | } |
| 400 | if bytes.Equal(b, gitOut(t, "show", oneOhTag+":"+path)) { |
| 401 | continue |
| 402 | } |
| 403 | change, ok := changedOneOhScripts[path] |
| 404 | switch sum := fmt.Sprintf("%x", sha256.Sum256(b)); { |
| 405 | case !ok: |
| 406 | t.Errorf("%s differs from %s:%s; a 1.x release changes no 1.0 script but by a changedOneOhScripts entry", path, oneOhTag, path) |
| 407 | case sum != change.Sum: |
| 408 | 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) |
| 409 | default: |
| 410 | used[path] = true |
| 411 | } |
| 412 | } |
| 413 | for _, path := range sortedNames(changedOneOhScripts) { |
| 414 | if !used[path] { |
| 415 | t.Errorf("changedOneOhScripts[%q] is used by no changed script; an exception for nothing is a hole waiting to be used", path) |
| 416 | } |
| 417 | file, name, _ := strings.Cut(changedOneOhScripts[path].Test, " ") |
| 418 | if source, err := os.ReadFile(filepath.FromSlash(file)); err != nil || !bytes.Contains(source, []byte("func "+name+"(t *testing.T)")) { |
| 419 | 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) |
| 420 | } |
| 421 | } |
| 422 | } |
| 423 | |