package build import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "encoding/xml" "fmt" "io/fs" "math/rand" "sort" "strings" "testing" "testing/fstest" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/nodetest" ) // The release is a function of the embedded tree, so every test here // but one runs Dist on a MapFS copied from that tree, which a test can // then alter or leave a file out of. The one exception is the size // gate, TestTheOneLinkIsUnder25KiBGzipped, whose claim is about the // real tree and nothing else. // bundles is each one-file theme and the theme it carries. var bundles = map[string]string{"fiesta.css": "day", "fiesta-plain.css": "plain", "fiesta-signal.css": "signal"} // bundleNames is bundles' keys, sorted, for a stable order of // failures and figures. func bundleNames() []string { names := make([]string, 0, len(bundles)) for n := range bundles { names = append(names, n) } sort.Strings(names) return names } // sourceFS is the embedded tree's build inputs as a MapFS: LICENSE, // css/, js/, icons/ and i18n/, byte for byte. func sourceFS(t *testing.T) fstest.MapFS { t.Helper() m := fstest.MapFS{} err := fs.WalkDir(fiesta.FS(), ".", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } top, _, _ := strings.Cut(p, "/") switch top { case "LICENSE", "css", "js", "icons", "i18n": b, err := fs.ReadFile(fiesta.FS(), p) if err != nil { return err } m[p] = &fstest.MapFile{Data: b} } return nil }) if err != nil { t.Fatal(err) } if len(m) == 0 { t.Fatal("the embedded tree copied as nothing") } return m } // dist builds the release from src at the shipped version, failing // the test on an error. func dist(t *testing.T, src fs.FS) map[string][]byte { t.Helper() d, err := Dist(src, fiesta.Version) if err != nil { t.Fatalf("Dist: %v", err) } return d } // must is one file of a dist, failing the test when it is absent. func must(t *testing.T, d map[string][]byte, name string) []byte { t.Helper() b, ok := d[name] if !ok { t.Fatalf("the dist has no %s", name) } return b } // expectedFiles is every path the release holds, from the tree's own // lists: the licences, the sums, the sprite, the three one-file // themes, the separate stylesheets, every script, every language as // JSON and as a script, and every icon standalone. func expectedFiles() []string { names := []string{"LICENSE", "NOTICE", "SHA256SUMS", "icons.svg", "tokens.css", "shell.css"} for b, theme := range bundles { names = append(names, b, theme+".css") } names = append(names, fiesta.ScriptNames()...) for _, lang := range fiesta.Languages() { names = append(names, "i18n/"+lang+".json", "i18n/"+lang+".js") } for _, slug := range fiesta.IconSlugs() { names = append(names, "icons/"+slug+".svg") } sort.Strings(names) return names } // scriptsIn counts the dist's scripts outside i18n/, which is at // least the shipped scripts. func scriptsIn(d map[string][]byte) int { n := 0 for name := range d { if strings.HasSuffix(name, ".js") && !strings.HasPrefix(name, "i18n/") { n++ } } return n } // TestTheOneLinkIsUnder25KiBGzipped is the size gate, and `make size` // runs it alone: each one-file theme built from the real tree exists, // is not empty, and gzips under OneLinkLimit. The three figures are // logged so the gate's output says what the page costs today. func TestTheOneLinkIsUnder25KiBGzipped(t *testing.T) { d := dist(t, fiesta.FS()) for _, name := range bundleNames() { b := must(t, d, name) if len(b) == 0 { t.Fatalf("%s is empty", name) } g := Gzipped(b) 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) } if err := CheckSize(d); err != nil { t.Fatal(err) } } // incompressible is n bytes a fixed seed produced, which gzip cannot // shrink, trimmed from the front until the compressed length lands // on target exactly. func incompressible(t *testing.T, target int) []byte { t.Helper() r := rand.New(rand.NewSource(20261027)) buf := make([]byte, target+64) r.Read(buf) for n := target; n >= 0; n-- { if Gzipped(buf[:n]) == target { return buf[:n] } } t.Fatalf("no prefix of the random bytes gzips to exactly %d", target) return nil } // gatedDist is a dist of the three themes' own files, each bundle // holding the bytes given: the shape CheckSize reads its bundle list // from. func gatedDist(bundle []byte) map[string][]byte { d := map[string][]byte{} for name, theme := range bundles { d[theme+".css"] = []byte(":root{}") d[name] = bundle } return d } // TestTheSizeGateFailsAtTheLine holds CheckSize to its line, one // bundle at a time: a dist whose three bundles gzip to 25,599 bytes // passes, and one with any bundle at 25,600 fails, naming the file // and giving both figures. The bundles the gate holds are the themes // the dist carries, so a theme file with no bundle fails it by the // bundle's name, and a dist with no theme fails it outright. func TestTheSizeGateFailsAtTheLine(t *testing.T) { under := incompressible(t, OneLinkLimit-1) over := incompressible(t, OneLinkLimit) if g := Gzipped(under); g != OneLinkLimit-1 { t.Fatalf("the under bundle gzips to %d, want %d", g, OneLinkLimit-1) } if g := Gzipped(over); g != OneLinkLimit { t.Fatalf("the over bundle gzips to %d, want %d", g, OneLinkLimit) } for _, name := range bundleNames() { d := gatedDist(under) if err := CheckSize(d); err != nil { t.Errorf("%s at %d gzipped bytes failed the gate: %v", name, OneLinkLimit-1, err) } d[name] = over err := CheckSize(d) if err == nil { t.Errorf("%s at %d gzipped bytes passed the gate; the line is %d", name, OneLinkLimit, OneLinkLimit) continue } if !strings.Contains(err.Error(), name) { t.Errorf("the gate's error does not name %s: %v", name, err) } } // The figures: the measured size and the limit, each in bytes and // in KiB, with a bundle far enough over the line that the two // differ in both units. wide := incompressible(t, OneLinkLimit+500) d := gatedDist(under) d["fiesta.css"] = wide err := CheckSize(d) if err == nil { t.Fatalf("fiesta.css at %d gzipped bytes passed the gate", OneLinkLimit+500) } for _, want := range []string{fmt.Sprintf("%d bytes gzipped (25.5 KiB)", OneLinkLimit+500), fmt.Sprintf("limit is %d (25.0 KiB)", OneLinkLimit)} { if !strings.Contains(err.Error(), want) { t.Errorf("the gate's error does not say %q: %v", want, err) } } // A fourth theme is gated by its bundle, present or not. d = gatedDist(under) d["extra.css"] = []byte(":root{}") if err := CheckSize(d); err == nil || !strings.Contains(err.Error(), "fiesta-extra.css") { t.Errorf("a theme with no bundle passed the gate, or the error does not name fiesta-extra.css: %v", err) } d["fiesta-extra.css"] = over if err := CheckSize(d); err == nil || !strings.Contains(err.Error(), "fiesta-extra.css") { t.Errorf("a fourth theme's bundle at the line passed the gate, or the error does not name it: %v", err) } d["fiesta-extra.css"] = under if err := CheckSize(d); err != nil { t.Errorf("a fourth theme's bundle under the line failed the gate: %v", err) } if err := CheckSize(map[string][]byte{"fiesta.css": under}); err == nil { t.Error("a dist with no theme file passed the gate") } } // TestTheBundlesAreTokensThenThemeStripped: each one-file theme is the // banner, then tokens.css and the theme joined by a newline and // stripped of comments, and nothing else. The separate files are the // banner and the stripped file. func TestTheBundlesAreTokensThenThemeStripped(t *testing.T) { src := sourceFS(t) d := dist(t, src) tokens, err := fs.ReadFile(src, "css/tokens.css") if err != nil { t.Fatal(err) } for _, name := range bundleNames() { theme, err := fs.ReadFile(src, "css/themes/"+bundles[name]+".css") if err != nil { t.Fatal(err) } joined := append(append(append([]byte{}, tokens...), '\n'), theme...) want := append([]byte(Banner(fiesta.Version)), stripCSSComments(joined)...) if got := must(t, d, name); !bytes.Equal(got, want) { t.Errorf("%s is not banner + strip(tokens.css + \"\\n\" + %s.css): %d bytes, want %d", name, bundles[name], len(got), len(want)) } want = append([]byte(Banner(fiesta.Version)), stripCSSComments(theme)...) if got := must(t, d, bundles[name]+".css"); !bytes.Equal(got, want) { t.Errorf("%s.css is not banner + strip(%s.css)", bundles[name], bundles[name]) } } for _, name := range []string{"tokens.css", "shell.css"} { in, err := fs.ReadFile(src, "css/"+name) if err != nil { t.Fatal(err) } want := append([]byte(Banner(fiesta.Version)), stripCSSComments(in)...) if got := must(t, d, name); !bytes.Equal(got, want) { t.Errorf("%s is not banner + strip(css/%s)", name, name) } } for _, name := range fiesta.ScriptNames() { in, err := fs.ReadFile(src, "js/"+name) if err != nil { t.Fatal(err) } want := append([]byte(Banner(fiesta.Version)), stripJSComments(in)...) if got := must(t, d, name); !bytes.Equal(got, want) { t.Errorf("%s is not banner + stripJS(js/%s)", name, name) } } } // TestDistNeedsOnlyItsFS: Dist reads src and nothing else, so a src // without LICENSE is an error that says so, not a dist that found // the file somewhere else. func TestDistNeedsOnlyItsFS(t *testing.T) { src := sourceFS(t) if _, ok := src["LICENSE"]; !ok { t.Fatal("the source has no LICENSE to leave out") } delete(src, "LICENSE") _, err := Dist(src, fiesta.Version) if err == nil { t.Fatal("Dist built a release from a tree with no LICENSE") } if !strings.Contains(err.Error(), "LICENSE") { t.Errorf("the error does not name LICENSE: %v", err) } } // parseJS is the Node side of TestTheDistHasNoComments: each source // compiled, and the error for one that does not parse. const parseJS = ` const vm = require("vm"); const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); process.stdout.write(JSON.stringify(srcs.map(s => { try { new vm.Script(s); return ""; } catch (e) { return e.name + ": " + e.message; } }))); ` // TestTheDistHasNoComments: past the banner, no stylesheet holds a // comment opener, and every script, i18n/ included, still parses. func TestTheDistHasNoComments(t *testing.T) { d := dist(t, sourceFS(t)) banner := Banner(fiesta.Version) var names []string for name := range d { names = append(names, name) } sort.Strings(names) stylesheets, scripts := 0, 0 var srcs []string var scriptNames []string for _, name := range names { switch { case strings.HasSuffix(name, ".css"): stylesheets++ body := d[name] if !bytes.HasPrefix(body, []byte(banner)) { t.Errorf("%s does not open with the banner", name) continue } if bytes.Contains(body[len(banner):], []byte("/*")) { t.Errorf("%s still holds a comment after the banner", name) } case strings.HasSuffix(name, ".js"): scripts++ if !bytes.HasPrefix(d[name], []byte(banner)) && !strings.HasPrefix(name, "i18n/") { t.Errorf("%s does not open with the banner", name) } srcs = append(srcs, string(d[name])) scriptNames = append(scriptNames, name) } } if stylesheets == 0 { t.Fatal("the dist has no stylesheet") } if scriptsIn(d) < len(fiesta.ScriptNames()) { t.Fatalf("the dist has %d scripts, fewer than the %d shipped", scriptsIn(d), len(fiesta.ScriptNames())) } in, err := json.Marshal(srcs) if err != nil { t.Fatal(err) } var errs []string if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", parseJS}, Stdin: in}), &errs); err != nil { t.Fatal(err) } for i, e := range errs { if e != "" { t.Errorf("%s does not parse stripped: %s", scriptNames[i], e) } } } // TestTheSumsDescribeTheDist: SHA256SUMS is one line per file but // itself, sorted by path, each the file's sha256 in the two-space // form sha256sum -c reads. func TestTheSumsDescribeTheDist(t *testing.T) { d := dist(t, sourceFS(t)) sums := string(must(t, d, "SHA256SUMS")) var names []string for name := range d { if name != "SHA256SUMS" { names = append(names, name) } } sort.Strings(names) var want []string for _, name := range names { sum := sha256.Sum256(d[name]) want = append(want, hex.EncodeToString(sum[:])+" "+name) } if len(want) < 2 { t.Fatalf("the dist has %d files besides the sums", len(want)) } got := strings.Split(strings.TrimSuffix(sums, "\n"), "\n") if strings.Join(got, "\n") != strings.Join(want, "\n") { 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")) } } // sprite is icons.svg as encoding/xml reads it. type sprite struct { XMLName xml.Name `xml:"svg"` Symbols []struct { ID string `xml:"id,attr"` ViewBox string `xml:"viewBox,attr"` Fill string `xml:"fill,attr"` Stroke string `xml:"stroke,attr"` Width string `xml:"stroke-width,attr"` Linecap string `xml:"stroke-linecap,attr"` Linejoin string `xml:"stroke-linejoin,attr"` Inner string `xml:",innerxml"` } `xml:"symbol"` } // iconInner is what sits between an inline icon's root tags, read // here on its own so the sprite is checked against the icon, not // against the build's reading of it. func iconInner(t *testing.T, svg []byte) string { t.Helper() s := strings.TrimSpace(string(svg)) open := strings.IndexByte(s, '>') if !strings.HasPrefix(s, "") { t.Fatalf("not an inline icon: %q", s) } return strings.TrimSpace(s[open+1 : len(s)-len("")]) } // TestTheSpriteHasEverySlug: icons.svg is one SVG document in the SVG // namespace holding a symbol per slug, each with the icon's own // viewBox and stroke attributes and the icon's children inside. func TestTheSpriteHasEverySlug(t *testing.T) { src := sourceFS(t) d := dist(t, src) var s sprite if err := xml.Unmarshal(must(t, d, "icons.svg"), &s); err != nil { t.Fatalf("icons.svg does not parse: %v", err) } if s.XMLName.Space != svgNS { t.Errorf("icons.svg's root is in namespace %q, want %q", s.XMLName.Space, svgNS) } slugs := fiesta.IconSlugs() if len(slugs) == 0 { t.Fatal("the tree has no icons") } byID := map[string]int{} for i, sym := range s.Symbols { byID[sym.ID] = i } if len(s.Symbols) != len(slugs) { t.Errorf("icons.svg holds %d symbols, want one per slug: %d", len(s.Symbols), len(slugs)) } for _, slug := range slugs { i, ok := byID["rst-icon-"+slug] if !ok { t.Errorf("icons.svg has no symbol rst-icon-%s", slug) continue } sym := s.Symbols[i] if sym.ViewBox != "0 0 24 24" || sym.Fill != "none" || sym.Stroke != "currentColor" || sym.Width != "2" || sym.Linecap != "round" || sym.Linejoin != "round" { t.Errorf("rst-icon-%s's attributes are %+v", slug, sym) } icon, err := fs.ReadFile(src, "icons/"+slug+".svg") if err != nil { t.Fatal(err) } if got, want := strings.TrimSpace(sym.Inner), iconInner(t, icon); got != want { t.Errorf("rst-icon-%s wraps %q, want the icon's children %q", slug, got, want) } } } // TestStandaloneIconsAreSVGDocuments: every icons/.svg in the // dist, one per shipped slug, parses as XML with a root in the SVG // namespace, which is what makes it an image a browser will draw from // an or a CSS url(), where the inline form is not. func TestStandaloneIconsAreSVGDocuments(t *testing.T) { d := dist(t, sourceFS(t)) slugs := fiesta.IconSlugs() if len(slugs) == 0 { t.Fatal("the tree has no icons") } n := 0 for name := range d { if strings.HasPrefix(name, "icons/") && strings.HasSuffix(name, ".svg") { n++ } } if n != len(slugs) { t.Fatalf("the dist has %d standalone icons, want one per slug: %d", n, len(slugs)) } for _, slug := range slugs { var root struct { XMLName xml.Name } b := must(t, d, "icons/"+slug+".svg") if err := xml.Unmarshal(b, &root); err != nil { t.Errorf("icons/%s.svg does not parse: %v", slug, err) continue } if root.XMLName.Local != "svg" || root.XMLName.Space != svgNS { t.Errorf("icons/%s.svg's root is <%s> in namespace %q, want in %q", slug, root.XMLName.Local, root.XMLName.Space, svgNS) } } } // stringsJS is the Node side of TestTheStringsScriptIsTheJSON: the // scripts run in one context whose self is the global, as a page's // is, and each table is compared to the JSON it came from. The table // is round-tripped through JSON first, since an object made in the // vm's realm has that realm's prototypes and deepStrictEqual would // call it a different object on that alone. const stringsJS = ` const vm = require("vm"); const assert = require("assert"); const {scripts, tables} = JSON.parse(require("fs").readFileSync(0, "utf8")); const ctx = {}; ctx.self = ctx; for (const s of scripts) vm.runInNewContext(s, ctx); const out = {}; for (const [lang, json] of Object.entries(tables)) { try { assert.deepStrictEqual(JSON.parse(JSON.stringify(ctx.fiestaStrings[lang])), JSON.parse(json)); out[lang] = ""; } catch (e) { out[lang] = e.message; } } process.stdout.write(JSON.stringify(out)); ` // TestTheStringsScriptIsTheJSON: i18n/es.js, run in Node, leaves // self.fiestaStrings.es deep-equal to es.json; and a second language's // script run after it adds to the registry instead of replacing it. func TestTheStringsScriptIsTheJSON(t *testing.T) { d := dist(t, sourceFS(t)) in, err := json.Marshal(map[string]any{ "scripts": []string{string(must(t, d, "i18n/es.js")), string(must(t, d, "i18n/en.js"))}, "tables": map[string]string{"es": string(must(t, d, "i18n/es.json")), "en": string(must(t, d, "i18n/en.json"))}, }) if err != nil { t.Fatal(err) } var got map[string]string if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", stringsJS}, Stdin: in}), &got); err != nil { t.Fatal(err) } for _, lang := range []string{"es", "en"} { if got[lang] != "" { t.Errorf("self.fiestaStrings.%s is not %s.json: %s", lang, lang, got[lang]) } } es, src := must(t, d, "i18n/es.json"), string(must(t, d, "i18n/es.js")) if want := `(self.fiestaStrings = self.fiestaStrings || {})["es"] = ` + string(es) + ";\n"; src != want { t.Errorf("i18n/es.js is not the one assignment:\n%s", src) } } // TestVendoredAxeIsNotAShippedAsset is rastrillo's containment gate // (ui/axe_test.go at the seed commit) over the release instead of the // accessors: the vendored axe-core is test data, and no file of the // dist carries its banner. The script count is the precondition that // keeps an empty dist from passing. func TestVendoredAxeIsNotAShippedAsset(t *testing.T) { // The banner is present in every build of axe-core and appears in // no hand-written asset, so it is the marker to hunt for. const marker = "Deque Systems" d := dist(t, sourceFS(t)) if n := scriptsIn(d); n < len(fiesta.ScriptNames()) { t.Fatalf("the dist has %d scripts, fewer than the %d shipped", n, len(fiesta.ScriptNames())) } for name, body := range d { if bytes.Contains(body, []byte(marker)) { t.Errorf("%s carries the vendored axe-core: it is test data and must not ship", name) } } } // TestTheDistIsDeterministic: two builds of the same tree are the same // bytes under the same names, and the names are the expected set. A // release that differed between runs could not be checked against its // own SHA256SUMS by anyone else. func TestTheDistIsDeterministic(t *testing.T) { src := sourceFS(t) a, b := dist(t, src), dist(t, src) var names []string for name := range a { names = append(names, name) } sort.Strings(names) if want := expectedFiles(); strings.Join(names, "\n") != strings.Join(want, "\n") { t.Fatalf("the dist's files are not the expected set:\n got: %s\nwant: %s", strings.Join(names, " "), strings.Join(want, " ")) } if len(b) != len(a) { t.Fatalf("the second build has %d files, the first %d", len(b), len(a)) } for _, name := range names { if !bytes.Equal(a[name], b[name]) { t.Errorf("%s differs between two builds of the same tree", name) } } }