rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package www_test
2
3import (
4 "bytes"
5 "fmt"
6 "io/fs"
7 "maps"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "os"
12 "regexp"
13 "slices"
14 "sort"
15 "strings"
16 "testing"
17 "testing/fstest"
18
19 xhtml "golang.org/x/net/html"
20
21 "amadan.net/rastrillo/fiesta"
22 "amadan.net/rastrillo/fiesta/internal/build"
23 "amadan.net/rastrillo/fiesta/internal/site"
24 "amadan.net/rastrillo/fiesta/internal/www"
25)
26
27// outsideURLs are the URLs the site links on purpose that are not its
28// own: the top bar's, the footer's, and the design system's one
29// outbound link, lucide.dev, where its icons come from. Exactly these,
30// as written, so a new outside link is a decision someone takes here
31// rather than a line that slips in. The site's own absolute URLs, the hreflang
32// links and the install text, start with www.Origin and are resolved
33// as pages of the site instead, so a wrong path under the origin is
34// caught; the origin itself is never on this list.
35var outsideURLs = []string{
36 "https://rastrillo.org",
37 "https://rastrillo.org/docs/",
38 "https://amadan.net/rastrillo/fiesta",
39 "https://amadan.net/rastrillo/rastrillo",
40 "https://carlosframework.com",
41 "https://carlosframework.com/platform/",
42 "https://carlosframework.com/start/",
43 "https://carlosframework.com/letters/",
44 "https://carlosframework.com/trust/",
45 "https://carloku.com",
46 "https://11factor.org",
47 "https://lucide.dev",
48}
49
50// textURL is an absolute URL inside text: the install box's <code>
51// shows a whole link tag as text, and the URL in it has to reach the
52// stylesheet like any other.
53var textURL = regexp.MustCompile(`https?://[^\s"'<>]+`)
54
55// reference is one URL a page carries: where it was found and what it
56// says.
57type reference struct {
58 page string // the tree path of the page, index.html or ar/index.html
59 where string // the element and attribute, "a href", or "code text"
60 raw string // the URL as written
61}
62
63// referencesOf reads every href, src and srcset on a page, whatever
64// the element (a, link, script, img, iframe, use...), and every URL in
65// the text of a translate="no" code element, the one place a URL is
66// shown rather than linked.
67func referencesOf(page string, doc *xhtml.Node) []reference {
68 var out []reference
69 walk(doc, func(n *xhtml.Node) {
70 for _, a := range n.Attr {
71 switch a.Key {
72 case "href", "src":
73 out = append(out, reference{page, n.Data + " " + a.Key, a.Val})
74 case "srcset":
75 // Comma-separated candidates, each a URL and an
76 // optional descriptor.
77 for _, c := range strings.Split(a.Val, ",") {
78 if f := strings.Fields(c); len(f) > 0 {
79 out = append(out, reference{page, n.Data + " srcset", f[0]})
80 }
81 }
82 }
83 }
84 if n.Data == "code" && attr(n, "translate") == "no" {
85 for _, u := range textURL.FindAllString(text(n), -1) {
86 out = append(out, reference{page, "code text", u})
87 }
88 }
89 })
90 return out
91}
92
93// served is what the edge answers for one path: the status, and the
94// ids on the page if it is one, read once.
95type served struct {
96 status int
97 html bool
98 ids map[string]bool
99}
100
101// resolver answers for the built site the way the edge does, through
102// serveSite's own handler, so a directory index, the _redirects
103// rewrite and a real file count and nothing else does.
104type resolver struct {
105 h *siteHandler
106 cache map[string]*served
107}
108
109func newResolver(t *testing.T, tree map[string][]byte) *resolver {
110 t.Helper()
111 return &resolver{h: newSiteHandler(t, tree), cache: map[string]*served{}}
112}
113
114// get serves p through the handler, once per path.
115func (r *resolver) get(p string) *served {
116 if s, ok := r.cache[p]; ok {
117 return s
118 }
119 rec := httptest.NewRecorder()
120 r.h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil))
121 s := &served{status: rec.Code}
122 if s.status == http.StatusOK && strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
123 s.html = true
124 s.ids = map[string]bool{}
125 if doc, err := xhtml.Parse(rec.Body); err == nil {
126 walk(doc, func(n *xhtml.Node) {
127 if id := attr(n, "id"); id != "" {
128 s.ids[id] = true
129 }
130 })
131 }
132 }
133 r.cache[p] = s
134 return s
135}
136
137// check reports what is wrong with one reference, or "" when it
138// resolves: an outside URL not on the list, a path the edge would not
139// serve, or a fragment with no id on the page it names. A bare # is a
140// link to nowhere on purpose, as in a sample, and is left alone.
141func (r *resolver) check(ref reference) string {
142 if ref.raw == "#" {
143 return ""
144 }
145 raw := ref.raw
146 u, err := url.Parse(raw)
147 if err != nil {
148 return fmt.Sprintf("%s: %s %q does not parse: %v", ref.page, ref.where, raw, err)
149 }
150 if u.Scheme != "" || u.Host != "" {
151 if raw == www.Origin || strings.HasPrefix(raw, www.Origin+"/") {
152 // The site's own, written in full: resolve the rest as a
153 // root-relative path of this very tree.
154 raw = strings.TrimPrefix(raw, www.Origin)
155 if raw == "" {
156 raw = "/"
157 }
158 if u, err = url.Parse(raw); err != nil {
159 return fmt.Sprintf("%s: %s %q does not parse: %v", ref.page, ref.where, ref.raw, err)
160 }
161 } else {
162 for _, ok := range outsideURLs {
163 if raw == ok {
164 return ""
165 }
166 }
167 return fmt.Sprintf("%s: %s links %s, which leaves the site and is not among the outside URLs the site links on purpose", ref.page, ref.where, raw)
168 }
169 }
170 base := &url.URL{Path: "/" + ref.page}
171 target := base.ResolveReference(u)
172 s := r.get(target.Path)
173 if s.status != http.StatusOK {
174 return fmt.Sprintf("%s: %s %s resolves to %s, which the edge answers with a %d", ref.page, ref.where, ref.raw, target.Path, s.status)
175 }
176 if target.Fragment == "" {
177 return ""
178 }
179 if !s.html {
180 return fmt.Sprintf("%s: %s %s names a fragment on %s, which is not a page", ref.page, ref.where, ref.raw, target.Path)
181 }
182 if !s.ids[target.Fragment] {
183 return fmt.Sprintf("%s: %s %s names #%s, and %s has no element with that id", ref.page, ref.where, ref.raw, target.Fragment, target.Path)
184 }
185 return ""
186}
187
188// linkProblems checks every reference on every HTML file of a built
189// site and returns what is wrong, one line each, sorted, with the
190// pages it read.
191func linkProblems(t *testing.T, tree map[string][]byte) (problems []string, pages []string) {
192 t.Helper()
193 r := newResolver(t, tree)
194 for name, data := range tree {
195 if !strings.HasSuffix(name, ".html") {
196 continue
197 }
198 pages = append(pages, name)
199 for _, ref := range referencesOf(name, parse(t, data)) {
200 if p := r.check(ref); p != "" {
201 problems = append(problems, p)
202 }
203 }
204 }
205 sort.Strings(problems)
206 sort.Strings(pages)
207 return problems, pages
208}
209
210// unpublished reports whether the source is ahead of releases/: there
211// is no releases/<version>/, or it holds other files than dist, the
212// release the source builds. That is the stretch between a change to
213// the source and the release that carries it, and the stretch the
214// design system's pages spend linking files of the release to come.
215func unpublished(releases fs.FS, version string, dist map[string][]byte) (bool, error) {
216 if _, err := fs.Stat(releases, version); err != nil {
217 return true, nil
218 }
219 dir, err := fs.Sub(releases, version)
220 if err != nil {
221 return false, err
222 }
223 published, err := site.Release(dir)
224 if err != nil {
225 return false, err
226 }
227 return !maps.EqualFunc(published, dist, bytes.Equal), nil
228}
229
230// servingSource is tree as the edge will serve it once dist is
231// released: dist sits beside the published releases, and the rewrite
232// for version's major sends /M/ to it rather than to the newest
233// published release. It sits under version/ when that is free, where
234// fiesta release will put it, and under unreleased/ when version is
235// published already and the source has moved past it, so a pinned
236// /version/ link still reaches the bytes that version shipped.
237func servingSource(t *testing.T, tree map[string][]byte, version string, dist map[string][]byte) map[string][]byte {
238 t.Helper()
239 out := maps.Clone(tree)
240 dir := version
241 for name := range tree {
242 if strings.HasPrefix(name, version+"/") {
243 dir = "unreleased"
244 break
245 }
246 }
247 for name, data := range dist {
248 out[dir+"/"+name] = data
249 }
250 major, _, _ := strings.Cut(version, ".")
251 prefix := "/" + major + "/* "
252 rule := prefix + "/" + dir + "/:splat 200"
253 var lines []string
254 found := false
255 for _, line := range strings.Split(strings.TrimSuffix(string(tree["_redirects"]), "\n"), "\n") {
256 if strings.HasPrefix(line, prefix) {
257 line, found = rule, true
258 }
259 lines = append(lines, line)
260 }
261 if !found {
262 // A new major's first release: its rule goes first, as
263 // site.Build writes the newest major's.
264 lines = append([]string{rule}, lines...)
265 }
266 out["_redirects"] = []byte(strings.Join(lines, "\n") + "\n")
267 return out
268}
269
270// publishedOnlyEnv turns off the unpublished fallback below: the
271// ship check, make site-links-published, sets it on the exported tree
272// about to go out, where a /1/ link the newest published release
273// cannot answer is a 404 the day the site ships, whatever the source
274// would build. The fallback alone would pass such a link: today
275// releases/1.0.0/ exists and is /1/'s target, and it has no copy.js.
276const publishedOnlyEnv = "FIESTA_LINKS_PUBLISHED"
277
278// linkTree is the tree the link check reads: tree as site.Build made
279// it, or, while the source is ahead of releases/ and publishedOnly is
280// false, tree with dist served as the release to come (servingSource).
281// It reports whether it fell back to dist.
282func linkTree(t *testing.T, tree map[string][]byte, releases fs.FS, version string, dist map[string][]byte, publishedOnly bool) (map[string][]byte, bool) {
283 t.Helper()
284 if publishedOnly {
285 return tree, false
286 }
287 ahead, err := unpublished(releases, version, dist)
288 if err != nil {
289 t.Fatalf("reading releases/%s: %v", version, err)
290 }
291 if !ahead {
292 return tree, false
293 }
294 return servingSource(t, tree, version, dist), true
295}
296
297// TestEveryLinkInTheSiteResolves: on every page of the built site,
298// the landing page in each language, the design system's index, every
299// example page and every frame, every href, src and srcset reaches a
300// file the edge would serve, resolved against the page's own URL,
301// through a directory index, the /1/ rewrite or a real file; every
302// fragment names an id on the page it points at; and nothing links
303// outside the site but the URLs listed above. The install text's URL
304// is held to the same rule as the hreflang links: the site's own,
305// through the rewrite to the newest release.
306//
307// While the source is unpublished (see unpublished), /1/ is resolved
308// against the release the source builds, not the newest published one:
309// the design system lists every file the source ships, linked through
310// /1/, and the site goes out with the release that carries them. Read
311// against 1.0.0 alone, every new file is a 404 from the day it is
312// added to the day the release is cut. Once releases/ holds what the
313// source builds, /1/ resolves against it as the edge will. With
314// FIESTA_LINKS_PUBLISHED=1 there is no fallback: /1/ resolves against
315// the newest published release only, as the edge serves the tree being
316// shipped. That mode fails from a change to the source until the
317// release that carries it is cut, by design, so it runs before a ship
318// (make site-links-published) and not in the suite.
319//
320// The control comes first: a scratch site whose one page links a
321// missing page, a missing fragment, a wrong path under the site's own
322// origin and an outside URL that is not on the list. The check has to
323// name all four, or a clean run of the real site proves nothing. A
324// second control holds the unpublished stretch: a file the source
325// builds resolves through /1/, and one it does not build still fails;
326// and with the fallback off, the file only the source builds fails
327// too.
328func TestEveryLinkInTheSiteResolves(t *testing.T) {
329 var publishedOnly bool
330 switch v := os.Getenv(publishedOnlyEnv); v {
331 case "":
332 case "1":
333 publishedOnly = true
334 default:
335 t.Fatalf("%s=%q; set it to 1 or leave it unset", publishedOnlyEnv, v)
336 }
337 control := map[string][]byte{
338 "_redirects": []byte("/1/* /1.0.0/:splat 200\n"),
339 "1.0.0/fiesta.css": []byte("body{}"),
340 "design-system/index.html": []byte(`<main id="main"><a href="../">up</a><link rel="stylesheet" href="/1/fiesta.css"></main>`),
341 "design-system/scratch.html": []byte(`<a href="/design-system/nope.html">missing</a>` +
342 `<a href="index.html#nowhere">no id</a>` +
343 `<a href="` + www.Origin + `/nope/">wrong path</a>` +
344 `<a href="https://example.com/">outside</a>` +
345 `<a href="#">bare</a><a href="/#main">main</a>`),
346 "index.html": []byte(`<main id="main"></main>`),
347 }
348 problems, _ := linkProblems(t, control)
349 for _, want := range []string{"/design-system/nope.html", "#nowhere", www.Origin + "/nope/", "https://example.com/"} {
350 found := false
351 for _, p := range problems {
352 found = found || strings.Contains(p, want)
353 }
354 if !found {
355 t.Errorf("the control's link to %s was not reported; the check would prove nothing. Reported:\n %s", want, strings.Join(problems, "\n "))
356 }
357 }
358 if len(problems) != 4 {
359 t.Errorf("the control has 4 broken links and %d were reported:\n %s", len(problems), strings.Join(problems, "\n "))
360 }
361 unpublishedControl(t)
362 if t.Failed() {
363 return
364 }
365
366 dist, err := build.Dist(fiesta.FS(), fiesta.Version)
367 if err != nil {
368 t.Fatalf("building the release the source would publish: %v", err)
369 }
370 tree, fellBack := linkTree(t, builtSite(t), os.DirFS("../../releases"), fiesta.Version, dist, publishedOnly)
371 switch {
372 case publishedOnly:
373 t.Logf("%s=1: /1/ resolves against the newest published release only", publishedOnlyEnv)
374 case fellBack:
375 t.Logf("the source is ahead of releases/%s, so /1/ resolves against the release it builds", fiesta.Version)
376 }
377 problems, pages := linkProblems(t, tree)
378 for _, p := range problems {
379 t.Error(p)
380 }
381 read := map[string]bool{}
382 frames, examples := 0, 0
383 for _, p := range pages {
384 read[p] = true
385 switch {
386 case strings.Contains(p, "/frames/"):
387 frames++
388 case strings.HasPrefix(p, "design-system/") && p != "design-system/index.html":
389 examples++
390 }
391 }
392 for _, l := range www.Languages {
393 if name := strings.TrimPrefix(pageURL(l), "/") + "index.html"; !read[name] {
394 t.Errorf("the landing page in %s, %s, was not read", l, name)
395 }
396 }
397 if !read["design-system/index.html"] {
398 t.Error("the design system's index was not read")
399 }
400 if examples == 0 || frames == 0 {
401 t.Errorf("%d example pages and %d frames were read; the design system has both", examples, frames)
402 }
403}
404
405// unpublishedControl is the unpublished stretch on scratch trees. A
406// page links /1/fiesta.css, which 1.0.0 has, /1/copy.js, which only
407// the source builds, and /1/missing.js, which nothing does. Read
408// against 1.0.0 alone the last two fail; served as the release to come
409// only the last does. unpublished is held on both sides: a release
410// equal to the source build (its stamp aside) is published, one with a
411// file changed or none at all is not.
412func unpublishedControl(t *testing.T) {
413 t.Helper()
414 dist := map[string][]byte{"fiesta.css": []byte("body{}"), "copy.js": []byte("0")}
415 dist["SHA256SUMS"] = sumsOf(dist)
416 tree := map[string][]byte{
417 "_redirects": []byte("/1/* /1.0.0/:splat 200\n"),
418 "1.0.0/fiesta.css": []byte("body{}"),
419 "index.html": []byte(`<main id="main"><link rel="stylesheet" href="/1/fiesta.css">` +
420 `<script src="/1/copy.js"></script><script src="/1/missing.js"></script></main>`),
421 }
422 // releases/1.0.0/ as it is published: fiesta.css, and no copy.js.
423 published := map[string][]byte{"fiesta.css": []byte("body{}")}
424 published["SHA256SUMS"] = sumsOf(published)
425 releases := fstest.MapFS{}
426 for name, data := range published {
427 releases["1.0.0/"+name] = &fstest.MapFile{Data: data}
428 }
429 fallback, fellBack := linkTree(t, tree, releases, "1.0.0", dist, false)
430 if !fellBack {
431 t.Error("the unpublished control: a source build with copy.js, over a 1.0.0 without it, did not fall back to the release to come")
432 }
433 shipped, fellBack := linkTree(t, tree, releases, "1.0.0", dist, true)
434 if fellBack {
435 t.Errorf("the unpublished control: with %s=1 the check still fell back to the source build", publishedOnlyEnv)
436 }
437 for _, c := range []struct {
438 name string
439 tree map[string][]byte
440 want []string
441 }{
442 {"against 1.0.0", tree, []string{"/1/copy.js", "/1/missing.js"}},
443 {"as the release to come", fallback, []string{"/1/missing.js"}},
444 {"as a new version's release", servingSource(t, tree, "1.1.0", dist), []string{"/1/missing.js"}},
445 // The ship check: a file only the source builds is a 404 on the
446 // edge until it is released, and must fail here.
447 {"published only", shipped, []string{"/1/copy.js", "/1/missing.js"}},
448 } {
449 problems, _ := linkProblems(t, c.tree)
450 var got []string
451 for _, p := range problems {
452 for _, link := range []string{"/1/fiesta.css", "/1/copy.js", "/1/missing.js"} {
453 if strings.Contains(p, "resolves to "+link+",") {
454 got = append(got, link)
455 }
456 }
457 }
458 if len(got) != len(problems) || !slices.Equal(got, c.want) {
459 t.Errorf("the unpublished control, %s, reported %q; want only %q broken", c.name, problems, c.want)
460 }
461 }
462
463 stamp := &fstest.MapFile{Data: []byte("fiesta release\n")}
464 same := fstest.MapFS{"1.0.0/" + site.StampName: stamp}
465 changed := fstest.MapFS{"1.0.0/" + site.StampName: stamp}
466 for name, data := range dist {
467 same["1.0.0/"+name] = &fstest.MapFile{Data: data}
468 changed["1.0.0/"+name] = &fstest.MapFile{Data: data}
469 }
470 changed["1.0.0/copy.js"] = &fstest.MapFile{Data: []byte("1")}
471 changed["1.0.0/SHA256SUMS"] = &fstest.MapFile{Data: sumsOf(map[string][]byte{"fiesta.css": dist["fiesta.css"], "copy.js": []byte("1")})}
472 for _, c := range []struct {
473 name string
474 releases fs.FS
475 want bool
476 }{
477 {"the same files", same, false},
478 {"one file changed", changed, true},
479 {"no release directory", fstest.MapFS{}, true},
480 } {
481 got, err := unpublished(c.releases, "1.0.0", dist)
482 if err != nil || got != c.want {
483 t.Errorf("unpublished with %s = %v, %v; want %v", c.name, got, err, c.want)
484 }
485 }
486}
487