1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006 | package render_test
import (
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"code.bruce-hill.com/repo-renderer/internal/config"
"code.bruce-hill.com/repo-renderer/internal/render"
)
// env is a throwaway site, all under one temporary directory, with one bare
// repo and a working clone.
type env struct {
t *testing.T
root string
host string
site *render.Site
repo string
work string
home string
}
const testHost = "test.example"
func newEnv(t *testing.T, repo, extraCfg string) *env {
t.Helper()
root := t.TempDir()
e := &env{t: t, root: root, host: testHost, repo: repo, home: t.TempDir()}
e.configure(extraCfg)
bare := e.site.Lay.GitDir(repo)
if err := os.MkdirAll(filepath.Dir(bare), 0o755); err != nil {
t.Fatal(err)
}
e.git("", "init", "-q", "--bare", "-b", "main", bare)
e.work = t.TempDir()
e.git(e.work, "init", "-q", "-b", "main")
e.git(e.work, "remote", "add", "origin", bare)
return e
}
// configure (re)builds the Site from config text so tests can change
// settings mid-way (which must force a full re-render).
func (e *env) configure(extra string) {
e.t.Helper()
src := fmt.Sprintf("host: %q\ngit_dir: %q\nhtdocs: %q\nspool_dir: %q\nstate_dir: %q\nlog: \"\"\nlexer_dirs: []\n%s",
e.host, filepath.Join(e.root, "git"), filepath.Join(e.root, "htdocs", e.host),
filepath.Join(e.root, "spool"), filepath.Join(e.root, "db"), extra)
cfg, err := config.Parse([]byte(src))
if err != nil {
e.t.Fatalf("config: %v", err)
}
// Most tests render a repository in no section, which the default
// keeps off the site.
if !strings.Contains(extra, "unlisted:") {
cfg.Site.Unlisted = "show"
}
site, err := render.New(cfg, log.New(testWriter{e.t}, "", 0))
if err != nil {
e.t.Fatalf("render.New: %v", err)
}
e.site = site
}
type testWriter struct{ t *testing.T }
func (w testWriter) Write(p []byte) (int, error) {
w.t.Log(strings.TrimRight(string(p), "\n"))
return len(p), nil
}
func (e *env) git(dir string, args ...string) string {
e.t.Helper()
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
cmd.Env = append(os.Environ(),
"HOME="+e.home,
"GIT_CONFIG_GLOBAL="+filepath.Join(e.home, "nogitconfig"),
"GIT_CONFIG_NOSYSTEM=1",
"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com",
"GIT_AUTHOR_DATE=2026-01-02T03:04:05Z", "GIT_COMMITTER_DATE=2026-01-02T03:04:05Z",
)
out, err := cmd.CombinedOutput()
if err != nil {
e.t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return string(out)
}
// gitStdin is git with the given standard input.
func (e *env) gitStdin(dir, stdin string, args ...string) string {
e.t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Stdin = strings.NewReader(stdin)
cmd.Env = append(os.Environ(), "HOME="+e.home, "GIT_CONFIG_GLOBAL="+filepath.Join(e.home, "nogitconfig"), "GIT_CONFIG_NOSYSTEM=1")
out, err := cmd.CombinedOutput()
if err != nil {
e.t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return string(out)
}
func (e *env) write(path, content string) {
e.t.Helper()
p := filepath.Join(e.work, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
e.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
e.t.Fatal(err)
}
}
func (e *env) rm(path string) {
e.t.Helper()
if err := os.Remove(filepath.Join(e.work, filepath.FromSlash(path))); err != nil {
e.t.Fatal(err)
}
}
func (e *env) commit(msg string) {
e.t.Helper()
e.git(e.work, "add", "-A")
e.git(e.work, "commit", "-q", "--allow-empty", "-m", msg)
}
func (e *env) push(force bool) {
e.t.Helper()
args := []string{"push", "-q", "origin", "main"}
if force {
args = append(args, "--force")
}
e.git(e.work, args...)
}
func (e *env) render() bool {
e.t.Helper()
changed, err := e.site.RenderRepo(e.repo, false)
if err != nil {
e.t.Fatalf("RenderRepo: %v", err)
}
if err := e.site.RenderIndex(); err != nil {
e.t.Fatalf("RenderIndex: %v", err)
}
return changed
}
func (e *env) h(rel string) string {
return filepath.Join(e.root, "htdocs", e.host, filepath.FromSlash(rel))
}
func (e *env) read(rel string) string {
e.t.Helper()
b, err := os.ReadFile(e.h(rel))
if err != nil {
e.t.Fatalf("read %s: %v", rel, err)
}
return string(b)
}
func (e *env) exists(rel string) bool {
_, err := os.Stat(e.h(rel))
return err == nil
}
func (e *env) mustExist(rels ...string) {
e.t.Helper()
for _, r := range rels {
if !e.exists(r) {
e.t.Errorf("expected %s to exist", r)
}
}
}
func (e *env) mustNotExist(rels ...string) {
e.t.Helper()
for _, r := range rels {
if e.exists(r) {
e.t.Errorf("expected %s to be gone", r)
}
}
}
func (e *env) page(path string) string {
if path == "" {
return "code/" + e.repo + ".html"
}
return "code/" + e.repo + "/" + path + ".html"
}
func (e *env) raw(path string) string { return "raw/" + e.repo + "/" + path }
// drawer is the repository's drawer document.
func (e *env) drawer() string { return "iframes/" + e.repo + "/filetree.html" }
// frame returns the drawer document the page at p shows, and the
// fragment that selects its entry there.
func (e *env) frame(p string) (doc, id string) {
e.t.Helper()
m := regexp.MustCompile(`<iframe class="drawer-frame" src="/(iframes/[^"#]+/filetree\.html)#([^"]*)"`).FindStringSubmatch(e.read(e.page(p)))
if m == nil {
e.t.Fatalf("%s: no drawer frame", p)
}
return e.read(m[1]), m[2]
}
// mustSelect checks that the page at p frames the drawer document and
// that its fragment, id, names an element there.
func (e *env) mustSelect(p, id string) string {
e.t.Helper()
doc, got := e.frame(p)
if got != id {
e.t.Errorf("%s: drawer fragment %q, want %q", p, got, id)
}
if doc != e.read(e.drawer()) {
e.t.Errorf("%s: frame is not %s", p, e.drawer())
}
if !strings.Contains(doc, ` id="`+id+`"`) {
e.t.Errorf("%s: drawer has no element with id %q", p, id)
}
return doc
}
// mustStat stats path, failing the test when it cannot.
func mustStat(t *testing.T, path string) os.FileInfo {
t.Helper()
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return st
}
// mustReadFile reads path, failing the test when it cannot.
func mustReadFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func mustContain(t *testing.T, what, s string, subs ...string) {
t.Helper()
for _, sub := range subs {
if !strings.Contains(s, sub) {
t.Errorf("%s: missing %q", what, sub)
}
}
}
func mustNotContain(t *testing.T, what, s string, subs ...string) {
t.Helper()
for _, sub := range subs {
if strings.Contains(s, sub) {
t.Errorf("%s: unexpectedly contains %q", what, sub)
}
}
}
// between returns the part of s from the first from up to the next to
// after it, failing the test when either is missing.
func between(t *testing.T, s, from, to string) string {
t.Helper()
_, rest, ok := strings.Cut(s, from)
if !ok {
t.Fatalf("no %q in output", from)
}
mid, _, ok := strings.Cut(rest, to)
if !ok {
t.Fatalf("no %q after %q in output", to, from)
}
return from + mid
}
func gunzip(t *testing.T, path string) string {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = f.Close() }() // read only
zr, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
b, err := io.ReadAll(zr)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// seed creates a representative first commit.
func (e *env) seed() {
e.write("README.md", "# Hello\n\nSee [the guide](docs/guide.md) and  .\n\n```lua\nlocal x = 1\n```\n")
e.write("src/main.c", "#include <stdio.h>\n\nint main(void) {\n\treturn 0;\n}\n")
e.write("src/util.h", "#pragma once\n")
e.write("docs/guide.md", "# Guide\n\nBack to [readme](../README.md).\n")
e.write("x.lua", "local x = 1\n")
e.write("foo", "plain foo\n")
e.write("foo.html", "<b>html file</b>\n")
e.write("d/index", "an index file\n")
e.write("img/logo.png", "\x89PNG\r\n\x1a\n\x00\x00binary")
e.write("bin.dat", "\x00\x01\x02binary")
e.write("vendor/lib.min.js", "minified\n")
e.write("note.svg", "<?xml version=\"1.0\"?>\n<!-- c -->\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1 1\"><rect fill=\"currentColor\" width=\"1\" height=\"1\"/></svg>\n")
e.commit("initial")
e.push(false)
}
// ---------------------------------------------------------------- tests
func TestInitialRender(t *testing.T) {
e := newEnv(t, "proj", "")
e.seed()
if !e.render() {
t.Fatal("first render reported no change")
}
e.mustExist(
"index.html", "index.html.gz",
"static/style.css", "static/chroma.css", "static/search.js", "static/ui.js", "static/favicon.svg",
"static/icon-dir.svg", "static/icon-dir-open.svg", "static/icon-file.svg", "static/icon-symlink.svg", "static/icon-submodule.svg",
e.page(""), e.page("")+".gz",
e.page("src"), e.page("src/main.c"), e.page("src/util.h"),
e.page("foo"), e.page("foo.html"), e.page("d"), e.page("d/index"),
e.page("bin.dat"), e.page("img/logo.png"),
e.raw("src/main.c"), e.raw("img/logo.png"), e.raw("bin.dat"),
"search/proj/index.html", "search/proj/corpus.json", "search/proj/corpus.json.gz",
)
index := e.read("index.html")
mustContain(t, "index", index, `href="/code/proj"`, `<time class="num ago" datetime="2026-01-02T03:04:05Z" title="2026-01-02 03:04 UTC">2026-01-02</time>`)
root := e.read(e.page(""))
mustContain(t, "root page", root,
`<h1>proj</h1>`, "git clone https://test.example/git/proj.git",
`href="/code/proj/src"`, `href="/code/proj/README.md"`,
`<h1 id="hello">Hello</h1>`, // README rendered
`href="/code/proj/docs/guide.md"`, `src="/raw/proj/img/logo.png"`, `src="/svg/proj/note.svg"`, // links rewritten
`class="pill"`, "total", // line counts
`class="drawer"`, `<iframe class="drawer-frame" src="/iframes/proj/filetree.html#."`,
)
mustNotContain(t, "root page", root, "Last commit", "Releases")
// Listing columns: Lines and Size, each an amount and a bar of the
// entry's share of the folder; every row has a last-modified date.
mustContain(t, "root page", root,
`<th class="bar lines">Lines</th><th class="bar size">Size</th>`, `<svg class="ring"`,
`<time class="ago" datetime="2026-01-02T03:04:05Z" title="2026-01-02 03:04 UTC">2026-01-02</time>`,
// The whole row links to the entry, the bar too, keeping its tooltip.
`<a class="bar-link" href="/code/proj/src" tabindex="-1" aria-hidden="true">`)
src := e.read(e.page("src"))
mustContain(t, "src listing", src, `<span class="amount">5 lines</span>`, `<span class="amount">1 line</span>`)
mustContain(t, "binary size", e.read(e.page("img")), `<span class="amount">0 lines</span>`, `<td class="bar size" title="100% of this folder's bytes">`, `<span class="amount">16 B</span>`)
main := e.read(e.page("src/main.c"))
// Titles name the repo, so pages from different repos and branches
// stay apart in tabs and history.
mustContain(t, "main.c title", main, `<title>proj/src/main.c · `)
mustContain(t, "src title", e.read(e.page("src")), `<title>proj/src/ · `)
// The branch, clone and download controls appear once per page.
if n := strings.Count(main, `class="clone-box"`); n != 1 {
t.Errorf("main.c: %d clone boxes, want 1", n)
}
// Every page shares one drawer document and selects its entry by
// fragment: a file by its path, a folder by the list of its entries,
// the root by the header. It holds the whole tree with every folder
// shut, folder names linking to their pages.
mustNotContain(t, "main.c page", main, `class="tree"`)
drawer := e.mustSelect("src/main.c", "src/main.c")
e.mustSelect("src", "src")
e.mustSelect("README.md", "README.md")
e.mustSelect("", ".")
mustContain(t, "drawer", drawer,
`<div class="drawer-head" id="."><a href="/code/proj">proj</a></div>`,
`<li class="d"><details><summary><i class="icon i-dir"></i><a href="/code/proj/src">src/</a></summary><ul id="src">`,
`<li class="f" id="src/main.c"><a href="/code/proj/src/main.c">`,
`<li class="d"><details><summary><i class="icon i-dir"></i><a href="/code/proj/docs">`, `<li class="f" id="docs/guide.md"><a href="/code/proj/docs/guide.md">`,
`<li class="f" id="README.md"><a href="/code/proj/README.md">`,
`<base target="_top">`)
// No link is a target: a browser would focus it, taking the page's
// focus into the frame.
mustNotContain(t, "drawer", drawer, "aria-current", "<a id=", "<details open")
mustContain(t, "main.c page", main, `<code class="chroma">`, `id="L1"`, `id="L5"`, `href="/raw/proj/src/main.c"`, "5 lines", `<span class="kt">int</span>`)
// Asset URLs carry the template hash, so a page never pairs with a
// cached copy of an older stylesheet or script.
v := "?v=" + e.site.TmplHash
mustContain(t, "main.c page", main, `<script src="/static/ui.js`+v+`"></script>`, `rel="icon" href="/static/favicon.svg`+v+`"`,
`href="/static/style.css`+v+`"`, `href="/static/chroma.css`+v+`"`)
mustContain(t, "drawer", drawer, `href="/static/style.css`+v+`"`, `src="/static/ui.js`+v+`"`)
// So do the stylesheet's icons, which a relative url() would leave
// unversioned.
mustContain(t, "style.css", e.read("static/style.css"), `url(icon-dir.svg`+v+`)`, `url(icon-file.svg`+v+`)`)
mustNotContain(t, "style.css", e.read("static/style.css"), `.svg)`)
mustNotContain(t, "main.c page", main, "search.js")
mustContain(t, "main.c path", main, `<nav class="pathbar"`, `<a href="/code/proj/src">src</a>`, `<span aria-current="page">main.c</span>`, "Modified 2026-01-02")
if got := e.read(e.raw("src/main.c")); got != "#include <stdio.h>\n\nint main(void) {\n\treturn 0;\n}\n" {
t.Errorf("raw content mismatch: %q", got)
}
// foo vs foo.html never collide.
mustContain(t, "foo", e.read(e.page("foo")), "plain foo")
mustContain(t, "foo.html", e.read(e.page("foo.html")), "<", "html file")
mustNotContain(t, "foo.html", e.read(e.page("foo.html")), "<b>html file</b>")
// A file literally named index inside a directory, and the dir page.
mustContain(t, "d/index", e.read(e.page("d/index")), "an index file")
mustContain(t, "d listing", e.read(e.page("d")), `href="/code/proj/d/index"`)
// Binary and image handling.
mustContain(t, "bin.dat", e.read(e.page("bin.dat")), "Binary file", `href="/raw/proj/bin.dat"`)
mustContain(t, "logo", e.read(e.page("img/logo.png")), `<img src="/raw/proj/img/logo.png"`)
// Markdown page has a rendered view and the source view.
guide := e.read(e.page("docs/guide.md"))
mustContain(t, "guide", guide, `id="v-rendered"`, `href="/code/proj/README.md"`, `<code class="chroma">`)
// Search page: the only one with a script.
mustContain(t, "search page", e.read("search/proj/index.html"), `<script src="/static/search.js`+v+`">`, `data-max="2000"`)
// Non-blank lines of code: main.c (4 of 5) + util.h (1). Prose is counted
// in words on its own row: README.md (11) + docs/guide.md (4), with
// markup-only tokens like "#" and "```" left out.
root = e.read(e.page(""))
mustContain(t, "totals", root, `<span class="pill total" title="7 lines of code across 4 files">Code`,
`<span class="pill total" title="20 words of documentation across 4 files (≈ 1 page)">Docs`)
mustContain(t, "stats", root, `<a class="pill" href="/types/proj/code/c" title="5 lines of C across 2 files">C <b>5</b></a>`,
`<div class="langs docs"`, `<a class="pill" href="/types/proj/docs/markdown" title="15 words of Markdown across 2 files (≈ 1 page)">Markdown <b>15</b></a>`)
code := between(t, root, `<div class="langs code"`, "</div>")
mustNotContain(t, "code stats", code, "Markdown")
// Binary data in bytes per format: img/logo.png (16) + bin.dat (9).
mustContain(t, "binary stats", root, `<span class="pill total" title="25 B of binary data across 2 files">Binary data <b>25 B</b></span>`,
`<a class="pill" href="/types/proj/binary/png" title="16 B of PNG across 1 file">PNG <b>16 B</b></a>`, `<a class="pill" href="/types/proj/binary/dat" title="9 B of DAT across 1 file">DAT <b>9 B</b></a>`)
// Each pill's page lists that type's files in its row's unit.
cPage := e.read("types/proj/code/c.html")
mustContain(t, "C page", cPage, `<div class="langs code"><span class="pill total">C <b>2 files · 5 lines of code</b></span>`,
`<a class="path-dir" href="/code/proj/src">src</a><span class="sep" aria-hidden="true">/</span><a href="/code/proj/src/main.c">main.c</a>`, `<td class="num">4 lines</td>`,
`<a class="path-dir" href="/code/proj/src">src</a><span class="sep" aria-hidden="true">/</span><a href="/code/proj/src/util.h">util.h</a>`, `<td class="num">1 line</td>`)
mustNotContain(t, "C page", cPage, "x.lua", "README.md")
mustContain(t, "Markdown page", e.read("types/proj/docs/markdown.html"),
`<a class="path-dir" href="/code/proj/docs">docs</a><span class="sep" aria-hidden="true">/</span><a href="/code/proj/docs/guide.md">guide.md</a>`, `<td class="num">4 words</td>`)
mustContain(t, "PNG page", e.read("types/proj/binary/png.html"),
`<a class="path-dir" href="/code/proj/img">img</a><span class="sep" aria-hidden="true">/</span><a href="/code/proj/img/logo.png">logo.png</a>`, `<td class="num">16 B</td>`)
// Only the root shows them; other pages would go stale on later pushes.
for _, p := range []string{"src", "src/main.c", "docs/guide.md"} {
mustNotContain(t, "stats on "+p, e.read(e.page(p)), `class="langs"`)
}
mustNotContain(t, "stats on search", e.read("search/proj/index.html"), `class="langs"`)
}
func TestIncrementalRender(t *testing.T) {
e := newEnv(t, "proj", "")
e.seed()
e.render()
if e.render() {
t.Error("second render with no changes reported work")
}
before := mustStat(t, e.h(e.page("src/util.h")))
time.Sleep(20 * time.Millisecond)
// Modify only: untouched pages must not be rewritten.
e.write("src/main.c", "int main(void) { return 1; }\n")
e.commit("modify")
e.push(false)
if !e.render() {
t.Fatal("render after modify reported no change")
}
mustContain(t, "main.c", e.read(e.page("src/main.c")), `class="mi">1</span>`)
after := mustStat(t, e.h(e.page("src/util.h")))
if !after.ModTime().Equal(before.ModTime()) {
t.Error("modify-only push rewrote an unrelated page")
}
// Add + delete: listings update, deleted outputs vanish, drawers refresh.
e.write("src/new.c", "// new\n")
e.rm("foo")
e.rm("docs/guide.md")
e.commit("add and delete")
e.push(false)
e.render()
e.mustExist(e.page("src/new.c"), e.raw("src/new.c"))
e.mustNotExist(e.page("foo"), e.raw("foo"), e.page("docs/guide.md"), e.page("docs"), e.raw("docs/guide.md"))
if _, err := os.Stat(filepath.Join(e.root, "htdocs", e.host, "code", "proj", "docs")); err == nil {
t.Error("empty output directory not pruned")
}
mustContain(t, "src listing", e.read(e.page("src")), "new.c")
mustNotContain(t, "root listing", e.read(e.page("")), `href="/code/proj/foo"`, `href="/code/proj/docs"`)
// The drawer now knows about new.c and has forgotten the rest.
mustContain(t, "drawer", e.mustSelect("src/new.c", "src/new.c"), `href="/code/proj/src/util.h"`)
mustNotContain(t, "drawer", e.read(e.drawer()), `href="/code/proj/foo"`, `href="/code/proj/docs`)
// Adding a file rewrites the drawer, but no other page.
readme := mustStat(t, e.h(e.page("README.md")))
util := mustStat(t, e.h(e.page("src/util.h")))
time.Sleep(20 * time.Millisecond)
e.write("src/extra.c", "// extra\n")
e.write("src/sub/deep.c", "// deep\n")
e.commit("extra")
e.push(false)
e.render()
e.mustSelect("src/extra.c", "src/extra.c")
e.mustSelect("src/sub", "src/sub")
mustContain(t, "drawer", e.mustSelect("src/sub/deep.c", "src/sub/deep.c"),
`<li class="d"><details><summary><i class="icon i-dir"></i><a href="/code/proj/src/sub">sub/</a></summary><ul id="src/sub">`)
for p, before := range map[string]os.FileInfo{e.page("README.md"): readme, e.page("src/util.h"): util} {
if after := mustStat(t, e.h(p)); !after.ModTime().Equal(before.ModTime()) {
t.Errorf("adding a file in src/ rewrote %s", p)
}
}
// There is one document, beside the download list.
if ents, err := os.ReadDir(e.h("iframes/proj")); err != nil || len(ents) != 4 {
t.Errorf("iframes/proj: %d entries, %v; want the two documents and their .gz", len(ents), err)
}
// Editing a file leaves the drawer alone.
doc := mustStat(t, e.h(e.drawer()))
time.Sleep(20 * time.Millisecond)
e.write("src/extra.c", "// extra, edited\n")
e.commit("edit")
e.push(false)
e.render()
if after := mustStat(t, e.h(e.drawer())); !after.ModTime().Equal(doc.ModTime()) {
t.Error("editing a file rewrote the drawer")
}
}
func TestForcePushTriggersFullRender(t *testing.T) {
e := newEnv(t, "proj", "")
e.seed()
e.render()
e.write("extra.txt", "extra\n")
e.write("gone/deep/x.txt", "x\n")
e.commit("extra")
e.push(false)
e.render()
e.mustExist(e.page("extra.txt"))
mustContain(t, "drawer", e.read(e.drawer()), `href="/code/proj/gone/deep/x.txt"`)
// Rewrite history so extra.txt never existed.
e.git(e.work, "reset", "-q", "--hard", "HEAD~1")
e.write("other.txt", "other\n")
e.commit("replacement")
e.push(true)
e.render()
e.mustNotExist(e.page("extra.txt"), e.raw("extra.txt"))
mustContain(t, "drawer", e.read(e.drawer()), `id="other.txt"`)
mustNotContain(t, "drawer", e.read(e.drawer()), `gone`)
}
func TestConfigChangeForcesFullRender(t *testing.T) {
e := newEnv(t, "proj", "")
e.seed()
e.render()
mustNotContain(t, "root", e.read(e.page("")), "My Site")
e.configure("site:\n title: My Site\n")
if !e.render() {
t.Fatal("config change did not trigger a render")
}
mustContain(t, "root", e.read(e.page("")), "My Site")
mustContain(t, "deep page", e.read(e.page("src/main.c")), "My Site")
}
// describe writes the repository's description file.
func (e *env) describe(desc string) {
e.t.Helper()
if err := os.WriteFile(filepath.Join(e.site.Lay.GitDir(e.repo), "description"), []byte(desc+"\n"), 0o644); err != nil {
e.t.Fatal(err)
}
}
// TestDescriptionChangeForcesFullRender: every page carries the description,
// which lives outside the commit, so changing the description file
// re-renders every page, even with no new commit.
func TestDescriptionChangeForcesFullRender(t *testing.T) {
e := newEnv(t, "proj", "")
e.describe("First words")
e.seed()
e.render()
if e.render() {
t.Fatal("re-render with nothing changed")
}
e.describe("Second words")
if !e.render() {
t.Fatal("description change did not trigger a render")
}
for _, rel := range []string{e.page(""), e.page("src/main.c"), "search/proj/index.html", "index.html"} {
html := e.read(rel)
mustContain(t, rel, html, "Second words")
mustNotContain(t, rel, html, "First words")
}
if e.render() {
t.Fatal("re-render after the description was rendered")
}
}
// TestRepoTags: a repository's tags show on its index row and its root
// listing, and changing them in the config re-renders both.
func TestRepoTags(t *testing.T) {
e := newEnv(t, "proj", "repos:\n proj:\n tags: [lua, <vibe>]\n")
e.describe("A project")
e.seed()
e.render()
mustContain(t, "index", e.read("index.html"),
`<span class="desc">A project <span class="tags"><span class="tag">lua</span><span class="tag"><vibe></span></span></span>`)
mustContain(t, "root listing", e.read(e.page("")),
`<ul class="tags" aria-label="Tags"><li class="tag">lua</li><li class="tag"><vibe></li></ul>`)
mustNotContain(t, "file page", e.read(e.page("src/main.c")), `class="tags"`)
e.configure("repos:\n proj:\n tags: [tomo]\n")
if !e.render() {
t.Fatal("tag change did not trigger a render")
}
for _, rel := range []string{"index.html", e.page("")} {
html := e.read(rel)
mustContain(t, rel, html, ">tomo<")
mustNotContain(t, rel, html, ">lua<")
}
e.configure("")
e.render()
mustNotContain(t, "untagged index", e.read("index.html"), `class="tags"`)
}
func TestValidPath(t *testing.T) {
ok := []string{"a", "a/b", ".hidden", "a/..b", "weird name.txt", "日本"}
bad := []string{"", "/a", "a/../b", "..", "a//b", "a/", "a\x00b", "a\nb"}
for _, p := range ok {
if !render.ValidPath(p) {
t.Errorf("ValidPath(%q) = false", p)
}
}
for _, p := range bad {
if render.ValidPath(p) {
t.Errorf("ValidPath(%q) = true", p)
}
}
if n, ok := render.SanitizeTag("rel/1.0"); !ok || n != "rel-1.0" {
t.Errorf("SanitizeTag: %q %v", n, ok)
}
for _, bad := range []string{".hidden", "a b", "../x", ""} {
if _, ok := render.SanitizeTag(bad); ok {
t.Errorf("SanitizeTag(%q) accepted", bad)
}
}
}
// TestUnsafeTreePathNeverTouchesOutput pushes a tree with an entry named ".."
// (git mktree accepts it; only fsck complains) and checks that neither a
// full nor an incremental render writes or deletes anything outside the
// repository's own output directories.
func TestUnsafeTreePathNeverTouchesOutput(t *testing.T) {
e := newEnv(t, "proj", "")
e.seed()
e.render()
// Files that "../evil" would resolve to from code/proj, raw/proj, svg/proj.
victims := []string{"code/evil.html", "raw/evil", "svg/evil"}
for _, v := range victims {
if err := os.MkdirAll(filepath.Dir(e.h(v)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(e.h(v), []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
}
blob := strings.TrimSpace(e.git(e.work, "rev-parse", "HEAD:foo"))
sub := strings.TrimSpace(e.gitStdin(e.work, fmt.Sprintf("100644 blob %s\tevil\n", blob), "mktree"))
top := strings.TrimSpace(e.git(e.work, "rev-parse", "HEAD^{tree}"))
listing := e.git(e.work, "ls-tree", top)
tree := strings.TrimSpace(e.gitStdin(e.work, listing+fmt.Sprintf("040000 tree %s\t..\n", sub), "mktree"))
commit := strings.TrimSpace(e.git(e.work, "commit-tree", tree, "-p", "HEAD", "-m", "evil"))
e.git(e.work, "update-ref", "refs/heads/main", commit)
e.push(false)
for _, full := range []bool{false, true} {
if _, err := e.site.RenderRepo("proj", full); err != nil {
t.Fatalf("render (full=%v): %v", full, err)
}
for _, v := range victims {
if b, err := os.ReadFile(e.h(v)); err != nil || string(b) != "keep me" {
t.Errorf("full=%v: %s was touched: %v %q", full, v, err, b)
}
}
}
// Removing the entry again must be just as harmless.
e.git(e.work, "update-ref", "refs/heads/main", "HEAD~1")
e.push(true)
if _, err := e.site.RenderRepo("proj", false); err != nil {
t.Fatal(err)
}
for _, v := range victims {
if _, err := os.Stat(e.h(v)); err != nil {
t.Errorf("%s deleted after removal: %v", v, err)
}
}
}
// TestDirFileFlip: a directory replaced by a file of the same name (and
// back) keeps exactly one page at that path, and a rename inside a
// one-file directory leaves no gap for the delete/create race.
func TestDirFileFlip(t *testing.T) {
e := newEnv(t, "proj", "")
e.seed()
e.render()
mustContain(t, "dir page", e.read(e.page("d")), `class="listing`)
if err := os.RemoveAll(filepath.Join(e.work, "d")); err != nil {
t.Fatal(err)
}
e.write("d", "now a file\n")
e.commit("dir to file")
e.push(false)
e.render()
mustContain(t, "file page", e.read(e.page("d")), `class="file file-code `, "now a file")
e.mustExist(e.raw("d"))
e.mustNotExist(e.page("d/index"), e.raw("d/index"))
e.rm("d")
e.write("d/other", "back to a dir\n")
e.commit("file to dir")
e.push(false)
e.render()
mustContain(t, "dir page again", e.read(e.page("d")), `class="listing`, "other")
e.mustExist(e.raw("d/other"))
e.rm("d/other")
e.write("d/renamed", "back to a dir\n")
e.commit("rename in place")
e.push(false)
e.render()
e.mustExist(e.page("d/renamed"), e.raw("d/renamed"))
e.mustNotExist(e.page("d/other"))
}
// TestMissingPreviousCommit: if the commit recorded in the state file has
// been pruned (or the state is nonsense), the next render is full instead
// of failing until someone runs render -full.
func TestMissingPreviousCommit(t *testing.T) {
e := newEnv(t, "proj", "")
e.seed()
e.render()
stateFile := e.site.Lay.StatePath("proj")
rewrite := func(sha string) {
t.Helper()
b, err := os.ReadFile(stateFile)
if err != nil {
t.Fatal(err)
}
var st map[string]any
if err := json.Unmarshal(b, &st); err != nil {
t.Fatal(err)
}
st["sha"] = sha
if b, err = json.Marshal(st); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(stateFile, b, 0o644); err != nil {
t.Fatal(err)
}
}
e.write("src/new.c", "int y;\n")
e.commit("more")
e.push(false)
for _, sha := range []string{strings.Repeat("d", 40), "abc"} {
rewrite(sha)
if !e.render() {
t.Errorf("state sha %q: expected a render", sha)
}
e.mustExist(e.page("src/new.c"), e.page("src/main.c"))
}
}
// TestBranchViews: allowlisted branches render under /code/<repo>@<branch>
// (slash written as ":"), unlisted and missing ones do not, the default
// branch's URLs are unchanged, and dropping a branch from the allowlist
// removes its pages.
func TestBranchViews(t *testing.T) {
cfg := "repos:\n proj:\n branches: [dev, my-name/feature-x, ghost]\n"
e := newEnv(t, "proj", cfg)
e.seed()
for _, b := range []string{"dev", "my-name/feature-x", "secret"} {
e.git(e.work, "checkout", "-q", "-b", b, "main")
e.write("branch.txt", "on "+b+"\n")
e.commit("work on " + b)
e.git(e.work, "push", "-q", "origin", b)
}
e.git(e.work, "checkout", "-q", "main")
e.push(false)
e.render()
e.mustExist("code/proj.html", "code/proj@dev.html", "code/proj@dev/branch.txt.html",
"code/proj@my-name:feature-x.html", "code/proj@my-name:feature-x/branch.txt.html",
"raw/proj@dev/branch.txt", "search/proj@dev/index.html", "static/chroma.css")
e.mustNotExist("code/proj@secret.html", "code/proj@ghost.html", "code/proj/branch.txt.html")
mustContain(t, "dev file", e.read("code/proj@dev/branch.txt.html"), "on dev")
mustNotContain(t, "default root", e.read("code/proj.html"), "branch.txt")
// The picker lists the default branch first on every page of every view,
// and only non-default views ask not to be indexed.
for _, page := range []string{"code/proj.html", "code/proj@dev.html"} {
mustContain(t, page, e.read(page), `href="/code/proj"`, `href="/code/proj@dev"`, `href="/code/proj@my-name:feature-x"`)
mustNotContain(t, page, e.read(page), "secret", "ghost")
}
mustContain(t, "dev file", e.read("code/proj@dev/branch.txt.html"), `href="/code/proj"`,
`href="/code/proj@dev"`, `href="/code/proj@my-name:feature-x"`)
mustNotContain(t, "default", e.read("code/proj.html"), `name="robots"`)
mustContain(t, "dev", e.read("code/proj@dev.html"), `<meta name="robots" content="noindex">`, "proj@dev")
// Each view frames a download list of its own tip.
dev := strings.TrimSpace(e.git(e.work, "rev-parse", "dev"))
mustContain(t, "dev", e.read("code/proj@dev.html"), `src="/iframes/proj@dev/downloads.html"`)
mustContain(t, "dev downloads", e.read("iframes/proj@dev/downloads.html"), `href="/snapshot/proj@dev/proj@`+dev[:12]+`.tar.gz"`, "work on dev")
e.mustExist("snapshot/proj@dev/proj@" + dev[:12] + ".tar.gz")
// A push to one branch updates only that branch.
before := e.read("code/proj@my-name:feature-x/branch.txt.html")
e.git(e.work, "checkout", "-q", "dev")
e.write("branch.txt", "dev again\n")
e.commit("more dev")
e.git(e.work, "push", "-q", "origin", "dev")
e.git(e.work, "checkout", "-q", "main")
if !e.render() {
t.Error("render after pushing dev reported no change")
}
mustContain(t, "dev file", e.read("code/proj@dev/branch.txt.html"), "dev again")
if e.read("code/proj@my-name:feature-x/branch.txt.html") != before {
t.Error("untouched branch was rewritten")
}
// Allowlisted branches are clonable from the export.
if err := e.site.ExportPub("proj", "main"); err != nil {
t.Fatal(err)
}
pub := e.site.Lay.PubDir("proj")
for _, f := range []string{"refs/heads/main", "refs/heads/dev", "refs/heads/my-name/feature-x"} {
if _, err := os.Stat(filepath.Join(pub, f)); err != nil {
t.Errorf("export missing %s", f)
}
}
if _, err := os.Stat(filepath.Join(pub, "refs/heads/secret")); err == nil {
t.Error("export leaks unlisted branch")
}
// Dropping a branch from the allowlist retires its pages everywhere.
e.mustExist("iframes/proj@dev/filetree.html")
e.configure("repos:\n proj:\n branches: [my-name/feature-x]\n")
e.render()
e.mustNotExist("code/proj@dev.html", "code/proj@dev", "raw/proj@dev", "search/proj@dev",
"iframes/proj@dev", "archive/proj@dev", "snapshot/proj@dev")
if _, err := os.Stat(e.site.Lay.StatePath("proj@dev")); err == nil {
t.Error("state of retired branch view kept")
}
e.mustExist("code/proj@my-name:feature-x.html", "code/proj.html")
mustNotContain(t, "default", e.read("code/proj.html"), `/code/proj@dev`)
}
// TestBranchPickerKeepsPath: the picker on a page links to the same path on
// each other branch, or else to the nearest directory above it there, and a
// push that adds or removes that path on the other branch re-renders the
// page so the link follows.
// TestBranchPickerLinksToRoots: the picker links to each branch's root, so
// one branch's pages never depend on another branch's layout, and a push
// to one branch leaves the other's pages alone.
func TestBranchPickerLinksToRoots(t *testing.T) {
e := newEnv(t, "proj", "repos:\n proj:\n branches: [dev]\n")
e.seed()
e.git(e.work, "checkout", "-q", "-b", "dev", "main")
e.write("src/dev.c", "int dev;\n")
e.commit("dev work")
e.git(e.work, "push", "-q", "origin", "dev")
e.git(e.work, "checkout", "-q", "main")
e.render()
// picker is the branch menu of a page.
picker := func(page string) string {
p := e.read(page)
i := strings.Index(p, `class="dl branches"`)
if i < 0 {
t.Fatalf("%s: no branch picker", page)
}
return p[i : i+strings.Index(p[i:], "</details>")]
}
for _, page := range []string{"code/proj@dev/src/dev.c.html", "code/proj/src.html", "search/proj@dev/index.html"} {
mustContain(t, page, picker(page), `href="/code/proj"`, `href="/code/proj@dev"`)
}
before := e.read("code/proj@dev/src/main.c.html")
e.write("src/dev.c", "int main_too;\n")
e.commit("dev.c on main")
e.push(false)
e.render()
if e.read("code/proj@dev/src/main.c.html") != before {
t.Error("a push to main rewrote a dev page")
}
}
func TestOpenGraphTags(t *testing.T) {
e := newEnv(t, "proj", "site:\n title: My code\n tagline: Things & stuff\n")
e.describe("A <small> project")
e.seed()
e.render()
common := []string{
`<meta property="og:type" content="website">`,
`<meta property="og:site_name" content="My code">`,
`<meta name="twitter:card" content="summary">`,
}
desc := `<meta property="og:description" content="A <small> project">`
for _, c := range []struct{ rel, title, url, desc string }{
{"index.html", "My code", "/", `<meta property="og:description" content="Things & stuff">`},
{e.page(""), "proj", "/code/proj", desc},
{e.page("src"), "proj/src/", "/code/proj/src", desc},
{e.page("src/main.c"), "proj/src/main.c", "/code/proj/src/main.c", desc},
{"search/proj/index.html", "Search proj", "/search/proj/", desc},
} {
html := e.read(c.rel)
mustContain(t, c.rel, html, common...)
mustContain(t, c.rel, html,
`<meta property="og:title" content="`+c.title+`">`,
`<meta property="og:url" content="https://`+testHost+c.url+`">`,
c.desc)
}
}
// TestRemoveOthers: render --all publishes exactly what is in git_dir. A
// repository removed from it loses every view, its clone export, sitemap
// and state; the others, and files no repository could have left, stay.
func TestRemoveOthers(t *testing.T) {
e := newEnv(t, "proj", "repos:\n proj:\n branches: [dev]\n")
e.seed()
e.git(e.work, "checkout", "-q", "-b", "dev", "main")
e.write("branch.txt", "on dev\n")
e.commit("work on dev")
e.git(e.work, "push", "-q", "origin", "dev")
e.git(e.work, "checkout", "-q", "main")
e.render()
if err := e.site.ExportPub("proj", "main"); err != nil {
t.Fatal(err)
}
e.git("", "clone", "-q", "--bare", e.site.Lay.GitDir("proj"), e.site.Lay.GitDir("other"))
if _, err := e.site.RenderRepo("other", false); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(e.h("code/notes.txt"), []byte("mine\n"), 0o644); err != nil {
t.Fatal(err)
}
gone := []string{"code/proj.html", "code/proj", "code/proj@dev.html", "code/proj@dev",
"raw/proj", "raw/proj@dev", "search/proj", "search/proj@dev", "iframes/proj", "iframes/proj@dev",
"types/proj", "snapshot/proj", "snapshot/proj@dev", "git/proj.git", "sitemap/proj.xml"}
e.mustExist(gone...)
kept := []string{"code/other.html", "code/other/src/main.c.html", "search/other/index.html", "code/notes.txt"}
// With git_dir missing, nothing is removed.
saved := e.site.Lay.GitBase + ".saved"
if err := os.Rename(e.site.Lay.GitBase, saved); err != nil {
t.Fatal(err)
}
if err := e.site.RemoveOthers(nil); err == nil {
t.Error("RemoveOthers removed output with git_dir missing")
}
if err := os.Rename(saved, e.site.Lay.GitBase); err != nil {
t.Fatal(err)
}
e.mustExist(gone...)
if err := os.RemoveAll(e.site.Lay.GitDir("proj")); err != nil {
t.Fatal(err)
}
repos, err := e.site.Repos()
if err != nil {
t.Fatal(err)
}
if err := e.site.RemoveOthers(repos); err != nil {
t.Fatal(err)
}
if err := e.site.RenderIndex(); err != nil {
t.Fatal(err)
}
e.mustNotExist(gone...)
e.mustExist(kept...)
for _, key := range []string{"proj", "proj@dev"} {
if _, err := os.Stat(e.site.Lay.StatePath(key)); err == nil {
t.Errorf("state of %s kept", key)
}
}
mustNotContain(t, "index", e.read("index.html"), `href="/code/proj"`)
mustContain(t, "index", e.read("index.html"), `href="/code/other"`)
}
// privateConfig is config for a site with a private one, and the
// repositories listed public, the rest private.
func (e *env) privateConfig(listed ...string) string {
return fmt.Sprintf("private_htdocs: %q\nsite:\n unlisted: private\nsections:\n - title: Public\n repos: [%s]\n",
filepath.Join(e.root, "private", e.host), strings.Join(listed, ", "))
}
|