Download
204 lines 6.7 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
package render_test

import (
	"bytes"
	"io"
	"log"
	"os"
	"strings"
	"testing"

	"code.bruce-hill.com/repo-renderer/internal/queue"
	"code.bruce-hill.com/repo-renderer/internal/render"
)

var zeroSHA = strings.Repeat("0", 40)

// captureLog sends the site's log to the returned buffer as well as the
// test log.
func (e *env) captureLog() *bytes.Buffer {
	var buf bytes.Buffer
	e.site.Log = log.New(io.MultiWriter(&buf, testWriter{e.t}), "", 0)
	return &buf
}

func (e *env) sha(rev string) string {
	e.t.Helper()
	return strings.TrimSpace(e.git(e.work, "rev-parse", rev))
}

// kick queues jobs the way the post-receive hook does, then drains the
// queue.
func (e *env) kick(jobs ...queue.Job) {
	e.t.Helper()
	for _, j := range jobs {
		if err := render.Enqueue(e.site.Cfg, j); err != nil {
			e.t.Fatalf("Enqueue %v: %v", j, err)
		}
	}
	if err := e.site.Kick(); err != nil {
		e.t.Fatalf("Kick: %v", err)
	}
	if b, err := os.ReadFile(e.site.Lay.QueuePath()); err == nil && len(b) > 0 {
		e.t.Errorf("queue left with %q", b)
	}
}

// refs lists a bare repository's refs.
func (e *env) refs(dir string) string {
	e.t.Helper()
	return e.git(dir, "for-each-ref", "--format=%(refname)")
}

// A push renders and exports its own repository and rebuilds the index;
// the other repositories are left alone. A push that changes nothing does
// none of it.
func TestKickRendersPushedRepo(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.git("", "clone", "-q", "--bare", e.site.Lay.GitDir("proj"), e.site.Lay.GitDir("other"))
	logs := e.captureLog()

	e.kick(queue.Job{Repo: "proj", Ref: "refs/heads/main", Old: zeroSHA, New: e.sha("main")})
	e.mustExist(e.page(""), "index.html", "git/proj.git/HEAD")
	e.mustNotExist("code/other.html", "git/other.git")

	// Jobs for one repository make one render, and when it changes
	// nothing there is nothing to export or index.
	if err := os.Remove(e.h("index.html")); err != nil {
		t.Fatal(err)
	}
	if err := os.RemoveAll(e.h("git/proj.git")); err != nil {
		t.Fatal(err)
	}
	logs.Reset()
	same := queue.Job{Repo: "proj", Ref: "refs/heads/main", Old: e.sha("main"), New: e.sha("main")}
	e.kick(same, same)
	if n := strings.Count(logs.String(), "up to date"); n != 1 {
		t.Errorf("proj rendered %d times, want once", n)
	}
	e.mustNotExist("index.html", "git/proj.git")
}

// A tag push archives the tag before the branch is rendered, since the
// render writes the download lists from the archives there are. A tag
// alone rewrites the download lists and the index.
func TestKickTagsBeforeBranch(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.render()
	e.git(e.work, "tag", "v1")
	e.git(e.work, "push", "-q", "origin", "v1")
	logs := e.captureLog()

	e.kick(
		queue.Job{Repo: "proj", Ref: "refs/heads/main", Old: e.sha("main"), New: e.sha("main")},
		queue.Job{Repo: "proj", Ref: "refs/tags/v1", Old: zeroSHA, New: e.sha("v1")},
	)
	out := logs.String()
	tag, render := strings.Index(out, "archiving tag v1"), strings.Index(out, "up to date")
	if tag < 0 || render < 0 || tag > render {
		t.Errorf("tag not archived before the render:\n%s", out)
	}
	e.mustExist("archive/proj/proj-v1.tar.gz")

	if err := os.Remove(e.h("index.html")); err != nil {
		t.Fatal(err)
	}
	e.git(e.work, "tag", "v2")
	e.git(e.work, "push", "-q", "origin", "v2")
	logs.Reset()
	e.kick(queue.Job{Repo: "proj", Ref: "refs/tags/v2", Old: zeroSHA, New: e.sha("v2")})
	mustNotContain(t, "log", logs.String(), "up to date")
	e.mustExist("archive/proj/proj-v2.tar.gz", "index.html", "git/proj.git/HEAD")
	mustContain(t, "download list", e.read("iframes/proj/downloads.html"), "proj-v1.tar.gz", "proj-v2.tar.gz")
}

