rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package fiesta
2
3import (
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.
29const 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.
35func 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.
51func 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.
73func 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.
112func 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.
124func 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.
136func 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.
151func 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.
183func 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.
217var 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.
230func 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.
264func 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.
281const 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.
286func 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.
297func 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.
318func 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.
359type 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.
366var 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.
391func 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