rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "amadan.net/rastrillo/fiesta"
15 "amadan.net/rastrillo/fiesta/internal/build"
16 "amadan.net/rastrillo/fiesta/internal/site"
17 "amadan.net/rastrillo/fiesta/internal/www"
18)
19
20// requireGitEnv names the environment variable that turns a missing
21// git from a skip into a failure, as FIESTA_TEST_REQUIRE_NODE does for
22// node. make ci sets it: the tag guard of fiesta release -candidate is
23// tested in a scratch repository, and a runner without git would
24// otherwise go green with the guard untested.
25const requireGitEnv = "FIESTA_TEST_REQUIRE_GIT"
26
27// requireGit skips the test when git is not on PATH, saying so on
28// stderr as well, since go test prints a skip's reason only under -v,
29// or fails it when requireGitEnv is set.
30func requireGit(t *testing.T) {
31 t.Helper()
32 if _, err := exec.LookPath("git"); err == nil {
33 return
34 }
35 msg := fmt.Sprintf("%s: git is not on PATH, so the tag guard of fiesta release -candidate goes unverified", t.Name())
36 if os.Getenv(requireGitEnv) != "" {
37 t.Fatalf("%s (%s is set, so this is a failure, not a skip)", msg, requireGitEnv)
38 }
39 fmt.Fprintln(os.Stderr, "SKIP "+msg)
40 t.Skip(msg)
41}
42
43// fiestaRelease runs `fiesta release -version <version> -releases
44// <releases> [-candidate]` as main would, and returns its error.
45func fiestaRelease(t *testing.T, releases, version string, candidate bool) error {
46 t.Helper()
47 args := []string{"release", "-version", version, "-releases", releases}
48 if candidate {
49 args = append(args, "-candidate")
50 }
51 return run(args, io.Discard)
52}
53
54// dist is the release the command should write at fiesta.Version.
55func dist(t *testing.T) map[string][]byte {
56 t.Helper()
57 d, err := build.Dist(fiesta.FS(), fiesta.Version)
58 if err != nil {
59 t.Fatal(err)
60 }
61 return d
62}
63
64// assertIsTheDist checks that dir holds the dist file for file, plus
65// fiesta release's stamp at the top, and nothing else: no leftovers.
66func assertIsTheDist(t *testing.T, dir string, d map[string][]byte) {
67 t.Helper()
68 for name, want := range d {
69 got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name)))
70 if err != nil {
71 t.Errorf("%s was not written: %v", name, err)
72 continue
73 }
74 if !bytes.Equal(got, want) {
75 t.Errorf("%s on disk differs from the dist", name)
76 }
77 }
78 if b, err := os.ReadFile(filepath.Join(dir, stampName)); err != nil || string(b) != stampBody("fiesta release") {
79 t.Errorf("the stamp is not fiesta release's: %q, %v", b, err)
80 }
81 var extra []string
82 err := filepath.WalkDir(dir, func(p string, e os.DirEntry, err error) error {
83 if err != nil {
84 return err
85 }
86 if e.IsDir() {
87 return nil
88 }
89 rel, _ := filepath.Rel(dir, p)
90 if rel == stampName {
91 return nil
92 }
93 if _, ok := d[filepath.ToSlash(rel)]; !ok {
94 extra = append(extra, rel)
95 }
96 return nil
97 })
98 if err != nil {
99 t.Fatal(err)
100 }
101 if len(extra) > 0 {
102 t.Errorf("%s holds files that are not in the dist: %s", dir, strings.Join(extra, ", "))
103 }
104}
105
106// age turns the release in dir into an older candidate: one built from
107// a tree without NOTICE, with the sums to match. It is still
108// self-consistent and still stamped, and it is not what the tree
109// builds now, so NOTICE coming back is the sign of a rebuild and its
110// absence the sign of a refusal that touched nothing.
111func age(t *testing.T, dir string, d map[string][]byte) {
112 t.Helper()
113 if err := os.Remove(filepath.Join(dir, "NOTICE")); err != nil {
114 t.Fatal(err)
115 }
116 var kept []string
117 for _, line := range strings.SplitAfter(string(d["SHA256SUMS"]), "\n") {
118 if line != "" && !strings.HasSuffix(line, " NOTICE\n") {
119 kept = append(kept, line)
120 }
121 }
122 if err := os.WriteFile(filepath.Join(dir, "SHA256SUMS"), []byte(strings.Join(kept, "")), 0o644); err != nil {
123 t.Fatal(err)
124 }
125}
126
127// untouched asserts an aged candidate was not rebuilt.
128func untouched(t *testing.T, dir, what string) {
129 t.Helper()
130 if _, err := os.Stat(filepath.Join(dir, "NOTICE")); !os.IsNotExist(err) {
131 t.Errorf("after %s the candidate was rebuilt: NOTICE is back", what)
132 }
133}
134
135// TestReleaseRefusesToOverwrite: a version other than fiesta.Version
136// is refused and nothing is created; the first run writes the dist,
137// file for file, with the command's stamp; a second run finds the
138// directory and refuses it, leaving it exactly as it was.
139func TestReleaseRefusesToOverwrite(t *testing.T) {
140 releases := filepath.Join(t.TempDir(), "releases")
141 err := fiestaRelease(t, releases, "9.9.9", false)
142 if err == nil {
143 t.Fatal("fiesta release built 9.9.9, which is not fiesta.Version")
144 }
145 if !strings.Contains(err.Error(), fiesta.Version) {
146 t.Errorf("the refusal does not name the version it would build, %s: %v", fiesta.Version, err)
147 }
148 if _, err := os.Stat(releases); !os.IsNotExist(err) {
149 t.Errorf("a refused release created %s: %v", releases, err)
150 }
151
152 if err := fiestaRelease(t, releases, fiesta.Version, false); err != nil {
153 t.Fatalf("fiesta release into an absent directory: %v", err)
154 }
155 dir := filepath.Join(releases, fiesta.Version)
156 assertIsTheDist(t, dir, dist(t))
157
158 css := filepath.Join(dir, "fiesta.css")
159 if err := os.WriteFile(css, []byte("mine"), 0o644); err != nil {
160 t.Fatal(err)
161 }
162 err = fiestaRelease(t, releases, fiesta.Version, false)
163 if err == nil {
164 t.Fatalf("fiesta release overwrote %s, which already exists", dir)
165 }
166 if !strings.Contains(err.Error(), dir) {
167 t.Errorf("the refusal does not name %s: %v", dir, err)
168 }
169 if !strings.Contains(err.Error(), "-candidate") {
170 t.Errorf("the refusal does not say how a candidate may be replaced: %v", err)
171 }
172 if b, err := os.ReadFile(css); err != nil || string(b) != "mine" {
173 t.Errorf("the refused directory was touched: fiesta.css is %q, %v", b, err)
174 }
175}
176
177// git runs git in dir for the test's own setup, with no user or system
178// configuration so the machine's settings cannot change what it does,
179// and fails the test on error.
180func git(t *testing.T, dir string, args ...string) string {
181 t.Helper()
182 cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "init.defaultBranch=main"}, args...)...)
183 cmd.Dir = dir
184 cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1")
185 out, err := cmd.CombinedOutput()
186 if err != nil {
187 t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
188 }
189 return string(out)
190}
191
192// scratchRepo is a git repository with one commit and an origin that
193// is a bare repository beside it, so pushing a tag never leaves the
194// machine.
195func scratchRepo(t *testing.T) string {
196 t.Helper()
197 requireGit(t)
198 root := t.TempDir()
199 repo := filepath.Join(root, "repo")
200 origin := filepath.Join(root, "origin.git")
201 if err := os.Mkdir(repo, 0o755); err != nil {
202 t.Fatal(err)
203 }
204 git(t, root, "init", "-q", "--bare", origin)
205 git(t, repo, "init", "-q")
206 if err := os.WriteFile(filepath.Join(repo, "README"), []byte("scratch\n"), 0o644); err != nil {
207 t.Fatal(err)
208 }
209 git(t, repo, "add", "README")
210 git(t, repo, "commit", "-q", "-m", "scratch")
211 git(t, repo, "remote", "add", "origin", origin)
212 git(t, repo, "push", "-q", "origin", "main")
213 return repo
214}
215
216// TestACandidateMayBeRebuiltUntilItIsTagged: -candidate replaces an
217// existing release directory whole, but only while it carries fiesta
218// release's own stamp and no tag vX.Y.Z names it, locally or on
219// origin. A stray file in a directory the command owns is replaced
220// with the rest; a link inside, another command's stamp, a missing
221// stamp, a local tag, or a tag on origin alone, each refuses the
222// rebuild and leaves the directory as it was.
223func TestACandidateMayBeRebuiltUntilItIsTagged(t *testing.T) {
224 repo := scratchRepo(t)
225 releases := filepath.Join(repo, "releases")
226 dir := filepath.Join(releases, fiesta.Version)
227 d := dist(t)
228 tag := "v" + fiesta.Version
229 stamp := filepath.Join(dir, stampName)
230
231 if err := fiestaRelease(t, releases, fiesta.Version, true); err != nil {
232 t.Fatalf("fiesta release -candidate into an absent directory: %v", err)
233 }
234 assertIsTheDist(t, dir, d)
235
236 age(t, dir, d)
237 if err := fiestaRelease(t, releases, fiesta.Version, true); err != nil {
238 t.Fatalf("fiesta release -candidate refused an untagged candidate: %v", err)
239 }
240 assertIsTheDist(t, dir, d)
241
242 age(t, dir, d)
243 stray := filepath.Join(dir, "notes.txt")
244 if err := os.WriteFile(stray, []byte("mine"), 0o644); err != nil {
245 t.Fatal(err)
246 }
247 if err := fiestaRelease(t, releases, fiesta.Version, true); err != nil {
248 t.Fatalf("fiesta release -candidate refused its own directory over a stray file: %v", err)
249 }
250 if _, err := os.Stat(stray); !os.IsNotExist(err) {
251 t.Errorf("the stray file survived a rebuild of a directory the command owns: %v", err)
252 }
253 assertIsTheDist(t, dir, d)
254
255 age(t, dir, d)
256 link := filepath.Join(dir, "LICENSE")
257 if err := os.Remove(link); err != nil {
258 t.Fatal(err)
259 }
260 if err := os.Symlink(filepath.Join(repo, "README"), link); err != nil {
261 t.Fatal(err)
262 }
263 err := fiestaRelease(t, releases, fiesta.Version, true)
264 if err == nil {
265 t.Fatal("fiesta release -candidate replaced a directory holding a symbolic link")
266 }
267 if !strings.Contains(err.Error(), "LICENSE") {
268 t.Errorf("the refusal does not name the link: %v", err)
269 }
270 untouched(t, dir, "a link")
271 if err := os.Remove(link); err != nil {
272 t.Fatal(err)
273 }
274 if err := os.WriteFile(link, d["LICENSE"], 0o644); err != nil {
275 t.Fatal(err)
276 }
277
278 if err := os.WriteFile(stamp, []byte(stampBody("fiesta build")), 0o644); err != nil {
279 t.Fatal(err)
280 }
281 err = fiestaRelease(t, releases, fiesta.Version, true)
282 if err == nil {
283 t.Fatal("fiesta release -candidate replaced a directory stamped by fiesta build")
284 }
285 if !strings.Contains(err.Error(), "fiesta build") {
286 t.Errorf("the refusal does not name the command that owns the directory: %v", err)
287 }
288 untouched(t, dir, "another command's stamp")
289
290 if err := os.Remove(stamp); err != nil {
291 t.Fatal(err)
292 }
293 err = fiestaRelease(t, releases, fiesta.Version, true)
294 if err == nil {
295 t.Fatal("fiesta release -candidate replaced a directory with no stamp")
296 }
297 if !strings.Contains(err.Error(), stampName) {
298 t.Errorf("the refusal does not name the missing stamp: %v", err)
299 }
300 untouched(t, dir, "a missing stamp")
301 if err := os.WriteFile(stamp, []byte(stampBody("fiesta release")), 0o644); err != nil {
302 t.Fatal(err)
303 }
304
305 git(t, repo, "tag", tag)
306 err = fiestaRelease(t, releases, fiesta.Version, true)
307 if err == nil {
308 t.Fatalf("fiesta release -candidate replaced %s, which the local tag %s names", dir, tag)
309 }
310 if !strings.Contains(err.Error(), tag) {
311 t.Errorf("the refusal does not name the tag: %v", err)
312 }
313 untouched(t, dir, "a local tag")
314
315 git(t, repo, "push", "-q", "origin", tag)
316 git(t, repo, "tag", "-d", tag)
317 if out := git(t, repo, "tag", "-l", tag); strings.TrimSpace(out) != "" {
318 t.Fatalf("the local tag is still there: %q", out)
319 }
320 err = fiestaRelease(t, releases, fiesta.Version, true)
321 if err == nil {
322 t.Fatalf("fiesta release -candidate replaced %s, which origin's tag %s names", dir, tag)
323 }
324 if !strings.Contains(err.Error(), tag) {
325 t.Errorf("the refusal does not name the tag: %v", err)
326 }
327 untouched(t, dir, "a tag on origin")
328
329 err = fiestaRelease(t, releases, fiesta.Version, false)
330 if err == nil {
331 t.Fatal("fiesta release without -candidate replaced an existing directory")
332 }
333 untouched(t, dir, "a run without -candidate")
334}
335
336// TestAnUnreachableOriginRefusesACandidate: when git cannot say
337// whether origin has the tag, the answer is a refusal, not "no". The
338// origin is pointed at a path that does not exist, and the aged
339// candidate stays as it was.
340func TestAnUnreachableOriginRefusesACandidate(t *testing.T) {
341 repo := scratchRepo(t)
342 releases := filepath.Join(repo, "releases")
343 dir := filepath.Join(releases, fiesta.Version)
344 d := dist(t)
345 if err := fiestaRelease(t, releases, fiesta.Version, true); err != nil {
346 t.Fatal(err)
347 }
348 age(t, dir, d)
349 git(t, repo, "remote", "set-url", "origin", filepath.Join(t.TempDir(), "gone.git"))
350 err := fiestaRelease(t, releases, fiesta.Version, true)
351 if err == nil {
352 t.Fatalf("fiesta release -candidate replaced %s without being able to ask origin about its tag", dir)
353 }
354 if !strings.Contains(err.Error(), "could not tell") || !strings.Contains(err.Error(), "v"+fiesta.Version) {
355 t.Errorf("the refusal does not say the tag could not be checked: %v", err)
356 }
357 untouched(t, dir, "an unreachable origin")
358}
359
360// TestSiteWritesWhatBuildReturns is a preservation test: `fiesta site
361// -releases R -site S -out DIR` writes site.Build's tree into
362// DIR/public, file for file, stamps DIR itself as its own, and leaves
363// nothing hidden in the tree to ship. The pages are what www.Pages
364// assembles from S and their rules www.Redirects, so the expected tree
365// is computed the same way.
366func TestSiteWritesWhatBuildReturns(t *testing.T) {
367 root := t.TempDir()
368 releases := filepath.Join(root, "releases")
369 if err := fiestaRelease(t, releases, fiesta.Version, false); err != nil {
370 t.Fatal(err)
371 }
372 siteDir := filepath.Join(root, "site")
373 siteFixture(t, siteDir)
374 out := filepath.Join(root, "out", "site")
375 if err := run([]string{"site", "-releases", releases, "-site", siteDir, "-out", out}, io.Discard); err != nil {
376 t.Fatalf("fiesta site: %v", err)
377 }
378 pages, err := www.Pages(fiesta.FS(), os.DirFS(siteDir))
379 if err != nil {
380 t.Fatal(err)
381 }
382 want, err := site.Build(os.DirFS(releases), www.FS(pages), www.Rules(pages))
383 if err != nil {
384 t.Fatal(err)
385 }
386 if len(want) == 0 {
387 t.Fatal("the site is empty")
388 }
389 shipped := filepath.Join(out, shipDir)
390 for name, data := range want {
391 got, err := os.ReadFile(filepath.Join(shipped, filepath.FromSlash(name)))
392 if err != nil {
393 t.Errorf("the site did not write %s: %v", name, err)
394 continue
395 }
396 if !bytes.Equal(got, data) {
397 t.Errorf("%s on disk differs from the build", name)
398 }
399 }
400 major, _, _ := strings.Cut(fiesta.Version, ".")
401 rules := "/" + major + "/* /" + fiesta.Version + "/:splat 200\n" + strings.Join(www.Redirects(pages), "\n") + "\n"
402 if got := string(want["_redirects"]); got != rules {
403 t.Errorf("_redirects is %q, want the release rewrite and then the design system's rules", got)
404 }
405 if b, err := os.ReadFile(filepath.Join(out, stampName)); err != nil || string(b) != stampBody("fiesta site") {
406 t.Errorf("the stamp is not fiesta site's, or is not in -out itself: %q, %v", b, err)
407 }
408 var written int
409 err = filepath.WalkDir(shipped, func(p string, e os.DirEntry, err error) error {
410 if err != nil {
411 return err
412 }
413 if p != shipped && strings.HasPrefix(e.Name(), ".") {
414 t.Errorf("%s is hidden and sits in the tree to ship", p)
415 }
416 if !e.IsDir() {
417 written++
418 }
419 return nil
420 })
421 if err != nil {
422 t.Fatal(err)
423 }
424 if written != len(want) {
425 t.Errorf("%s holds %d files, the build has %d", shipped, written, len(want))
426 }
427 if entries, err := os.ReadDir(out); err != nil || len(entries) != 2 {
428 t.Errorf("-out holds %d entries, want the stamp and %s: %v", len(entries), shipDir, err)
429 }
430}
431
432// siteFixture writes a whole site source into siteDir: one static
433// file, the landing page's catalogs and the repository's own template.
434func siteFixture(t *testing.T, siteDir string) {
435 t.Helper()
436 if err := os.MkdirAll(filepath.Join(siteDir, "static"), 0o755); err != nil {
437 t.Fatal(err)
438 }
439 if err := os.WriteFile(filepath.Join(siteDir, "static", "x.css"), []byte("body{margin:0}\n"), 0o644); err != nil {
440 t.Fatal(err)
441 }
442 // The landing page's catalogs, one per language, each complete and
443 // valued by its keys: a stand-in for site/landing/strings/ that
444 // www.Strings accepts, so the fixture is a whole site source.
445 catalogs := filepath.Join(siteDir, "landing", "strings")
446 if err := os.MkdirAll(catalogs, 0o755); err != nil {
447 t.Fatal(err)
448 }
449 catalog := map[string]string{}
450 for _, key := range www.RequiredKeys {
451 catalog[key] = key
452 }
453 stub, err := json.Marshal(catalog)
454 if err != nil {
455 t.Fatal(err)
456 }
457 for _, lang := range www.Languages {
458 if err := os.WriteFile(filepath.Join(catalogs, lang+".json"), stub, 0o644); err != nil {
459 t.Fatal(err)
460 }
461 }
462 // The landing page's template, the repository's own: what the
463 // catalogs fill in.
464 tmpl, err := os.ReadFile(filepath.Join("..", "..", "site", "landing", "page.html.tmpl"))
465 if err != nil {
466 t.Fatal(err)
467 }
468 if err := os.WriteFile(filepath.Join(siteDir, "landing", "page.html.tmpl"), tmpl, 0o644); err != nil {
469 t.Fatal(err)
470 }
471}
472