a73x

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")
	}
}