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, "")])
}
// 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