| 1 | package build |
| 2 | |
| 3 | import ( |
| 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. |
| 28 | var 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. |
| 32 | func 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. |
| 43 | func 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. |
| 75 | func 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. |
| 85 | func 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. |
| 98 | func 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. |
| 116 | func 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. |
| 130 | func 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. |
| 148 | func 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. |
| 165 | func 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. |
| 180 | func 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. |
| 244 | func 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. |
| 291 | func 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. |
| 308 | const parseJS = ` |
| 309 | const vm = require("vm"); |
| 310 | const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); |
| 311 | process.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. |
| 316 | func 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. |
| 372 | func 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. |
| 397 | type 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. |
| 414 | func 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. |
| 427 | func 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. |
| 472 | func 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. |
| 508 | const stringsJS = ` |
| 509 | const vm = require("vm"); |
| 510 | const assert = require("assert"); |
| 511 | const {scripts, tables} = JSON.parse(require("fs").readFileSync(0, "utf8")); |
| 512 | const ctx = {}; |
| 513 | ctx.self = ctx; |
| 514 | for (const s of scripts) vm.runInNewContext(s, ctx); |
| 515 | const out = {}; |
| 516 | for (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 | } |
| 520 | process.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. |
| 526 | func 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. |
| 555 | func 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. |
| 574 | func 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 | |