package www_test import ( "bytes" "fmt" "io/fs" "maps" "net/http" "net/http/httptest" "net/url" "os" "regexp" "slices" "sort" "strings" "testing" "testing/fstest" xhtml "golang.org/x/net/html" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/build" "amadan.net/rastrillo/fiesta/internal/site" "amadan.net/rastrillo/fiesta/internal/www" ) // outsideURLs are the URLs the site links on purpose that are not its // own: the top bar's, the footer's, and the design system's one // outbound link, lucide.dev, where its icons come from. Exactly these, // as written, so a new outside link is a decision someone takes here // rather than a line that slips in. The site's own absolute URLs, the hreflang // links and the install text, start with www.Origin and are resolved // as pages of the site instead, so a wrong path under the origin is // caught; the origin itself is never on this list. var outsideURLs = []string{ "https://rastrillo.org", "https://rastrillo.org/docs/", "https://amadan.net/rastrillo/fiesta", "https://amadan.net/rastrillo/rastrillo", "https://carlosframework.com", "https://carlosframework.com/platform/", "https://carlosframework.com/start/", "https://carlosframework.com/letters/", "https://carlosframework.com/trust/", "https://carloku.com", "https://11factor.org", "https://lucide.dev", } // textURL is an absolute URL inside text: the install box's // shows a whole link tag as text, and the URL in it has to reach the // stylesheet like any other. var textURL = regexp.MustCompile(`https?://[^\s"'<>]+`) // reference is one URL a page carries: where it was found and what it // says. type reference struct { page string // the tree path of the page, index.html or ar/index.html where string // the element and attribute, "a href", or "code text" raw string // the URL as written } // referencesOf reads every href, src and srcset on a page, whatever // the element (a, link, script, img, iframe, use...), and every URL in // the text of a translate="no" code element, the one place a URL is // shown rather than linked. func referencesOf(page string, doc *xhtml.Node) []reference { var out []reference walk(doc, func(n *xhtml.Node) { for _, a := range n.Attr { switch a.Key { case "href", "src": out = append(out, reference{page, n.Data + " " + a.Key, a.Val}) case "srcset": // Comma-separated candidates, each a URL and an // optional descriptor. for _, c := range strings.Split(a.Val, ",") { if f := strings.Fields(c); len(f) > 0 { out = append(out, reference{page, n.Data + " srcset", f[0]}) } } } } if n.Data == "code" && attr(n, "translate") == "no" { for _, u := range textURL.FindAllString(text(n), -1) { out = append(out, reference{page, "code text", u}) } } }) return out } // served is what the edge answers for one path: the status, and the // ids on the page if it is one, read once. type served struct { status int html bool ids map[string]bool } // resolver answers for the built site the way the edge does, through // serveSite's own handler, so a directory index, the _redirects // rewrite and a real file count and nothing else does. type resolver struct { h *siteHandler cache map[string]*served } func newResolver(t *testing.T, tree map[string][]byte) *resolver { t.Helper() return &resolver{h: newSiteHandler(t, tree), cache: map[string]*served{}} } // get serves p through the handler, once per path. func (r *resolver) get(p string) *served { if s, ok := r.cache[p]; ok { return s } rec := httptest.NewRecorder() r.h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil)) s := &served{status: rec.Code} if s.status == http.StatusOK && strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { s.html = true s.ids = map[string]bool{} if doc, err := xhtml.Parse(rec.Body); err == nil { walk(doc, func(n *xhtml.Node) { if id := attr(n, "id"); id != "" { s.ids[id] = true } }) } } r.cache[p] = s return s } // check reports what is wrong with one reference, or "" when it // resolves: an outside URL not on the list, a path the edge would not // serve, or a fragment with no id on the page it names. A bare # is a // link to nowhere on purpose, as in a sample, and is left alone. func (r *resolver) check(ref reference) string { if ref.raw == "#" { return "" } raw := ref.raw u, err := url.Parse(raw) if err != nil { return fmt.Sprintf("%s: %s %q does not parse: %v", ref.page, ref.where, raw, err) } if u.Scheme != "" || u.Host != "" { if raw == www.Origin || strings.HasPrefix(raw, www.Origin+"/") { // The site's own, written in full: resolve the rest as a // root-relative path of this very tree. raw = strings.TrimPrefix(raw, www.Origin) if raw == "" { raw = "/" } if u, err = url.Parse(raw); err != nil { return fmt.Sprintf("%s: %s %q does not parse: %v", ref.page, ref.where, ref.raw, err) } } else { for _, ok := range outsideURLs { if raw == ok { return "" } } 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) } } base := &url.URL{Path: "/" + ref.page} target := base.ResolveReference(u) s := r.get(target.Path) if s.status != http.StatusOK { 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) } if target.Fragment == "" { return "" } if !s.html { return fmt.Sprintf("%s: %s %s names a fragment on %s, which is not a page", ref.page, ref.where, ref.raw, target.Path) } if !s.ids[target.Fragment] { 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) } return "" } // linkProblems checks every reference on every HTML file of a built // site and returns what is wrong, one line each, sorted, with the // pages it read. func linkProblems(t *testing.T, tree map[string][]byte) (problems []string, pages []string) { t.Helper() r := newResolver(t, tree) for name, data := range tree { if !strings.HasSuffix(name, ".html") { continue } pages = append(pages, name) for _, ref := range referencesOf(name, parse(t, data)) { if p := r.check(ref); p != "" { problems = append(problems, p) } } } sort.Strings(problems) sort.Strings(pages) return problems, pages } // unpublished reports whether the source is ahead of releases/: there // is no releases//, or it holds other files than dist, the // release the source builds. That is the stretch between a change to // the source and the release that carries it, and the stretch the // design system's pages spend linking files of the release to come. func unpublished(releases fs.FS, version string, dist map[string][]byte) (bool, error) { if _, err := fs.Stat(releases, version); err != nil { return true, nil } dir, err := fs.Sub(releases, version) if err != nil { return false, err } published, err := site.Release(dir) if err != nil { return false, err } return !maps.EqualFunc(published, dist, bytes.Equal), nil } // servingSource is tree as the edge will serve it once dist is // released: dist sits beside the published releases, and the rewrite // for version's major sends /M/ to it rather than to the newest // published release. It sits under version/ when that is free, where // fiesta release will put it, and under unreleased/ when version is // published already and the source has moved past it, so a pinned // /version/ link still reaches the bytes that version shipped. func servingSource(t *testing.T, tree map[string][]byte, version string, dist map[string][]byte) map[string][]byte { t.Helper() out := maps.Clone(tree) dir := version for name := range tree { if strings.HasPrefix(name, version+"/") { dir = "unreleased" break } } for name, data := range dist { out[dir+"/"+name] = data } major, _, _ := strings.Cut(version, ".") prefix := "/" + major + "/* " rule := prefix + "/" + dir + "/:splat 200" var lines []string found := false for _, line := range strings.Split(strings.TrimSuffix(string(tree["_redirects"]), "\n"), "\n") { if strings.HasPrefix(line, prefix) { line, found = rule, true } lines = append(lines, line) } if !found { // A new major's first release: its rule goes first, as // site.Build writes the newest major's. lines = append([]string{rule}, lines...) } out["_redirects"] = []byte(strings.Join(lines, "\n") + "\n") return out } // publishedOnlyEnv turns off the unpublished fallback below: the // ship check, make site-links-published, sets it on the exported tree // about to go out, where a /1/ link the newest published release // cannot answer is a 404 the day the site ships, whatever the source // would build. The fallback alone would pass such a link: today // releases/1.0.0/ exists and is /1/'s target, and it has no copy.js. const publishedOnlyEnv = "FIESTA_LINKS_PUBLISHED" // linkTree is the tree the link check reads: tree as site.Build made // it, or, while the source is ahead of releases/ and publishedOnly is // false, tree with dist served as the release to come (servingSource). // It reports whether it fell back to dist. func linkTree(t *testing.T, tree map[string][]byte, releases fs.FS, version string, dist map[string][]byte, publishedOnly bool) (map[string][]byte, bool) { t.Helper() if publishedOnly { return tree, false } ahead, err := unpublished(releases, version, dist) if err != nil { t.Fatalf("reading releases/%s: %v", version, err) } if !ahead { return tree, false } return servingSource(t, tree, version, dist), true } // TestEveryLinkInTheSiteResolves: on every page of the built site, // the landing page in each language, the design system's index, every // example page and every frame, every href, src and srcset reaches a // file the edge would serve, resolved against the page's own URL, // through a directory index, the /1/ rewrite or a real file; every // fragment names an id on the page it points at; and nothing links // outside the site but the URLs listed above. The install text's URL // is held to the same rule as the hreflang links: the site's own, // through the rewrite to the newest release. // // While the source is unpublished (see unpublished), /1/ is resolved // against the release the source builds, not the newest published one: // the design system lists every file the source ships, linked through // /1/, and the site goes out with the release that carries them. Read // against 1.0.0 alone, every new file is a 404 from the day it is // added to the day the release is cut. Once releases/ holds what the // source builds, /1/ resolves against it as the edge will. With // FIESTA_LINKS_PUBLISHED=1 there is no fallback: /1/ resolves against // the newest published release only, as the edge serves the tree being // shipped. That mode fails from a change to the source until the // release that carries it is cut, by design, so it runs before a ship // (make site-links-published) and not in the suite. // // The control comes first: a scratch site whose one page links a // missing page, a missing fragment, a wrong path under the site's own // origin and an outside URL that is not on the list. The check has to // name all four, or a clean run of the real site proves nothing. A // second control holds the unpublished stretch: a file the source // builds resolves through /1/, and one it does not build still fails; // and with the fallback off, the file only the source builds fails // too. func TestEveryLinkInTheSiteResolves(t *testing.T) { var publishedOnly bool switch v := os.Getenv(publishedOnlyEnv); v { case "": case "1": publishedOnly = true default: t.Fatalf("%s=%q; set it to 1 or leave it unset", publishedOnlyEnv, v) } control := map[string][]byte{ "_redirects": []byte("/1/* /1.0.0/:splat 200\n"), "1.0.0/fiesta.css": []byte("body{}"), "design-system/index.html": []byte(`
up
`), "design-system/scratch.html": []byte(`missing` + `no id` + `wrong path` + `outside` + `baremain`), "index.html": []byte(`
`), } problems, _ := linkProblems(t, control) for _, want := range []string{"/design-system/nope.html", "#nowhere", www.Origin + "/nope/", "https://example.com/"} { found := false for _, p := range problems { found = found || strings.Contains(p, want) } if !found { t.Errorf("the control's link to %s was not reported; the check would prove nothing. Reported:\n %s", want, strings.Join(problems, "\n ")) } } if len(problems) != 4 { t.Errorf("the control has 4 broken links and %d were reported:\n %s", len(problems), strings.Join(problems, "\n ")) } unpublishedControl(t) if t.Failed() { return } dist, err := build.Dist(fiesta.FS(), fiesta.Version) if err != nil { t.Fatalf("building the release the source would publish: %v", err) } tree, fellBack := linkTree(t, builtSite(t), os.DirFS("../../releases"), fiesta.Version, dist, publishedOnly) switch { case publishedOnly: t.Logf("%s=1: /1/ resolves against the newest published release only", publishedOnlyEnv) case fellBack: t.Logf("the source is ahead of releases/%s, so /1/ resolves against the release it builds", fiesta.Version) } problems, pages := linkProblems(t, tree) for _, p := range problems { t.Error(p) } read := map[string]bool{} frames, examples := 0, 0 for _, p := range pages { read[p] = true switch { case strings.Contains(p, "/frames/"): frames++ case strings.HasPrefix(p, "design-system/") && p != "design-system/index.html": examples++ } } for _, l := range www.Languages { if name := strings.TrimPrefix(pageURL(l), "/") + "index.html"; !read[name] { t.Errorf("the landing page in %s, %s, was not read", l, name) } } if !read["design-system/index.html"] { t.Error("the design system's index was not read") } if examples == 0 || frames == 0 { t.Errorf("%d example pages and %d frames were read; the design system has both", examples, frames) } } // unpublishedControl is the unpublished stretch on scratch trees. A // page links /1/fiesta.css, which 1.0.0 has, /1/copy.js, which only // the source builds, and /1/missing.js, which nothing does. Read // against 1.0.0 alone the last two fail; served as the release to come // only the last does. unpublished is held on both sides: a release // equal to the source build (its stamp aside) is published, one with a // file changed or none at all is not. func unpublishedControl(t *testing.T) { t.Helper() dist := map[string][]byte{"fiesta.css": []byte("body{}"), "copy.js": []byte("0")} dist["SHA256SUMS"] = sumsOf(dist) tree := map[string][]byte{ "_redirects": []byte("/1/* /1.0.0/:splat 200\n"), "1.0.0/fiesta.css": []byte("body{}"), "index.html": []byte(`
` + `
`), } // releases/1.0.0/ as it is published: fiesta.css, and no copy.js. published := map[string][]byte{"fiesta.css": []byte("body{}")} published["SHA256SUMS"] = sumsOf(published) releases := fstest.MapFS{} for name, data := range published { releases["1.0.0/"+name] = &fstest.MapFile{Data: data} } fallback, fellBack := linkTree(t, tree, releases, "1.0.0", dist, false) if !fellBack { 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") } shipped, fellBack := linkTree(t, tree, releases, "1.0.0", dist, true) if fellBack { t.Errorf("the unpublished control: with %s=1 the check still fell back to the source build", publishedOnlyEnv) } for _, c := range []struct { name string tree map[string][]byte want []string }{ {"against 1.0.0", tree, []string{"/1/copy.js", "/1/missing.js"}}, {"as the release to come", fallback, []string{"/1/missing.js"}}, {"as a new version's release", servingSource(t, tree, "1.1.0", dist), []string{"/1/missing.js"}}, // The ship check: a file only the source builds is a 404 on the // edge until it is released, and must fail here. {"published only", shipped, []string{"/1/copy.js", "/1/missing.js"}}, } { problems, _ := linkProblems(t, c.tree) var got []string for _, p := range problems { for _, link := range []string{"/1/fiesta.css", "/1/copy.js", "/1/missing.js"} { if strings.Contains(p, "resolves to "+link+",") { got = append(got, link) } } } if len(got) != len(problems) || !slices.Equal(got, c.want) { t.Errorf("the unpublished control, %s, reported %q; want only %q broken", c.name, problems, c.want) } } stamp := &fstest.MapFile{Data: []byte("fiesta release\n")} same := fstest.MapFS{"1.0.0/" + site.StampName: stamp} changed := fstest.MapFS{"1.0.0/" + site.StampName: stamp} for name, data := range dist { same["1.0.0/"+name] = &fstest.MapFile{Data: data} changed["1.0.0/"+name] = &fstest.MapFile{Data: data} } changed["1.0.0/copy.js"] = &fstest.MapFile{Data: []byte("1")} changed["1.0.0/SHA256SUMS"] = &fstest.MapFile{Data: sumsOf(map[string][]byte{"fiesta.css": dist["fiesta.css"], "copy.js": []byte("1")})} for _, c := range []struct { name string releases fs.FS want bool }{ {"the same files", same, false}, {"one file changed", changed, true}, {"no release directory", fstest.MapFS{}, true}, } { got, err := unpublished(c.releases, "1.0.0", dist) if err != nil || got != c.want { t.Errorf("unpublished with %s = %v, %v; want %v", c.name, got, err, c.want) } } }