internal/site/site_test.go
Ref: Size: 10.2 KiB History
package site
import (
"image"
_ "image/png"
"os"
"path/filepath"
"strings"
"testing"
)
// writeFixture lays down a minimal docs/ + site/ tree covering every
// published page, plus repo-internal material that must never publish.
// Returns (root, docs, siteDir).
func writeFixture(t *testing.T) (string, string, string) {
t.Helper()
root := t.TempDir()
docs := filepath.Join(root, "docs")
siteDir := filepath.Join(root, "site")
for _, d := range []string{docs, siteDir, filepath.Join(docs, "superpowers")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write := func(path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
for _, slug := range pages {
write(filepath.Join(docs, slug+".md"), "# "+slug+"\n\nbody\n")
}
// Repo-internal docs sitting alongside the published set.
write(filepath.Join(docs, "architecture.md"), "# internal\n")
write(filepath.Join(docs, "shape.html"), "<html>internal</html>")
write(filepath.Join(docs, "superpowers", "secret.md"), "# nope\n")
write(filepath.Join(root, "ROADMAP.md"), "# roadmap\n")
write(filepath.Join(siteDir, "index.md"), "# eitri\n\n[q](quickstart.md)\n")
write(filepath.Join(siteDir, "docs.md"),
"# docs\n\n- [q](quickstart.md)\n- [r](../ROADMAP.md)\n")
write(filepath.Join(siteDir, "template.html"),
`<title>{{.Title}}</title><meta name="description" content="{{.Description}}">`+
`<meta property="og:url" content="{{.URL}}"><meta property="og:image" content="{{.Image}}">`+
`<nav data-s="{{.Section}}"></nav>{{.Content}}`)
write(filepath.Join(siteDir, "style.css"), "body{}")
return root, docs, siteDir
}
// buildTree runs Build over the fixture and returns the output dir.
func buildTree(t *testing.T, distDir string) string {
return buildTreeAt(t, distDir, testBase)
}
// testBase stands in for the published site's own address.
const testBase = "https://eitri.test"
func buildTreeAt(t *testing.T, distDir, base string) string {
t.Helper()
root, docs, siteDir := writeFixture(t)
out := filepath.Join(root, "out")
cfg := Config{DocsDir: docs, SiteDir: siteDir, DistDir: distDir, OutDir: out, BaseURL: base}
if err := Build(cfg); err != nil {
t.Fatal(err)
}
return out
}
func read(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestBuildEmitsPublishedTree(t *testing.T) {
out := buildTree(t, "")
want := []string{"index.html", "style.css", "favicon.svg", "docs/index.html", "docs/roadmap/index.html", "dl/index.html"}
for _, slug := range pages {
want = append(want, "docs/"+slug+"/index.html")
}
for _, p := range want {
if _, err := os.Stat(filepath.Join(out, p)); err != nil {
t.Errorf("missing %s: %v", p, err)
}
}
}
func TestBuildNeverPublishesInternalDocs(t *testing.T) {
out := buildTree(t, "")
for _, p := range []string{
"docs/architecture", "docs/architecture/index.html",
"docs/shape.html",
"docs/superpowers", "docs/secret",
} {
if _, err := os.Stat(filepath.Join(out, p)); !os.IsNotExist(err) {
t.Errorf("%s must not be published (stat err = %v)", p, err)
}
}
}
func TestBuildRewritesDocLinks(t *testing.T) {
out := buildTree(t, "")
idx := read(t, filepath.Join(out, "docs", "index.html"))
for _, want := range []string{`href="/docs/quickstart/"`, `href="/docs/roadmap/"`} {
if !strings.Contains(idx, want) {
t.Errorf("docs index missing %s:\n%s", want, idx)
}
}
}
func TestBuildSectionsAndTitles(t *testing.T) {
out := buildTree(t, "")
if got := read(t, filepath.Join(out, "index.html")); !strings.Contains(got, `data-s="home"`) {
t.Errorf("landing section wrong:\n%s", got)
}
qs := read(t, filepath.Join(out, "docs", "quickstart", "index.html"))
if !strings.Contains(qs, `data-s="docs"`) || !strings.Contains(qs, "<title>eitri – quickstart</title>") {
t.Errorf("doc page section/title wrong:\n%s", qs)
}
}
func TestBuildPublishesAPISpec(t *testing.T) {
root, docs, siteDir := writeFixture(t)
spec := `{"openapi":"3.1.0"}`
if err := os.WriteFile(filepath.Join(docs, "openapi.json"), []byte(spec), 0o644); err != nil {
t.Fatal(err)
}
out := filepath.Join(root, "out")
if err := Build(Config{DocsDir: docs, SiteDir: siteDir, OutDir: out}); err != nil {
t.Fatal(err)
}
if got := read(t, filepath.Join(out, "openapi.json")); got != spec {
t.Errorf("published spec differs from source: got %q, want %q", got, spec)
}
}
func TestBuildWithoutAPISpec(t *testing.T) {
// Synthetic docs trees without a spec build fine — they just publish none.
out := buildTree(t, "")
if _, err := os.Stat(filepath.Join(out, "openapi.json")); !os.IsNotExist(err) {
t.Errorf("openapi.json must not be published without a source (stat err = %v)", err)
}
}
func TestBuildFailsOnLinkToUnpublishedDoc(t *testing.T) {
root, docs, siteDir := writeFixture(t)
// A published doc linking a repo-internal doc must fail the build, not
// ship a dangling link.
if err := os.WriteFile(filepath.Join(docs, "connecting.md"),
[]byte("[why](architecture.md)\n"), 0o644); err != nil {
t.Fatal(err)
}
err := Build(Config{DocsDir: docs, SiteDir: siteDir, OutDir: filepath.Join(root, "out")})
if err == nil || !strings.Contains(err.Error(), "architecture.md") {
t.Fatalf("want unpublished-link failure naming architecture.md, got %v", err)
}
}
func TestBuildFailsOnMissingPublishedDoc(t *testing.T) {
root, docs, siteDir := writeFixture(t)
if err := os.Remove(filepath.Join(docs, "quickstart.md")); err != nil {
t.Fatal(err)
}
err := Build(Config{DocsDir: docs, SiteDir: siteDir, OutDir: filepath.Join(root, "out")})
if err == nil {
t.Fatal("want failure when a published doc is missing")
}
}
// The tab icon is generated from the font, not copied from site/: there is no
// favicon source file to fall out of step with the mark on the page.
func TestBuildGeneratesTheFavicon(t *testing.T) {
out := buildTree(t, "")
icon := read(t, filepath.Join(out, "favicon.svg"))
for _, want := range []string{`viewBox="0 0 16 16"`, "<rect", "prefers-color-scheme: dark"} {
if !strings.Contains(icon, want) {
t.Errorf("favicon.svg missing %q:\n%s", want, icon)
}
}
}
// Every page carries its own card, so a pasted docs link previews as that
// page rather than as the site.
func TestBuildWritesACardPerPage(t *testing.T) {
out := buildTree(t, "")
names := []string{"home", "docs", "dl", "roadmap"}
names = append(names, pages...)
for _, name := range names {
path := filepath.Join(out, "og", name+".png")
f, err := os.Open(path)
if err != nil {
t.Errorf("no card for %q: %v", name, err)
continue
}
cfg, format, err := image.DecodeConfig(f)
f.Close()
if err != nil {
t.Errorf("%s: %v", path, err)
continue
}
if format != "png" || cfg.Width != 1200 || cfg.Height != 630 {
t.Errorf("%s: %s %dx%d, want png 1200x630", path, format, cfg.Width, cfg.Height)
}
}
}
// Scrapers fetch og:image on its own: a relative path would resolve against
// their host, not ours.
func TestBuildAdvertisesAbsolutePreviewURLs(t *testing.T) {
out := buildTree(t, "")
for page, want := range map[string][]string{
"index.html": {`content="https://eitri.test/og/home.png"`, `content="https://eitri.test/"`},
"docs/quickstart/index.html": {`content="https://eitri.test/og/quickstart.png"`, `content="https://eitri.test/docs/quickstart/"`},
"dl/index.html": {`content="https://eitri.test/og/dl.png"`, `content="https://eitri.test/dl/"`},
} {
html := read(t, filepath.Join(out, page))
for _, w := range want {
if !strings.Contains(html, w) {
t.Errorf("%s missing %s:\n%s", page, w, html)
}
}
}
}
// A local preview has no address to speak of, so its links stay relative.
func TestBuildLeavesPreviewLinksRelativeWithoutABase(t *testing.T) {
out := buildTreeAt(t, "", "")
html := read(t, filepath.Join(out, "index.html"))
for _, want := range []string{`content="/og/home.png"`, `content="/"`} {
if !strings.Contains(html, want) {
t.Errorf("index.html missing %s:\n%s", want, html)
}
}
}
// The description comes from the page's own opening prose.
func TestBuildDescribesEachPageFromItsProse(t *testing.T) {
out := buildTree(t, "")
html := read(t, filepath.Join(out, "docs", "faq", "index.html"))
if !strings.Contains(html, `name="description" content="body"`) {
t.Errorf("faq page not described from its prose:\n%s", html)
}
}
// The theme control lives in the shipped template, which every page is
// rendered through — so this reads that file rather than a fixture standing in
// for it. Firefox answers prefers-color-scheme from its own appearance
// setting, so a reader whose browser disagrees with them needs the override;
// the pre-paint script is what keeps a stored choice from flashing the other
// theme first, and the hidden attribute is deliberate — with no script the
// browser's preference decides and a dead button would be a lie.
func TestShippedTemplateCarriesTheThemeControl(t *testing.T) {
tmpl := read(t, filepath.Join("..", "..", "site", "template.html"))
for _, want := range []string{
`localStorage.getItem("eitri-theme")`, // applied before first paint
`localStorage.setItem("eitri-theme"`, // and remembered
`<button type="button" class="theme" hidden>`,
`root.dataset.theme = dark() ? "light" : "dark"`,
} {
if !strings.Contains(tmpl, want) {
t.Errorf("site/template.html no longer carries %q", want)
}
}
css := read(t, filepath.Join("..", "..", "site", "style.css"))
for _, want := range []string{`:root[data-theme="dark"]`, `:root:not([data-theme="light"])`} {
if !strings.Contains(css, want) {
t.Errorf("site/style.css no longer honours %q — the toggle would set an attribute nothing reads", want)
}
}
}
// nginx builds an absolute Location from its own scheme and listen port
// unless told otherwise, and the site is served behind a proxy — so a
// redirect went out pointing at http://eitri.sh:8080, which serves nothing.
// Any redirect in the config has to be relative.
func TestNginxRedirectsRelatively(t *testing.T) {
conf := read(t, filepath.Join("..", "..", "site", "nginx.conf"))
if strings.Contains(conf, "return 301") && !strings.Contains(conf, "absolute_redirect off;") {
t.Error("site/nginx.conf redirects without absolute_redirect off — Location would name nginx's own port")
}
}