rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package build
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "encoding/xml"
9 "fmt"
10 "io/fs"
11 "math/rand"
12 "sort"
13 "strings"
14 "testing"
15 "testing/fstest"
16
17 "amadan.net/rastrillo/fiesta"
18 "amadan.net/rastrillo/fiesta/internal/nodetest"
19)
20
21// The release is a function of the embedded tree, so every test here
22// but one runs Dist on a MapFS copied from that tree, which a test can
23// then alter or leave a file out of. The one exception is the size
24// gate, TestTheOneLinkIsUnder25KiBGzipped, whose claim is about the
25// real tree and nothing else.
26
27// bundles is each one-file theme and the theme it carries.
28var bundles = map[string]string{"fiesta.css": "day", "fiesta-plain.css": "plain", "fiesta-signal.css": "signal"}
29
30// bundleNames is bundles' keys, sorted, for a stable order of
31// failures and figures.
32func bundleNames() []string {
33 names := make([]string, 0, len(bundles))
34 for n := range bundles {
35 names = append(names, n)
36 }
37 sort.Strings(names)
38 return names
39}
40
41// sourceFS is the embedded tree's build inputs as a MapFS: LICENSE,
42// css/, js/, icons/ and i18n/, byte for byte.
43func sourceFS(t *testing.T) fstest.MapFS {
44 t.Helper()
45 m := fstest.MapFS{}
46 err := fs.WalkDir(fiesta.FS(), ".", func(p string, d fs.DirEntry, err error) error {
47 if err != nil {
48 return err
49 }
50 if d.IsDir() {
51 return nil
52 }
53 top, _, _ := strings.Cut(p, "/")
54 switch top {
55 case "LICENSE", "css", "js", "icons", "i18n":
56 b, err := fs.ReadFile(fiesta.FS(), p)
57 if err != nil {
58 return err
59 }
60 m[p] = &fstest.MapFile{Data: b}
61 }
62 return nil
63 })
64 if err != nil {
65 t.Fatal(err)
66 }
67 if len(m) == 0 {
68 t.Fatal("the embedded tree copied as nothing")
69 }
70 return m
71}
72
73// dist builds the release from src at the shipped version, failing
74// the test on an error.
75func dist(t *testing.T, src fs.FS) map[string][]byte {
76 t.Helper()
77 d, err := Dist(src, fiesta.Version)
78 if err != nil {
79 t.Fatalf("Dist: %v", err)
80 }
81 return d
82}
83
84// must is one file of a dist, failing the test when it is absent.
85func must(t *testing.T, d map[string][]byte, name string) []byte {
86 t.Helper()
87 b, ok := d[name]
88 if !ok {
89 t.Fatalf("the dist has no %s", name)
90 }
91 return b
92}
93
94// expectedFiles is every path the release holds, from the tree's own
95// lists: the licences, the sums, the sprite, the three one-file
96// themes, the separate stylesheets, every script, every language as
97// JSON and as a script, and every icon standalone.
98func expectedFiles() []string {
99 names := []string{"LICENSE", "NOTICE", "SHA256SUMS", "icons.svg", "tokens.css", "shell.css"}
100 for b, theme := range bundles {
101 names = append(names, b, theme+".css")
102 }
103 names = append(names, fiesta.ScriptNames()...)
104 for _, lang := range fiesta.Languages() {
105 names = append(names, "i18n/"+lang+".json", "i18n/"+lang+".js")
106 }
107 for _, slug := range fiesta.IconSlugs() {
108 names = append(names, "icons/"+slug+".svg")
109 }
110 sort.Strings(names)
111 return names
112}
113
114// scriptsIn counts the dist's scripts outside i18n/, which is at
115// least the shipped scripts.
116func scriptsIn(d map[string][]byte) int {
117 n := 0
118 for name := range d {
119 if strings.HasSuffix(name, ".js") && !strings.HasPrefix(name, "i18n/") {
120 n++
121 }
122 }
123 return n
124}
125
126// TestTheOneLinkIsUnder25KiBGzipped is the size gate, and `make size`
127// runs it alone: each one-file theme built from the real tree exists,
128// is not empty, and gzips under OneLinkLimit. The three figures are
129// logged so the gate's output says what the page costs today.
130func TestTheOneLinkIsUnder25KiBGzipped(t *testing.T) {
131 d := dist(t, fiesta.FS())
132 for _, name := range bundleNames() {
133 b := must(t, d, name)
134 if len(b) == 0 {
135 t.Fatalf("%s is empty", name)
136 }
137 g := Gzipped(b)
138 t.Logf("%s: %d bytes, %d bytes gzipped (%.1f KiB of the %.1f KiB limit)", name, len(b), g, float64(g)/1024, float64(OneLinkLimit)/1024)
139 }
140 if err := CheckSize(d); err != nil {
141 t.Fatal(err)
142 }
143}
144
145// incompressible is n bytes a fixed seed produced, which gzip cannot
146// shrink, trimmed from the front until the compressed length lands
147// on target exactly.
148func incompressible(t *testing.T, target int) []byte {
149 t.Helper()
150 r := rand.New(rand.NewSource(20261027))
151 buf := make([]byte, target+64)
152 r.Read(buf)
153 for n := target; n >= 0; n-- {
154 if Gzipped(buf[:n]) == target {
155 return buf[:n]
156 }
157 }
158 t.Fatalf("no prefix of the random bytes gzips to exactly %d", target)
159 return nil
160}
161
162// gatedDist is a dist of the three themes' own files, each bundle
163// holding the bytes given: the shape CheckSize reads its bundle list
164// from.
165func gatedDist(bundle []byte) map[string][]byte {
166 d := map[string][]byte{}
167 for name, theme := range bundles {
168 d[theme+".css"] = []byte(":root{}")
169 d[name] = bundle
170 }
171 return d
172}
173
174// TestTheSizeGateFailsAtTheLine holds CheckSize to its line, one
175// bundle at a time: a dist whose three bundles gzip to 25,599 bytes
176// passes, and one with any bundle at 25,600 fails, naming the file
177// and giving both figures. The bundles the gate holds are the themes
178// the dist carries, so a theme file with no bundle fails it by the
179// bundle's name, and a dist with no theme fails it outright.
180func TestTheSizeGateFailsAtTheLine(t *testing.T) {
181 under := incompressible(t, OneLinkLimit-1)
182 over := incompressible(t, OneLinkLimit)
183 if g := Gzipped(under); g != OneLinkLimit-1 {
184 t.Fatalf("the under bundle gzips to %d, want %d", g, OneLinkLimit-1)
185 }
186 if g := Gzipped(over); g != OneLinkLimit {
187 t.Fatalf("the over bundle gzips to %d, want %d", g, OneLinkLimit)
188 }
189 for _, name := range bundleNames() {
190 d := gatedDist(under)
191 if err := CheckSize(d); err != nil {
192 t.Errorf("%s at %d gzipped bytes failed the gate: %v", name, OneLinkLimit-1, err)
193 }
194 d[name] = over
195 err := CheckSize(d)
196 if err == nil {
197 t.Errorf("%s at %d gzipped bytes passed the gate; the line is %d", name, OneLinkLimit, OneLinkLimit)
198 continue
199 }
200 if !strings.Contains(err.Error(), name) {
201 t.Errorf("the gate's error does not name %s: %v", name, err)
202 }
203 }
204
205 // The figures: the measured size and the limit, each in bytes and
206 // in KiB, with a bundle far enough over the line that the two
207 // differ in both units.
208 wide := incompressible(t, OneLinkLimit+500)
209 d := gatedDist(under)
210 d["fiesta.css"] = wide
211 err := CheckSize(d)
212 if err == nil {
213 t.Fatalf("fiesta.css at %d gzipped bytes passed the gate", OneLinkLimit+500)
214 }
215 for _, want := range []string{fmt.Sprintf("%d bytes gzipped (25.5 KiB)", OneLinkLimit+500), fmt.Sprintf("limit is %d (25.0 KiB)", OneLinkLimit)} {
216 if !strings.Contains(err.Error(), want) {
217 t.Errorf("the gate's error does not say %q: %v", want, err)
218 }
219 }
220
221 // A fourth theme is gated by its bundle, present or not.
222 d = gatedDist(under)
223 d["extra.css"] = []byte(":root{}")
224 if err := CheckSize(d); err == nil || !strings.Contains(err.Error(), "fiesta-extra.css") {
225 t.Errorf("a theme with no bundle passed the gate, or the error does not name fiesta-extra.css: %v", err)
226 }
227 d["fiesta-extra.css"] = over
228 if err := CheckSize(d); err == nil || !strings.Contains(err.Error(), "fiesta-extra.css") {
229 t.Errorf("a fourth theme's bundle at the line passed the gate, or the error does not name it: %v", err)
230 }
231 d["fiesta-extra.css"] = under
232 if err := CheckSize(d); err != nil {
233 t.Errorf("a fourth theme's bundle under the line failed the gate: %v", err)
234 }
235 if err := CheckSize(map[string][]byte{"fiesta.css": under}); err == nil {
236 t.Error("a dist with no theme file passed the gate")
237 }
238}
239
240// TestTheBundlesAreTokensThenThemeStripped: each one-file theme is the
241// banner, then tokens.css and the theme joined by a newline and
242// stripped of comments, and nothing else. The separate files are the
243// banner and the stripped file.
244func TestTheBundlesAreTokensThenThemeStripped(t *testing.T) {
245 src := sourceFS(t)
246 d := dist(t, src)
247 tokens, err := fs.ReadFile(src, "css/tokens.css")
248 if err != nil {
249 t.Fatal(err)
250 }
251 for _, name := range bundleNames() {
252 theme, err := fs.ReadFile(src, "css/themes/"+bundles[name]+".css")
253 if err != nil {
254 t.Fatal(err)
255 }
256 joined := append(append(append([]byte{}, tokens...), '\n'), theme...)
257 want := append([]byte(Banner(fiesta.Version)), stripCSSComments(joined)...)
258 if got := must(t, d, name); !bytes.Equal(got, want) {
259 t.Errorf("%s is not banner + strip(tokens.css + \"\\n\" + %s.css): %d bytes, want %d", name, bundles[name], len(got), len(want))
260 }
261 want = append([]byte(Banner(fiesta.Version)), stripCSSComments(theme)...)
262 if got := must(t, d, bundles[name]+".css"); !bytes.Equal(got, want) {
263 t.Errorf("%s.css is not banner + strip(%s.css)", bundles[name], bundles[name])
264 }
265 }
266 for _, name := range []string{"tokens.css", "shell.css"} {
267 in, err := fs.ReadFile(src, "css/"+name)
268 if err != nil {
269 t.Fatal(err)
270 }
271 want := append([]byte(Banner(fiesta.Version)), stripCSSComments(in)...)
272 if got := must(t, d, name); !bytes.Equal(got, want) {
273 t.Errorf("%s is not banner + strip(css/%s)", name, name)
274 }
275 }
276 for _, name := range fiesta.ScriptNames() {
277 in, err := fs.ReadFile(src, "js/"+name)
278 if err != nil {
279 t.Fatal(err)
280 }
281 want := append([]byte(Banner(fiesta.Version)), stripJSComments(in)...)
282 if got := must(t, d, name); !bytes.Equal(got, want) {
283 t.Errorf("%s is not banner + stripJS(js/%s)", name, name)
284 }
285 }
286}
287
288// TestDistNeedsOnlyItsFS: Dist reads src and nothing else, so a src
289// without LICENSE is an error that says so, not a dist that found
290// the file somewhere else.
291func TestDistNeedsOnlyItsFS(t *testing.T) {
292 src := sourceFS(t)
293 if _, ok := src["LICENSE"]; !ok {
294 t.Fatal("the source has no LICENSE to leave out")
295 }
296 delete(src, "LICENSE")
297 _, err := Dist(src, fiesta.Version)
298 if err == nil {
299 t.Fatal("Dist built a release from a tree with no LICENSE")
300 }
301 if !strings.Contains(err.Error(), "LICENSE") {
302 t.Errorf("the error does not name LICENSE: %v", err)
303 }
304}
305
306// parseJS is the Node side of TestTheDistHasNoComments: each source
307// compiled, and the error for one that does not parse.
308const parseJS = `
309const vm = require("vm");
310const srcs = JSON.parse(require("fs").readFileSync(0, "utf8"));
311process.stdout.write(JSON.stringify(srcs.map(s => { try { new vm.Script(s); return ""; } catch (e) { return e.name + ": " + e.message; } })));
312`
313
314// TestTheDistHasNoComments: past the banner, no stylesheet holds a
315// comment opener, and every script, i18n/ included, still parses.
316func TestTheDistHasNoComments(t *testing.T) {
317 d := dist(t, sourceFS(t))
318 banner := Banner(fiesta.Version)
319 var names []string
320 for name := range d {
321 names = append(names, name)
322 }
323 sort.Strings(names)
324 stylesheets, scripts := 0, 0
325 var srcs []string
326 var scriptNames []string
327 for _, name := range names {
328 switch {
329 case strings.HasSuffix(name, ".css"):
330 stylesheets++
331 body := d[name]
332 if !bytes.HasPrefix(body, []byte(banner)) {
333 t.Errorf("%s does not open with the banner", name)
334 continue
335 }
336 if bytes.Contains(body[len(banner):], []byte("/*")) {
337 t.Errorf("%s still holds a comment after the banner", name)
338 }
339 case strings.HasSuffix(name, ".js"):
340 scripts++
341 if !bytes.HasPrefix(d[name], []byte(banner)) && !strings.HasPrefix(name, "i18n/") {
342 t.Errorf("%s does not open with the banner", name)
343 }
344 srcs = append(srcs, string(d[name]))
345 scriptNames = append(scriptNames, name)
346 }
347 }
348 if stylesheets == 0 {
349 t.Fatal("the dist has no stylesheet")
350 }
351 if scriptsIn(d) < len(fiesta.ScriptNames()) {
352 t.Fatalf("the dist has %d scripts, fewer than the %d shipped", scriptsIn(d), len(fiesta.ScriptNames()))
353 }
354 in, err := json.Marshal(srcs)
355 if err != nil {
356 t.Fatal(err)
357 }
358 var errs []string
359 if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", parseJS}, Stdin: in}), &errs); err != nil {
360 t.Fatal(err)
361 }
362 for i, e := range errs {
363 if e != "" {
364 t.Errorf("%s does not parse stripped: %s", scriptNames[i], e)
365 }
366 }
367}
368
369// TestTheSumsDescribeTheDist: SHA256SUMS is one line per file but
370// itself, sorted by path, each the file's sha256 in the two-space
371// form sha256sum -c reads.
372func TestTheSumsDescribeTheDist(t *testing.T) {
373 d := dist(t, sourceFS(t))
374 sums := string(must(t, d, "SHA256SUMS"))
375 var names []string
376 for name := range d {
377 if name != "SHA256SUMS" {
378 names = append(names, name)
379 }
380 }
381 sort.Strings(names)
382 var want []string
383 for _, name := range names {
384 sum := sha256.Sum256(d[name])
385 want = append(want, hex.EncodeToString(sum[:])+" "+name)
386 }
387 if len(want) < 2 {
388 t.Fatalf("the dist has %d files besides the sums", len(want))
389 }
390 got := strings.Split(strings.TrimSuffix(sums, "\n"), "\n")
391 if strings.Join(got, "\n") != strings.Join(want, "\n") {
392 t.Errorf("SHA256SUMS is not one sorted line per file:\n got:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
393 }
394}
395
396// sprite is icons.svg as encoding/xml reads it.
397type sprite struct {
398 XMLName xml.Name `xml:"svg"`
399 Symbols []struct {
400 ID string `xml:"id,attr"`
401 ViewBox string `xml:"viewBox,attr"`
402 Fill string `xml:"fill,attr"`
403 Stroke string `xml:"stroke,attr"`
404 Width string `xml:"stroke-width,attr"`
405 Linecap string `xml:"stroke-linecap,attr"`
406 Linejoin string `xml:"stroke-linejoin,attr"`
407 Inner string `xml:",innerxml"`
408 } `xml:"symbol"`
409}
410
411// iconInner is what sits between an inline icon's root tags, read
412// here on its own so the sprite is checked against the icon, not
413// against the build's reading of it.
414func iconInner(t *testing.T, svg []byte) string {
415 t.Helper()
416 s := strings.TrimSpace(string(svg))
417 open := strings.IndexByte(s, '>')
418 if !strings.HasPrefix(s, "<svg") || open < 0 || !strings.HasSuffix(s, "</svg>") {
419 t.Fatalf("not an inline icon: %q", s)
420 }
421 return strings.TrimSpace(s[open+1 : len(s)-len("</svg>")])
422}
423
424// TestTheSpriteHasEverySlug: icons.svg is one SVG document in the SVG
425// namespace holding a symbol per slug, each with the icon's own
426// viewBox and stroke attributes and the icon's children inside.
427func TestTheSpriteHasEverySlug(t *testing.T) {
428 src := sourceFS(t)
429 d := dist(t, src)
430 var s sprite
431 if err := xml.Unmarshal(must(t, d, "icons.svg"), &s); err != nil {
432 t.Fatalf("icons.svg does not parse: %v", err)
433 }
434 if s.XMLName.Space != svgNS {
435 t.Errorf("icons.svg's root is in namespace %q, want %q", s.XMLName.Space, svgNS)
436 }
437 slugs := fiesta.IconSlugs()
438 if len(slugs) == 0 {
439 t.Fatal("the tree has no icons")
440 }
441 byID := map[string]int{}
442 for i, sym := range s.Symbols {
443 byID[sym.ID] = i
444 }
445 if len(s.Symbols) != len(slugs) {
446 t.Errorf("icons.svg holds %d symbols, want one per slug: %d", len(s.Symbols), len(slugs))
447 }
448 for _, slug := range slugs {
449 i, ok := byID["rst-icon-"+slug]
450 if !ok {
451 t.Errorf("icons.svg has no symbol rst-icon-%s", slug)
452 continue
453 }
454 sym := s.Symbols[i]
455 if sym.ViewBox != "0 0 24 24" || sym.Fill != "none" || sym.Stroke != "currentColor" || sym.Width != "2" || sym.Linecap != "round" || sym.Linejoin != "round" {
456 t.Errorf("rst-icon-%s's attributes are %+v", slug, sym)
457 }
458 icon, err := fs.ReadFile(src, "icons/"+slug+".svg")
459 if err != nil {
460 t.Fatal(err)
461 }
462 if got, want := strings.TrimSpace(sym.Inner), iconInner(t, icon); got != want {
463 t.Errorf("rst-icon-%s wraps %q, want the icon's children %q", slug, got, want)
464 }
465 }
466}
467
468// TestStandaloneIconsAreSVGDocuments: every icons/<slug>.svg in the
469// dist, one per shipped slug, parses as XML with a root in the SVG
470// namespace, which is what makes it an image a browser will draw from
471// an <img> or a CSS url(), where the inline form is not.
472func TestStandaloneIconsAreSVGDocuments(t *testing.T) {
473 d := dist(t, sourceFS(t))
474 slugs := fiesta.IconSlugs()
475 if len(slugs) == 0 {
476 t.Fatal("the tree has no icons")
477 }
478 n := 0
479 for name := range d {
480 if strings.HasPrefix(name, "icons/") && strings.HasSuffix(name, ".svg") {
481 n++
482 }
483 }
484 if n != len(slugs) {
485 t.Fatalf("the dist has %d standalone icons, want one per slug: %d", n, len(slugs))
486 }
487 for _, slug := range slugs {
488 var root struct {
489 XMLName xml.Name
490 }
491 b := must(t, d, "icons/"+slug+".svg")
492 if err := xml.Unmarshal(b, &root); err != nil {
493 t.Errorf("icons/%s.svg does not parse: %v", slug, err)
494 continue
495 }
496 if root.XMLName.Local != "svg" || root.XMLName.Space != svgNS {
497 t.Errorf("icons/%s.svg's root is <%s> in namespace %q, want <svg> in %q", slug, root.XMLName.Local, root.XMLName.Space, svgNS)
498 }
499 }
500}
501
502// stringsJS is the Node side of TestTheStringsScriptIsTheJSON: the
503// scripts run in one context whose self is the global, as a page's
504// is, and each table is compared to the JSON it came from. The table
505// is round-tripped through JSON first, since an object made in the
506// vm's realm has that realm's prototypes and deepStrictEqual would
507// call it a different object on that alone.
508const stringsJS = `
509const vm = require("vm");
510const assert = require("assert");
511const {scripts, tables} = JSON.parse(require("fs").readFileSync(0, "utf8"));
512const ctx = {};
513ctx.self = ctx;
514for (const s of scripts) vm.runInNewContext(s, ctx);
515const out = {};
516for (const [lang, json] of Object.entries(tables)) {
517 try { assert.deepStrictEqual(JSON.parse(JSON.stringify(ctx.fiestaStrings[lang])), JSON.parse(json)); out[lang] = ""; }
518 catch (e) { out[lang] = e.message; }
519}
520process.stdout.write(JSON.stringify(out));
521`
522
523// TestTheStringsScriptIsTheJSON: i18n/es.js, run in Node, leaves
524// self.fiestaStrings.es deep-equal to es.json; and a second language's
525// script run after it adds to the registry instead of replacing it.
526func TestTheStringsScriptIsTheJSON(t *testing.T) {
527 d := dist(t, sourceFS(t))
528 in, err := json.Marshal(map[string]any{
529 "scripts": []string{string(must(t, d, "i18n/es.js")), string(must(t, d, "i18n/en.js"))},
530 "tables": map[string]string{"es": string(must(t, d, "i18n/es.json")), "en": string(must(t, d, "i18n/en.json"))},
531 })
532 if err != nil {
533 t.Fatal(err)
534 }
535 var got map[string]string
536 if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", stringsJS}, Stdin: in}), &got); err != nil {
537 t.Fatal(err)
538 }
539 for _, lang := range []string{"es", "en"} {
540 if got[lang] != "" {
541 t.Errorf("self.fiestaStrings.%s is not %s.json: %s", lang, lang, got[lang])
542 }
543 }
544 es, src := must(t, d, "i18n/es.json"), string(must(t, d, "i18n/es.js"))
545 if want := `(self.fiestaStrings = self.fiestaStrings || {})["es"] = ` + string(es) + ";\n"; src != want {
546 t.Errorf("i18n/es.js is not the one assignment:\n%s", src)
547 }
548}
549
550// TestVendoredAxeIsNotAShippedAsset is rastrillo's containment gate
551// (ui/axe_test.go at the seed commit) over the release instead of the
552// accessors: the vendored axe-core is test data, and no file of the
553// dist carries its banner. The script count is the precondition that
554// keeps an empty dist from passing.
555func TestVendoredAxeIsNotAShippedAsset(t *testing.T) {
556 // The banner is present in every build of axe-core and appears in
557 // no hand-written asset, so it is the marker to hunt for.
558 const marker = "Deque Systems"
559 d := dist(t, sourceFS(t))
560 if n := scriptsIn(d); n < len(fiesta.ScriptNames()) {
561 t.Fatalf("the dist has %d scripts, fewer than the %d shipped", n, len(fiesta.ScriptNames()))
562 }
563 for name, body := range d {
564 if bytes.Contains(body, []byte(marker)) {
565 t.Errorf("%s carries the vendored axe-core: it is test data and must not ship", name)
566 }
567 }
568}
569
570// TestTheDistIsDeterministic: two builds of the same tree are the same
571// bytes under the same names, and the names are the expected set. A
572// release that differed between runs could not be checked against its
573// own SHA256SUMS by anyone else.
574func TestTheDistIsDeterministic(t *testing.T) {
575 src := sourceFS(t)
576 a, b := dist(t, src), dist(t, src)
577 var names []string
578 for name := range a {
579 names = append(names, name)
580 }
581 sort.Strings(names)
582 if want := expectedFiles(); strings.Join(names, "\n") != strings.Join(want, "\n") {
583 t.Fatalf("the dist's files are not the expected set:\n got: %s\nwant: %s", strings.Join(names, " "), strings.Join(want, " "))
584 }
585 if len(b) != len(a) {
586 t.Fatalf("the second build has %d files, the first %d", len(b), len(a))
587 }
588 for _, name := range names {
589 if !bytes.Equal(a[name], b[name]) {
590 t.Errorf("%s differs between two builds of the same tree", name)
591 }
592 }
593}
594