watch_test.go
package main
import (
"net/http"
"os"
"os/exec"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
// watchFixture is a server plus a watched config file in a temp dir,
// wired the same way main() wires them (watchConfig -> reloadConfig),
// with a fast poll and a reload-attempt counter for happens-after gating.
type watchFixture struct {
srv *server
dir string // temp dir holding the config and exhibit trees
cfgPath string
checked atomic.Int64 // completed reload attempts (accepted or rejected)
}
// newWatchFixture creates exhibit dir "one" (containing f.md) and "two" (containing g.md),
// writes an initial config mounting "one", starts the server and watcher, and returns the fixture.
func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture {
t.Helper()
dir := t.TempDir()
for _, f := range []string{"one/f.md", "two/g.md"} {
p := filepath.Join(dir, filepath.FromSlash(f))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("# hi\n"), 0o644); err != nil {
t.Fatal(err)
}
}
fx := &watchFixture{dir: dir, cfgPath: filepath.Join(dir, "vitrine.cfg")}
fx.writeConfig(t, "exhibits:\n one:\n path: one\n")
cfg, err := loadConfig(fx.cfgPath)
if err != nil {
t.Fatal(err)
}
fx.srv, err = newServer(cfg.Exhibits, theme{})
if err != nil {
t.Fatal(err)
}
watchConfig(fx.cfgPath, pollInterval, func() {
reloadConfig(fx.srv, fx.cfgPath)
fx.checked.Add(1)
})
return fx
}
func (fx *watchFixture) writeConfig(t *testing.T, text string) {
t.Helper()
if err := os.WriteFile(fx.cfgPath, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
func (fx *watchFixture) status(t *testing.T, url string) int {
t.Helper()
return get(t, fx.srv, url).Code
}
// waitFor polls cond until it holds.
// The timeout is damage control only; a passing test never leans on it.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for: %s", what)
}
time.Sleep(time.Millisecond)
}
}
func TestHotSwapServesNewMountsAndDropsOld(t *testing.T) {
fx := newWatchFixture(t, 2*time.Millisecond)
if fx.status(t, "/one/f.md") != http.StatusOK {
t.Fatal("initial mount not serving")
}
fx.writeConfig(t, "exhibits:\n two:\n path: two\n# swapped\n")
waitFor(t, "new mount to serve", func() bool {
return fx.status(t, "/two/g.md") == http.StatusOK
})
if code := fx.status(t, "/one/f.md"); code != http.StatusNotFound {
t.Errorf("old mount still serving after swap: status = %d", code)
}
}
func TestRejectedReloadKeepsOldConfigServing(t *testing.T) {
// Two flavors of bad: YAML that doesn't parse,
// and a clean parse whose exhibit dir fails to open (the setExhibits path).
for name, bad := range map[string]string{
"parse-error": "exhibits: [unclosed\n",
"unopenable-dir": "exhibits:\n ghost:\n path: no/such/dir\n",
} {
t.Run(name, func(t *testing.T) {
fx := newWatchFixture(t, 2*time.Millisecond)
before := fx.checked.Load()
fx.writeConfig(t, bad)
// Happens-after gate: wait for a reload attempt to complete,
// then assert it changed nothing.
waitFor(t, "a reload attempt on the bad config", func() bool {
return fx.checked.Load() > before
})
if code := fx.status(t, "/one/f.md"); code != http.StatusOK {
t.Errorf("old config stopped serving after rejected reload: status = %d", code)
}
if code := fx.status(t, "/ghost/"); code != http.StatusNotFound {
t.Errorf("rejected config partially applied: /ghost/ status = %d", code)
}
})
}
}
func TestRenameSwapPickedUpByInotify(t *testing.T) {
// The editor/sync-tool pattern: write a temp file, rename it over the config.
// The poll interval is set absurdly long so that ONLY inotify can pick this up:
// this test deliberately exercises the directory watch
// (a file-level watch would be orphaned by the rename).
fx := newWatchFixture(t, time.Hour)
tmp := fx.cfgPath + ".tmp"
if err := os.WriteFile(tmp, []byte("exhibits:\n two:\n path: two\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, fx.cfgPath); err != nil {
t.Fatal(err)
}
waitFor(t, "rename-swapped config to apply", func() bool {
return fx.status(t, "/two/g.md") == http.StatusOK
})
if code := fx.status(t, "/one/f.md"); code != http.StatusNotFound {
t.Errorf("old mount still serving after swap: status = %d", code)
}
}
func TestReloadRunsUpdateServerInfoForExposeGit(t *testing.T) {
fx := newWatchFixture(t, 2*time.Millisecond)
// Turn exhibit "one" into a real git repo; it is currently mounted WITHOUT expose-git,
// so no update-server-info has run and .git/info/refs does not exist.
repo := filepath.Join(fx.dir, "one")
for _, args := range [][]string{
{"init", "-q"},
{"-c", "user.name=t", "-c", "user.email=t@t", "add", "."},
{"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
infoRefs := filepath.Join(repo, ".git/info/refs")
if _, err := os.Stat(infoRefs); err == nil {
t.Fatal("info/refs exists before expose-git reload; test premise broken")
}
fx.writeConfig(t, "exhibits:\n one:\n path: one\n expose-git: true\n")
waitFor(t, "update-server-info to run on reload", func() bool {
_, err := os.Stat(infoRefs)
return err == nil
})
// And the exhibit actually serves the freshly generated refs.
waitFor(t, ".git/info/refs to be reachable over HTTP", func() bool {
return fx.status(t, "/one/.git/info/refs?fmt=raw") == http.StatusOK
})
}
func TestInotifyEventBufferParsing(t *testing.T) {
// Build a raw two-event buffer by hand and check name matching and overflow handling.
mkEvent := func(mask uint32, name string) []byte {
nameLen := (len(name)/16 + 1) * 16 // kernel pads names to alignment; emulate
buf := make([]byte, 16+nameLen)
putU32 := func(off int, v uint32) {
buf[off], buf[off+1], buf[off+2], buf[off+3] = byte(v), byte(v>>8), byte(v>>16), byte(v>>24)
}
putU32(4, mask)
putU32(12, uint32(nameLen))
copy(buf[16:], name)
return buf
}
for name, tc := range map[string]struct {
buf []byte
want bool
}{
"match": {mkEvent(0x80, "vitrine.cfg"), true},
"other-file": {mkEvent(0x80, "unrelated.txt"), false},
"second-event": {append(mkEvent(0x80, "unrelated.txt"), mkEvent(0x80, "vitrine.cfg")...), true},
"overflow": {mkEvent(0x4000, ""), true},
} {
if got := inotifyEventsMention(tc.buf, "vitrine.cfg"); got != tc.want {
t.Errorf("%s: got %v, want %v", name, got, tc.want)
}
}
}
watch_test.go · 6.6 KiB · 0d2c580698