// A push to a branch the site does not show renders nothing and says why;
// one to an allowlisted branch renders.
func TestKickSkipsUnshownBranch(t *testing.T) {
	e := newEnv(t, "proj", "repos:\n  proj:\n    branches: [dev]\n")
	e.seed()
	for _, b := range []string{"dev", "feature"} {
		e.git(e.work, "branch", b)
		e.git(e.work, "push", "-q", "origin", b)
	}
	logs := e.captureLog()

	e.kick(queue.Job{Repo: "proj", Ref: "refs/heads/feature", Old: zeroSHA, New: e.sha("feature")})
	mustContain(t, "log", logs.String(), "push to feature not shown")
	e.mustNotExist(e.page(""), "index.html")

	e.kick(queue.Job{Repo: "proj", Ref: "refs/heads/dev", Old: zeroSHA, New: e.sha("dev")})
	e.mustExist(e.page(""), "code/proj@dev.html", "index.html")
}

// A malformed line in the queue is logged and skipped; the jobs around it
// still run.
func TestKickIgnoresMalformedJobs(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	logs := e.captureLog()
	sha := e.sha("main")
	if err := os.MkdirAll(e.site.Lay.QueueDir, 0o755); err != nil {
		t.Fatal(err)
	}
	lines := []queue.Job{
		{Repo: "../proj", Ref: "refs/heads/main", Old: zeroSHA, New: sha},
		{Repo: "proj", Ref: "refs/heads/main", Old: zeroSHA, New: "main"},
		{Repo: "proj", Ref: "refs/heads/main", Old: zeroSHA, New: sha},
	}
	var q strings.Builder
	for _, j := range lines {
		q.WriteString(j.Format())
	}
	if err := os.WriteFile(e.site.Lay.QueuePath(), []byte(q.String()), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := e.site.Kick(); err != nil {
		t.Fatal(err)
	}
	if n := strings.Count(logs.String(), "queue: ignoring job"); n != 2 {
		t.Errorf("%d jobs ignored, want 2:\n%s", n, logs.String())
	}
	e.mustExist(e.page(""), "index.html")
	if _, err := os.Stat(e.site.Lay.QueuePath()); err == nil {
		t.Error("queue left behind")
	}
}

// Enqueue refuses jobs that could not have come from a push, and queues
// the rest.
func TestEnqueue(t *testing.T) {
	e := newEnv(t, "proj", "")
	sha := strings.Repeat("ab", 20)
	for _, j := range []queue.Job{
		{Repo: "../proj", Ref: "refs/heads/main", Old: zeroSHA, New: sha},
		{Repo: "", Ref: "refs/heads/main", Old: zeroSHA, New: sha},
		{Repo: "proj", Ref: "refs/heads/a\tb", Old: zeroSHA, New: sha},
		{Repo: "proj", Ref: "refs/heads/a\nb", Old: zeroSHA, New: sha},
		{Repo: "proj", Ref: "refs/heads/main", Old: "HEAD", New: sha},
		{Repo: "proj", Ref: "refs/heads/main", Old: zeroSHA, New: "main~1"},
		{Repo: "proj", Ref: "refs/heads/main", Old: zeroSHA, New: sha[:39]},
	} {
		if err := render.Enqueue(e.site.Cfg, j); err == nil {
			t.Errorf("Enqueue accepted %q", j.Format())
		}
	}
	if _, err := os.Stat(e.site.Lay.QueuePath()); err == nil {
		t.Errorf("rejected jobs queued: %q", mustReadFile(t, e.site.Lay.QueuePath()))
	}

	jobs := []queue.Job{
		{Repo: "proj", Ref: "refs/heads/main", Old: zeroSHA, New: sha},
		{Repo: "proj", Ref: "refs/tags/v1", Old: sha, New: zeroSHA},
	}
	for _, j := range jobs {
		if err := render.Enqueue(e.site.Cfg, j); err != nil {
			t.Errorf("Enqueue %q: %v", j.Format(), err)
		}
	}
	if got, want := mustReadFile(t, e.site.Lay.QueuePath()), jobs[0].Format()+jobs[1].Format(); got != want {
		t.Errorf("queue = %q, want %q", got, want)
	}
}