vitrine

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 · c6ea0e9b8f