Move the E2E suite from Bun and Playwright to Go
The tests now live in internal/web/e2e and run with go test. Each test starts the real router in-process on a random port with a throwaway data directory, drives it over HTTP with cookie-jar sessions, and asserts on the rendered HTML with goquery. Form clicks became POSTs to the same actions with the same fields. - All 382 Playwright tests are ported, 386 subtests. The whole suite runs in about 30 seconds instead of 100 and leaves no data-test dirs. - The CI tests carry a Go port of the mock Docker Engine API served over a unix socket. - A small rod-based browser suite covers the flows that need JavaScript or a layout engine: sort auto-submit, theme script, reaction picker, label filter, create-tag toggle, login form, console errors, horizontal overflow at 360 and 1280 px, and the raw SVG sandbox. It finds Chromium via HEARTHFORGE_BROWSER, PATH, or the Playwright cache and skips otherwise. - tests/ is removed. The CI config installs openssh-client in the tools step so the e2e tests run in the test step, and replaces the Bun e2e step with a browser step that installs Chromium and runs TestBrowser. - README development section updated. New test-only dependencies: goquery and rod. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
M.hearthforge-ci.toml
@@ -9,8 +9,6 @@ memory_limit = "4g"
cache = [
{ path = "/root/go/pkg/mod", max_size = "2g" },
{ path = "/root/.cache/go-build", max_size = "2g" },
{ path = "/root/.bun", max_size = "1g" },
{ path = "/root/.cache/ms-playwright", max_size = "2g" },
]
[on]
@@ -20,6 +18,7 @@ tag = true
[[steps]]
name = "tools"
run_sh = """
apt-get update -qq && apt-get install -y -qq --no-install-recommends openssh-client git > /dev/null
go install mvdan.cc/gofumpt@latest
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
go install golang.org/x/vuln/cmd/govulncheck@latest
@@ -53,14 +52,9 @@ run_sh = "cd project && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o hea
publish_file = ["/ci/build/project/hearthforge"]
[[steps]]
name = "e2e"
timeout = 2400
name = "browser"
timeout = 1200
run_sh = """
apt-get update -qq && apt-get install -y -qq --no-install-recommends unzip openssh-client git > /dev/null
curl -fsSL https://bun.sh/install | bash
export PATH="/root/.bun/bin:$PATH"
cd project/tests
bun install --frozen-lockfile
bunx playwright install --with-deps chromium
HEARTHFORGE_BIN=/ci/build/project/hearthforge bun run test
apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium > /dev/null
cd project && HEARTHFORGE_BROWSER=$(command -v chromium) go test -count=1 -run 'TestBrowser' ./internal/web/e2e/
"""
MREADME.md
@@ -129,11 +129,10 @@ an example file. Setup, the run model, caches, and known pitfalls are documented
```bash
go build -o hearthforge ./cmd/hearthforge
go test ./... # unit tests
cd tests && bun install && bun run test # Playwright E2E suite against the built binary
go test ./... # unit and end-to-end tests
```
The E2E suite lives in `tests/` and needs Bun and a Chromium install for Playwright (`bunx playwright install chromium`). It spawns the compiled binary with a throwaway `DATA_DIR`.
The end-to-end suite lives in `internal/web/e2e/`. It starts the real server in-process on a throwaway data directory and drives it over HTTP. It needs `git` and `ssh-keygen`. The browser tests in that package (`TestBrowser*`) need a Chromium binary. They look at `HEARTHFORGE_BROWSER`, then `PATH`, then a Playwright download, and skip when none is found.
Formatting and linting:
Mgo.mod
@@ -6,10 +6,12 @@ toolchain go1.26.6
require (
github.com/BurntSushi/toml v1.6.0
github.com/PuerkitoBio/goquery v1.13.0
github.com/alecthomas/chroma/v2 v2.27.0
github.com/gabriel-vasile/mimetype v1.4.15
github.com/gliderlabs/ssh v0.3.8
github.com/go-chi/chi/v5 v5.3.2
github.com/go-rod/rod v0.116.2
github.com/go-webauthn/webauthn v0.18.1
github.com/klauspost/compress v1.20.0
github.com/microcosm-cc/bluemonday v1.0.27
@@ -21,6 +23,7 @@ require (
)
require (
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
@@ -38,6 +41,11 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.48.0 // indirect
modernc.org/libc v1.75.7 // indirect
Mgo.sum
@@ -1,11 +1,15 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM=
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -22,6 +26,8 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.18.1 h1:KaQw6M+ODLvxHwddyeo6zFhhmicfLup/BClhigB6A+0=
@@ -62,6 +68,20 @@ github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
Ainternal/web/e2e/auth_test.go
@@ -0,0 +1,147 @@
package e2e
import (
"net/http"
"net/url"
"strings"
"testing"
"hearthforge/internal/db"
)
func TestAuth(t *testing.T) {
e := newEnv(t)
t.Run("homepage loads", func(t *testing.T) {
r := e.anon().get("/").mustStatus(200)
if !strings.Contains(r.Text("title"), "Hearthforge") {
t.Errorf("title = %q", r.Text("title"))
}
})
t.Run("wrong password shows error", func(t *testing.T) {
r := e.anon().post("/login", url.Values{"username": {"admin"}, "password": {"wrongpassword"}})
if !strings.Contains(r.Text(".form-error"), "Invalid") {
t.Errorf("error = %q", r.Text(".form-error"))
}
})
t.Run("correct credentials redirect to homepage", func(t *testing.T) {
s := e.admin()
if !s.get("/").Has(".nav-user") {
t.Error("nav-user missing after login")
}
})
t.Run("register new user", func(t *testing.T) {
s := e.register("alice", "password123")
if got := s.get("/").Text(".nav-user"); got != "alice" {
t.Errorf("nav-user = %q", got)
}
})
t.Run("register with mismatched passwords shows error", func(t *testing.T) {
r := e.anon().post("/register", url.Values{
"username": {"bob"}, "password": {"password123"}, "password2": {"different456"},
})
if !strings.Contains(r.Text(".form-error"), "match") {
t.Errorf("error = %q", r.Text(".form-error"))
}
})
t.Run("register with duplicate username shows error", func(t *testing.T) {
r := e.anon().post("/register", url.Values{
"username": {"alice"}, "password": {"password123"}, "password2": {"password123"},
})
if !strings.Contains(r.Text(".form-error"), "taken") {
t.Errorf("error = %q", r.Text(".form-error"))
}
})
t.Run("cross-origin POST is rejected", func(t *testing.T) {
e.anon().post("/login", url.Values{"username": {"admin"}, "password": {adminPass}},
"Origin", "http://evil.example").mustStatus(http.StatusForbidden)
})
t.Run("logout clears session", func(t *testing.T) {
s := e.admin()
s.post("/logout", nil).mustRedirect("/")
r := s.get("/")
if r.Has(".nav-user") {
t.Error("nav-user still shown after logout")
}
if !r.Has(`a[href="/login"]`) {
t.Error("sign-in link missing after logout")
}
})
}
func TestCSRFDevMode(t *testing.T) {
e := newEnv(t)
bad := url.Values{"username": {"admin"}, "password": {"wrong"}}
t.Run("no HSTS header", func(t *testing.T) {
if v := e.anon().get("/health").Header.Get("Strict-Transport-Security"); v != "" {
t.Errorf("HSTS = %q", v)
}
})
t.Run("POST with no Origin is allowed", func(t *testing.T) {
if r := e.anon().post("/login", bad); r.Code == 403 {
t.Error("rejected")
}
})
t.Run("POST with same-origin Origin is allowed", func(t *testing.T) {
if r := e.anon().post("/login", bad, "Origin", e.Base); r.Code == 403 {
t.Error("rejected")
}
})
t.Run("POST with mismatched Origin is rejected", func(t *testing.T) {
e.anon().post("/login", bad, "Origin", "http://attacker.example").mustStatus(403)
})
t.Run("login Set-Cookie omits Secure", func(t *testing.T) {
r := e.anon().post("/login", url.Values{"username": {db.AdminUsername}, "password": {adminPass}})
r.mustRedirect("/")
c := r.Header.Get("Set-Cookie")
if !strings.Contains(c, "session=") || strings.Contains(c, "Secure") {
t.Errorf("Set-Cookie = %q", c)
}
})
}
func TestCSRFHTTPSMode(t *testing.T) {
// The client still talks plain HTTP to localhost. The app trusts BASE_URL,
// not the transport of the proxy hop.
e := newEnv(t, "BASE_URL", "https://forge.test")
bad := url.Values{"username": {"admin"}, "password": {"wrong"}}
t.Run("POST with no Origin is allowed", func(t *testing.T) {
if r := e.anon().post("/login", bad); r.Code == 403 {
t.Error("rejected")
}
})
t.Run("POST with matching public Origin is allowed", func(t *testing.T) {
if r := e.anon().post("/login", bad, "Origin", "https://forge.test"); r.Code == 403 {
t.Error("rejected")
}
})
t.Run("Origin matching only Host is rejected", func(t *testing.T) {
e.anon().post("/login", bad, "Origin", e.Base).mustStatus(403)
})
t.Run("attacker Origin is rejected", func(t *testing.T) {
e.anon().post("/login", bad, "Origin", "https://attacker.example").mustStatus(403)
})
t.Run("login Set-Cookie includes Secure", func(t *testing.T) {
r := e.anon().post("/login", url.Values{"username": {db.AdminUsername}, "password": {adminPass}},
"Origin", "https://forge.test")
r.mustRedirect("/")
c := r.Header.Get("Set-Cookie")
if !strings.Contains(c, "session=") || !strings.Contains(c, "Secure") {
t.Errorf("Set-Cookie = %q", c)
}
})
t.Run("responses include HSTS", func(t *testing.T) {
if v := e.anon().get("/health").Header.Get("Strict-Transport-Security"); v != "max-age=31536000; includeSubDomains" {
t.Errorf("HSTS = %q", v)
}
})
}
Ainternal/web/e2e/browser_test.go
@@ -0,0 +1,266 @@
package e2e
import (
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
// The browser suite covers what needs JavaScript or a real layout engine:
// auto-submitting selects, details popups, the theme script, and the
// responsive layout. Everything else runs without a browser.
// chromePath finds a Chromium binary: HEARTHFORGE_BROWSER, then PATH, then
// a Playwright download. It returns "" when none is installed.
func chromePath() string {
if p := os.Getenv("HEARTHFORGE_BROWSER"); p != "" {
return p
}
if p, ok := launcher.LookPath(); ok {
return p
}
home, _ := os.UserHomeDir()
matches, _ := filepath.Glob(filepath.Join(home, ".cache", "ms-playwright", "chromium-*", "chrome-linux*", "chrome"))
sort.Strings(matches)
if len(matches) > 0 {
return matches[len(matches)-1]
}
return ""
}
// browser launches headless Chromium for one test.
func browser(t *testing.T) *rod.Browser {
t.Helper()
bin := chromePath()
if bin == "" {
t.Skip("no Chromium found; set HEARTHFORGE_BROWSER")
}
l := launcher.New().Bin(bin).Headless(true).NoSandbox(true)
u, err := l.Launch()
if err != nil {
t.Skipf("launch %s: %v", bin, err)
}
b := rod.New().ControlURL(u)
if err := b.Connect(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
b.MustClose()
l.Cleanup()
})
return b
}
// pageAs opens a page that carries the session's cookies, so the browser is
// signed in as that user without going through the login form.
func pageAs(t *testing.T, b *rod.Browser, s *session, width int) *rod.Page {
t.Helper()
p := b.MustIncognito().MustPage()
p.MustSetViewport(width, 900, 1, false)
if c := s.cookie("session"); c != nil {
p.MustSetCookies(&proto.NetworkCookieParam{Name: c.Name, Value: c.Value, URL: s.env.Base})
}
for _, name := range []string{"repo_sort", "theme"} {
if c := s.cookie(name); c != nil {
p.MustSetCookies(&proto.NetworkCookieParam{Name: c.Name, Value: c.Value, URL: s.env.Base})
}
}
return p
}
func goTo(p *rod.Page, base, path string) *rod.Page {
p.Timeout(15 * time.Second).MustNavigate(base + path).MustWaitLoad()
return p
}
func pathOf(p *rod.Page) string {
u, _ := url.Parse(p.MustInfo().URL)
return u.Path
}
func TestBrowser(t *testing.T) {
e := newEnv(t)
admin := e.admin()
e.createRepo(admin, "js-repo")
e.seedRepo("js-repo", nil)
admin.post("/js-repo/settings/labels", url.Values{"name": {"bug"}, "color": {"#d73a4a"}})
admin.post("/js-repo/settings/labels", url.Values{"name": {"docs"}, "color": {"#0075ca"}})
issue := admin.post("/js-repo/issues", url.Values{
"title": {"Picker issue"}, "body": {"Body **bold**."},
}).mustRedirect("/js-repo/issues/")
admin.post(issue+"/comments", url.Values{"body": {"A comment."}})
b := browser(t)
t.Run("sort select auto-submits and sets the cookie", func(t *testing.T) {
s := e.admin()
p := pageAs(t, b, s, 1280)
goTo(p, e.Base, "/")
wait := p.MustWaitNavigation()
p.MustElement(".repo-sort-select").MustSelect("Name")
wait()
p.MustWaitLoad()
if got := p.MustElement(".repo-sort-select").MustProperty("value").Str(); got != "name" {
t.Errorf("sort value after auto-submit = %q", got)
}
cookies := p.MustCookies()
found := false
for _, c := range cookies {
if c.Name == "repo_sort" && c.Value == "name" {
found = true
}
}
if !found {
t.Error("repo_sort cookie not set to name")
}
})
t.Run("Go button is a noscript fallback", func(t *testing.T) {
// Rod drives the page with injected JS, so it cannot run with scripts
// off. The no-JS path is covered by TestSorting posting /sort
// directly. Here: the fallback is in the HTML, and hidden with JS.
if !e.admin().get("/").Has(`form[action="/sort"] noscript`) {
t.Fatal("noscript Go button missing from the HTML")
}
p := pageAs(t, b, e.admin(), 1280)
goTo(p, e.Base, "/")
if p.MustHas(`form[action="/sort"] button[type=submit]`) {
t.Error("noscript Go button rendered with JS enabled")
}
})
t.Run("theme cookie applies data-theme before paint", func(t *testing.T) {
s := e.admin()
s.post("/settings/theme", url.Values{"theme": {"dark"}})
p := pageAs(t, b, s, 1280)
goTo(p, e.Base, "/")
if got := p.MustEval(`() => document.documentElement.getAttribute("data-theme")`).Str(); got != "dark" {
t.Errorf("data-theme = %q", got)
}
})
t.Run("reaction picker opens and posts a reaction", func(t *testing.T) {
p := pageAs(t, b, e.admin(), 1280)
goTo(p, e.Base, issue)
p.MustElement(".reaction-picker > summary").MustClick()
btn := p.MustElement(".reaction-picker-dropdown .reaction-picker-btn")
if !btn.MustVisible() {
t.Fatal("picker button not visible after opening")
}
wait := p.MustWaitNavigation()
btn.MustClick()
wait()
p.MustWaitLoad()
if !p.MustHas(".reaction-btn") {
t.Error("no reaction shown after clicking the picker")
}
})
t.Run("label filter popup applies the filter", func(t *testing.T) {
p := pageAs(t, b, e.admin(), 1280)
goTo(p, e.Base, "/js-repo/issues")
p.MustElement("details.label-filter > summary").MustClick()
p.MustElement(`.label-filter-popup input[type=checkbox]`).MustClick()
wait := p.MustWaitNavigation()
p.MustElement(`.label-filter-popup button[type=submit]`).MustClick()
wait()
p.MustWaitLoad()
u, _ := url.Parse(p.MustInfo().URL)
if u.Query().Get("labels") == "" {
t.Errorf("filter did not add labels param: %s", u)
}
})
t.Run("create-tag checkbox reveals the tag fields", func(t *testing.T) {
p := pageAs(t, b, e.admin(), 1280)
goTo(p, e.Base, "/js-repo/releases/new")
group := p.MustElement("#tag-fields-group")
if group.MustVisible() {
t.Fatal("tag fields visible before the checkbox is ticked")
}
p.MustElement("#create_tag").MustClick()
if !group.MustVisible() {
t.Error("tag fields hidden after the checkbox is ticked")
}
})
t.Run("login form works in a browser", func(t *testing.T) {
p := b.MustIncognito().MustPage()
goTo(p, e.Base, "/login")
p.MustElement("[name=username]").MustInput("admin")
p.MustElement("[name=password]").MustInput(adminPass)
wait := p.MustWaitNavigation()
p.MustElement("button[type=submit]").MustClick()
wait()
p.MustWaitLoad()
if pathOf(p) != "/" || !p.MustHas(".nav-user") {
t.Errorf("after login: path %s, nav-user %v", pathOf(p), p.MustHas(".nav-user"))
}
})
t.Run("no horizontal overflow at 360 and 1280", func(t *testing.T) {
sha := e.headCommit("js-repo")
pages := []string{
"/", "/js-repo", "/js-repo/issues", issue, "/js-repo/commits/main",
"/js-repo/commit/" + sha, "/js-repo/blob/main/index.js", "/js-repo/settings",
"/settings", "/login",
}
// 360, not 320: a classic 15px scrollbar plus the 320px body floor
// would always scroll at exactly 320.
for _, width := range []int{360, 1280} {
p := pageAs(t, b, e.admin(), width)
for _, path := range pages {
goTo(p, e.Base, path)
sw := p.MustEval(`() => document.documentElement.scrollWidth`).Int()
cw := p.MustEval(`() => document.documentElement.clientWidth`).Int()
if sw > cw {
t.Errorf("%s at %dpx: scrollWidth %d > clientWidth %d", path, width, sw, cw)
}
}
}
})
t.Run("raw svg document cannot read cookies", func(t *testing.T) {
p := pageAs(t, b, e.admin(), 1280)
goTo(p, e.Base, "/js-repo/raw/main/logo.svg")
res := p.MustEval(`() => { try { return "ok:" + document.cookie } catch (e) { return "err:" + e.name } }`).Str()
if !strings.HasPrefix(res, "err:") {
t.Errorf("raw svg could read document.cookie: %s", res)
}
})
}
// TestBrowserConsoleClean loads the main pages and fails on any console
// error, which catches broken asset paths and script errors.
func TestBrowserConsoleClean(t *testing.T) {
e := newEnv(t)
admin := e.admin()
e.createRepo(admin, "console-repo")
e.seedRepo("console-repo", nil)
b := browser(t)
p := pageAs(t, b, e.admin(), 1280)
var errs []string
go p.EachEvent(func(ev *proto.RuntimeExceptionThrown) {
errs = append(errs, ev.ExceptionDetails.Text)
}, func(ev *proto.LogEntryAdded) {
if ev.Entry.Level == proto.LogLogEntryLevelError {
errs = append(errs, fmt.Sprintf("%s %s", ev.Entry.Source, ev.Entry.Text))
}
})()
for _, path := range []string{"/", "/console-repo", "/console-repo/issues", "/settings", "/login", "/register"} {
goTo(p, e.Base, path)
}
time.Sleep(200 * time.Millisecond)
if len(errs) > 0 {
t.Errorf("console errors:\n%s", strings.Join(errs, "\n"))
}
}
Ainternal/web/e2e/ci_mock_test.go
@@ -0,0 +1,415 @@
package e2e
import (
"archive/tar"
"bytes"
"encoding/binary"
"encoding/json"
"io"
"net"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
)
// execResp is one programmed answer for a container exec.
type execResp struct {
output string
exitCode int
// delay holds the response open, so a step timeout can fire.
delay time.Duration
}
// ciUpload is one recorded PUT /containers/*/archive.
type ciUpload struct {
path string
body []byte
}
// ciVolume is one recorded POST /volumes/create.
type ciVolume struct {
name string
labels map[string]string
}
// ciVolumeUsage is what GET /system/df reports for a volume.
type ciVolumeUsage struct {
Size int64
RefCount int
}
// mockDocker is a fake Docker Engine API on a unix socket. It records what
// the CI runner did and lets a test program the exec results.
type mockDocker struct {
mu sync.Mutex
sock string
uploads []ciUpload
pulls []string
volumesCreated []ciVolume
volumesDeleted []string
// volumesOnHost is what GET /volumes reports.
volumesOnHost []string
// volumeUsage is what GET /system/df reports, by volume name.
volumeUsage map[string]ciVolumeUsage
// lastCreateBody is the body of the last POST /containers/create.
lastCreateBody map[string]any
// execMap holds the response of an exec id, assigned at creation.
execMap map[string]execResp
// execQueue is consumed in order as execs are created.
execQueue []execResp
// execCmds is every command run inside a container, in order.
execCmds [][]string
execCounter int
}
var (
reContainerStart = regexp.MustCompile(`/containers/[^/]+/start$`)
reContainerExec = regexp.MustCompile(`/containers/[^/]+/exec$`)
reExecStart = regexp.MustCompile(`/exec/([^/]+)/start$`)
reExecJSON = regexp.MustCompile(`/exec/([^/]+)/json$`)
reContainerArchive = regexp.MustCompile(`/containers/[^/]+/archive`)
)
// newMockDocker starts the mock on a unix socket. The socket lives in a short
// temp path: a unix socket path is limited to about 104 bytes.
func newMockDocker(t *testing.T) *mockDocker {
t.Helper()
dir, err := os.MkdirTemp("", "hf")
if err != nil {
t.Fatal(err)
}
m := &mockDocker{sock: filepath.Join(dir, "d.sock")}
m.reset()
ln, err := net.Listen("unix", m.sock)
if err != nil {
t.Fatal(err)
}
srv := &http.Server{Handler: m, ReadHeaderTimeout: 10 * time.Second}
go srv.Serve(ln)
t.Cleanup(func() {
srv.Close()
os.RemoveAll(dir)
})
return m
}
// queueExec programs the next exec that the runner creates.
func (m *mockDocker) queueExec(r execResp) {
m.mu.Lock()
defer m.mu.Unlock()
m.execQueue = append(m.execQueue, r)
}
// reset clears every recording and programmed response.
func (m *mockDocker) reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.execMap = map[string]execResp{}
m.execQueue = nil
m.execCmds = nil
m.execCounter = 0
m.uploads = nil
m.pulls = nil
m.volumesCreated = nil
m.volumesDeleted = nil
m.volumesOnHost = nil
m.volumeUsage = map[string]ciVolumeUsage{}
m.lastCreateBody = nil
}
func (m *mockDocker) setVolumesOnHost(names ...string) {
m.mu.Lock()
defer m.mu.Unlock()
m.volumesOnHost = names
}
func (m *mockDocker) setVolumeUsage(usage map[string]ciVolumeUsage) {
m.mu.Lock()
defer m.mu.Unlock()
m.volumeUsage = usage
}
func (m *mockDocker) uploadedPaths() []string {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]string, 0, len(m.uploads))
for _, u := range m.uploads {
out = append(out, u.path)
}
return out
}
func (m *mockDocker) uploadsTo(dest string) []ciUpload {
m.mu.Lock()
defer m.mu.Unlock()
var out []ciUpload
for _, u := range m.uploads {
if u.path == dest {
out = append(out, u)
}
}
return out
}
func (m *mockDocker) uploadCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.uploads)
}
func (m *mockDocker) pulledImages() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string(nil), m.pulls...)
}
func (m *mockDocker) createdVolumes() []ciVolume {
m.mu.Lock()
defer m.mu.Unlock()
return append([]ciVolume(nil), m.volumesCreated...)
}
func (m *mockDocker) deletedVolumes() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string(nil), m.volumesDeleted...)
}
func (m *mockDocker) createBody() map[string]any {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastCreateBody
}
func (m *mockDocker) commands() [][]string {
m.mu.Lock()
defer m.mu.Unlock()
return append([][]string(nil), m.execCmds...)
}
// allCommandText joins every command the runner ran, for a substring check.
func (m *mockDocker) allCommandText() string {
var parts []string
for _, c := range m.commands() {
parts = append(parts, strings.Join(c, " "))
}
return strings.Join(parts, " ")
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func (m *mockDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
qs := r.URL.Query()
switch {
// Health check.
case r.Method == http.MethodGet && p == "/v1.47/info":
writeJSON(w, map[string]string{"ServerVersion": "mock"})
// Pull image.
case r.Method == http.MethodPost && strings.HasPrefix(p, "/v1.47/images/create"):
m.mu.Lock()
m.pulls = append(m.pulls, qs.Get("fromImage"))
m.mu.Unlock()
_, _ = w.Write([]byte("{\"status\":\"Pull complete\"}\n"))
// Create container.
case r.Method == http.MethodPost && strings.Contains(p, "/containers/create"):
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
name := qs.Get("name")
if name == "" {
name = "mock-ctr-001"
}
// A copy creates its own source container, so the run's container
// must keep its identity.
if !strings.Contains(name, "-copy-") {
m.mu.Lock()
m.lastCreateBody = body
m.mu.Unlock()
}
writeJSON(w, map[string]string{"Id": name})
// Start container.
case r.Method == http.MethodPost && reContainerStart.MatchString(p):
w.WriteHeader(http.StatusNoContent)
// Create exec: take the next queued response and bind it to this id.
case r.Method == http.MethodPost && reContainerExec.MatchString(p):
var body struct{ Cmd []string }
_ = json.NewDecoder(r.Body).Decode(&body)
m.mu.Lock()
m.execCounter++
id := "mock-exec-" + strconv.Itoa(m.execCounter)
m.execCmds = append(m.execCmds, body.Cmd)
resp := execResp{}
if len(m.execQueue) > 0 {
resp, m.execQueue = m.execQueue[0], m.execQueue[1:]
}
m.execMap[id] = resp
m.mu.Unlock()
writeJSON(w, map[string]string{"Id": id})
// Start exec: return the programmed output as a mux stream.
case r.Method == http.MethodPost && reExecStart.MatchString(p):
resp := m.execFor(reExecStart.FindStringSubmatch(p)[1])
if resp.delay > 0 {
select {
case <-time.After(resp.delay):
case <-r.Context().Done():
return
}
}
if resp.output != "" {
_, _ = w.Write(muxFrame(resp.output))
}
// Inspect exec: return the exit code.
case r.Method == http.MethodGet && reExecJSON.MatchString(p):
resp := m.execFor(reExecJSON.FindStringSubmatch(p)[1])
writeJSON(w, map[string]int{"ExitCode": resp.exitCode})
// Archive upload: the checkout and the [[copy]] sources.
case r.Method == http.MethodPut && reContainerArchive.MatchString(p):
body, _ := io.ReadAll(r.Body)
m.mu.Lock()
m.uploads = append(m.uploads, ciUpload{path: qs.Get("path"), body: body})
m.mu.Unlock()
w.WriteHeader(http.StatusOK)
// Archive download: artifact collection and [[copy]].
case r.Method == http.MethodGet && reContainerArchive.MatchString(p):
filePath := qs.Get("path")
if filePath == "" {
filePath = "file.txt"
}
w.Header().Set("Content-Type", "application/x-tar")
_, _ = w.Write(makeTar(path.Base(filePath), "artifact-content-123"))
// Delete container.
case r.Method == http.MethodDelete && strings.Contains(p, "/containers/"):
w.WriteHeader(http.StatusNoContent)
// Volume create, used for cache volumes.
case r.Method == http.MethodPost && p == "/v1.47/volumes/create":
var body struct {
Name string
Labels map[string]string
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Labels == nil {
body.Labels = map[string]string{}
}
m.mu.Lock()
m.volumesCreated = append(m.volumesCreated, ciVolume{name: body.Name, labels: body.Labels})
m.mu.Unlock()
writeJSON(w, map[string]string{"Name": body.Name})
// Disk usage, used by the cache size caps.
case r.Method == http.MethodGet && p == "/v1.47/system/df":
type entry struct {
Name string
UsageData ciVolumeUsage
}
m.mu.Lock()
vols := make([]entry, 0, len(m.volumeUsage))
for name, usage := range m.volumeUsage {
vols = append(vols, entry{Name: name, UsageData: usage})
}
m.mu.Unlock()
writeJSON(w, map[string]any{"Volumes": vols})
// Volume list, used by prune and purge.
case r.Method == http.MethodGet && p == "/v1.47/volumes":
type entry struct{ Name string }
m.mu.Lock()
vols := make([]entry, 0, len(m.volumesOnHost))
for _, name := range m.volumesOnHost {
vols = append(vols, entry{Name: name})
}
m.mu.Unlock()
writeJSON(w, map[string]any{"Volumes": vols})
// Volume delete.
case r.Method == http.MethodDelete && strings.Contains(p, "/volumes/"):
m.mu.Lock()
m.volumesDeleted = append(m.volumesDeleted, path.Base(p))
m.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Not found", http.StatusNotFound)
}
}
func (m *mockDocker) execFor(id string) execResp {
m.mu.Lock()
defer m.mu.Unlock()
return m.execMap[id]
}
// muxFrame builds one Docker multiplexed stream frame.
func muxFrame(text string) []byte {
hdr := make([]byte, 8)
hdr[0] = 1
binary.BigEndian.PutUint32(hdr[4:], uint32(len(text)))
return append(hdr, text...)
}
// makeTar builds a tar archive holding one file.
func makeTar(filename, content string) []byte {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
_ = tw.WriteHeader(&tar.Header{
Name: filename, Mode: 0o644, Size: int64(len(content)), Typeflag: tar.TypeReg,
})
_, _ = tw.Write([]byte(content))
_ = tw.Close()
return buf.Bytes()
}
// tarEntry is one header of an uploaded archive.
type tarEntry struct {
name string
uid int
}
// tarHeaders lists the entries of an uncompressed tar.
func tarHeaders(t *testing.T, data []byte) []tarEntry {
t.Helper()
tr := tar.NewReader(bytes.NewReader(data))
var out []tarEntry
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read tar: %v", err)
}
out = append(out, tarEntry{name: h.Name, uid: h.Uid})
}
return out
}
// tarEntryNames lists the file names of an uncompressed tar.
func tarEntryNames(t *testing.T, data []byte) []string {
t.Helper()
var out []string
for _, h := range tarHeaders(t, data) {
out = append(out, h.name)
}
return out
}
Ainternal/web/e2e/ci_test.go
@@ -0,0 +1,1555 @@
package e2e
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// The CI suite drives the real pipeline runner against a mock Docker Engine
// on a unix socket, so no container engine is needed.
const ciSimpleTOML = `
image = "debian:latest"
[on]
manual = true
push = ["main"]
[[steps]]
name = "hello"
run_sh = "echo hello"
`
const ciArtifactTOML = `
image = "debian:latest"
work_dir = "/ci"
[on]
manual = true
[[steps]]
name = "build"
run_sh = "echo building"
publish_file = ["/ci/output.txt"]
`
// ciEnv starts a server wired to a fresh mock engine and seeds "ci-repo".
func ciEnv(t *testing.T) (*env, *mockDocker, *session) {
t.Helper()
m := newMockDocker(t)
e := newEnv(t, "CI_DOCKER_SOCKET", m.sock)
admin := e.admin()
e.createRepo(admin, "ci-repo")
e.seedRepo("ci-repo", nil)
return e, m, admin
}
// ciSeedToml pushes a .hearthforge-ci.toml into ci-repo and returns the
// commit sha. Re-seeding the same content is a no-op commit, so a test can
// put its config back without failing.
func ciSeedToml(e *env, toml string) string {
t := e.t
t.Helper()
work := t.TempDir()
gitRun(t, work, "clone", "-q", e.repoPath("ci-repo"), ".")
if err := os.WriteFile(filepath.Join(work, ".hearthforge-ci.toml"), []byte(toml), 0o644); err != nil {
t.Fatal(err)
}
gitRun(t, work, "add", ".hearthforge-ci.toml")
// Nothing to commit when the config is unchanged.
_, _ = gitTry(work, "commit", "-q", "-m", "Add CI config")
gitRun(t, work, "push", "-q", "origin", "HEAD:main")
e.Srv.Git.InvalidateRefCache("ci-repo")
return gitRun(t, work, "rev-parse", "HEAD")
}
// ciTrigger posts the manual trigger and returns the new run id. The route
// always builds HEAD of the default branch, so the expected sha is checked
// and a stale fixture fails loudly.
func ciTrigger(e *env, admin *session, sha string, overrides url.Values) int64 {
t := e.t
t.Helper()
if head := e.headCommit("ci-repo"); head != sha {
t.Fatalf("ciTrigger: expected HEAD %s, repo HEAD is %s", sha, head)
}
if overrides == nil {
overrides = url.Values{}
}
loc := admin.post("/ci-repo/ci/run", overrides).mustRedirect("/ci-repo/ci/")
id, err := strconv.ParseInt(idFromPath(t, loc), 10, 64)
if err != nil {
t.Fatalf("run id in %q: %v", loc, err)
}
return id
}
// ciLatestRunID is the highest run id in the database, or 0.
func ciLatestRunID(e *env) int64 {
var id sql.NullInt64
if err := e.DB.QueryRowContext(context.Background(),
`SELECT MAX(id) FROM ci_runs`).Scan(&id); err != nil {
e.t.Fatal(err)
}
return id.Int64
}
// ciPushRun pushes a commit to a branch over HTTP and returns the run the
// push trigger created. The manual route always builds the default branch.
func ciPushRun(e *env, sha, branch string) int64 {
t := e.t
t.Helper()
before := ciLatestRunID(e)
gitRun(t, e.repoPath("ci-repo"), "push", "--force", e.authURL("ci-repo"),
sha+":refs/heads/"+branch)
e.Srv.Git.InvalidateRefCache("ci-repo")
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if id := ciLatestRunID(e); id > before {
return id
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("push to %s did not create a run", branch)
return 0
}
// ciWaitForRun polls until the run leaves pending/running/queued.
func ciWaitForRun(e *env, runID int64, timeout ...time.Duration) string {
t := e.t
t.Helper()
limit := 15 * time.Second
if len(timeout) > 0 {
limit = timeout[0]
}
deadline := time.Now().Add(limit)
for time.Now().Before(deadline) {
status := ciRunStatus(e, runID)
if status != "" && status != "pending" && status != "running" && status != "queued" {
return status
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("run %d did not complete within %s", runID, limit)
return ""
}
func ciRunStatus(e *env, runID int64) string {
var status string
err := e.DB.QueryRowContext(context.Background(),
`SELECT status FROM ci_runs WHERE id = ?`, runID).Scan(&status)
if err != nil {
e.t.Fatalf("run %d: %v", runID, err)
}
return status
}
// ciStepRow is one row of ci_steps.
type ciStepRow struct {
Status string
Log string
}
// ciSteps returns every step of a run with that name, in insertion order.
func ciSteps(e *env, runID int64, name string) []ciStepRow {
rows, err := e.DB.QueryContext(context.Background(),
`SELECT status, log FROM ci_steps WHERE run_id = ? AND name = ? ORDER BY id ASC`,
runID, name)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var out []ciStepRow
for rows.Next() {
var s ciStepRow
if err := rows.Scan(&s.Status, &s.Log); err != nil {
e.t.Fatal(err)
}
out = append(out, s)
}
return out
}
// ciStep returns the first step of a run with that name.
func ciStep(e *env, runID int64, name string) ciStepRow {
steps := ciSteps(e, runID, name)
if len(steps) == 0 {
e.t.Fatalf("run %d has no step %q", runID, name)
}
return steps[0]
}
// ciOverrides decodes the stored variable overrides of a run.
func ciOverrides(e *env, runID int64) map[string]string {
var raw sql.NullString
if err := e.DB.QueryRowContext(context.Background(),
`SELECT variable_overrides FROM ci_runs WHERE id = ?`, runID).Scan(&raw); err != nil {
e.t.Fatal(err)
}
out := map[string]string{}
if raw.String != "" {
if err := json.Unmarshal([]byte(raw.String), &out); err != nil {
e.t.Fatalf("overrides %q: %v", raw.String, err)
}
}
return out
}
func ciRunPath(runID int64) string { return "/ci-repo/ci/" + strconv.FormatInt(runID, 10) }
// eqStrings compares two string lists.
func eqStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// ── pipelines tab ────────────────────────────────────────────────────────
func TestCIPipelinesTab(t *testing.T) {
e, m, admin := ciEnv(t)
t.Run("tab is visible in repo nav", func(t *testing.T) {
r := admin.get("/ci-repo").mustStatus(200)
if !contains(r.Texts(".repo-tab"), "Pipelines") {
t.Errorf("repo tabs = %v", r.Texts(".repo-tab"))
}
})
t.Run("history page shows empty state when no runs", func(t *testing.T) {
r := admin.get("/ci-repo/ci").mustStatus(200)
if !r.Has(".empty-state") {
t.Fatal("empty state missing")
}
if !strings.Contains(r.Text(".empty-state"), "No pipeline runs yet") {
t.Errorf("empty state = %q", r.Text(".empty-state"))
}
})
t.Run("the run form posts and overrides a declared variable", func(t *testing.T) {
// Regression: an input-less form posted an empty body and the route
// crashed whenever the config declared a variable.
sha := ciSeedToml(e, `
image = "debian:latest"
[on]
manual = true
[variables]
[variables.GREETING]
default = "hello"
description = "What to echo"
[[steps]]
name = "say"
run_sh = "echo $GREETING"
`)
m.reset()
m.queueExec(execResp{output: "hi\n"})
r := admin.get("/ci-repo/ci").mustStatus(200)
if got := r.Value(`input[name="var_GREETING"]`); got != "hello" {
t.Fatalf("var_GREETING default = %q", got)
}
runID := ciTrigger(e, admin, sha, url.Values{"var_GREETING": {"goodbye"}})
ciWaitForRun(e, runID)
got := ciOverrides(e, runID)
if len(got) != 1 || got["GREETING"] != "goodbye" {
t.Errorf("overrides = %v", got)
}
})
t.Run("an untouched variable field is not recorded as an override", func(t *testing.T) {
sha := ciSeedToml(e, `
image = "debian:latest"
[on]
manual = true
[variables]
[variables.GREETING]
default = "hello"
[[steps]]
name = "say"
run_sh = "echo $GREETING"
`)
m.reset()
m.queueExec(execResp{output: "hi\n"})
runID := ciTrigger(e, admin, sha, url.Values{"var_GREETING": {"hello"}})
ciWaitForRun(e, runID)
if got := ciOverrides(e, runID); len(got) != 0 {
t.Errorf("overrides = %v, want none", got)
}
})
t.Run("an empty POST to the run route does not crash", func(t *testing.T) {
sha := ciSeedToml(e, `
image = "debian:latest"
[on]
manual = true
[variables]
[variables.GREETING]
default = "hello"
[[steps]]
name = "say"
run_sh = "echo $GREETING"
`)
_ = sha
m.reset()
m.queueExec(execResp{output: "hi\n"})
r := admin.post("/ci-repo/ci/run", url.Values{})
if r.Code != http.StatusFound {
t.Fatalf("status = %d, body %s", r.Code, r.BodyString())
}
id, err := strconv.ParseInt(idFromPath(t, r.Location()), 10, 64)
if err != nil {
t.Fatal(err)
}
ciWaitForRun(e, id)
})
t.Run("help section is collapsible and contains template download", func(t *testing.T) {
r := admin.get("/ci-repo/ci").mustStatus(200)
if !r.Has("details.ci-help") {
t.Error("help section missing")
}
if !r.Has(`a[download=".hearthforge-ci.toml"]`) {
t.Error("template download link missing")
}
})
}
// ── successful run ───────────────────────────────────────────────────────
func TestCISuccessfulRun(t *testing.T) {
e, m, admin := ciEnv(t)
m.queueExec(execResp{output: "hello from mock CI\n"})
sha := ciSeedToml(e, ciSimpleTOML)
runID := ciTrigger(e, admin, sha, nil)
ciWaitForRun(e, runID)
t.Run("run status is success", func(t *testing.T) {
if got := ciRunStatus(e, runID); got != "success" {
t.Errorf("status = %q", got)
}
})
t.Run("step status is success and log is captured", func(t *testing.T) {
step := ciStep(e, runID, "hello")
if step.Status != "success" {
t.Errorf("step status = %q", step.Status)
}
if !strings.Contains(step.Log, "hello from mock CI") {
t.Errorf("step log = %q", step.Log)
}
})
t.Run("history page shows the completed run", func(t *testing.T) {
r := admin.get("/ci-repo/ci").mustStatus(200)
if r.Count(".ci-status-pill.ci-status-success") == 0 {
t.Error("no success pill on the history page")
}
})
t.Run("run detail page shows step and log", func(t *testing.T) {
r := admin.get(ciRunPath(runID)).mustStatus(200)
steps := r.Texts(".ci-step")
if len(steps) < 2 {
t.Fatalf("steps = %v", steps)
}
// Setup is a real step and sorts before the config's steps.
if !strings.Contains(steps[0], "pipeline setup") || !strings.Contains(steps[0], "success") {
t.Errorf("first step = %q", steps[0])
}
if !strings.Contains(steps[1], "hello") {
t.Errorf("second step = %q", steps[1])
}
if !contains(r.Texts(".ci-step-log"), "hello from mock CI") {
t.Errorf("logs = %v", r.Texts(".ci-step-log"))
}
})
t.Run("retry re-executes the same run in-place", func(t *testing.T) {
r := admin.post(ciRunPath(runID)+"/retry", url.Values{})
if got := r.mustRedirect(ciRunPath(runID)); got != ciRunPath(runID) {
t.Errorf("redirect = %q", got)
}
if got := ciWaitForRun(e, runID); got != "success" {
t.Errorf("status after retry = %q", got)
}
// No new run was created: the row still exists under the same id.
if ciRunStatus(e, runID) != "success" {
t.Error("run row changed")
}
})
}
// ── failing run ──────────────────────────────────────────────────────────
func TestCIFailingRun(t *testing.T) {
e, m, admin := ciEnv(t)
m.queueExec(execResp{output: "build error: file not found\n", exitCode: 1})
sha := ciSeedToml(e, ciSimpleTOML)
runID := ciTrigger(e, admin, sha, nil)
ciWaitForRun(e, runID)
t.Run("run status is failure", func(t *testing.T) {
if got := ciRunStatus(e, runID); got != "failure" {
t.Errorf("status = %q", got)
}
})
t.Run("step status is failure and error log captured", func(t *testing.T) {
step := ciStep(e, runID, "hello")
if step.Status != "failure" {
t.Errorf("step status = %q", step.Status)
}
if !strings.Contains(step.Log, "build error") {
t.Errorf("step log = %q", step.Log)
}
})
t.Run("run detail page shows failure status", func(t *testing.T) {
r := admin.get(ciRunPath(runID)).mustStatus(200)
if r.Count(".ci-status-pill.ci-status-failure") == 0 {
t.Error("no failure pill on the run page")
}
})
}
// ── cancel ───────────────────────────────────────────────────────────────
func TestCICancel(t *testing.T) {
e, _, admin := ciEnv(t)
t.Run("cancelling a pending run marks it cancelled", func(t *testing.T) {
sha := ciSeedToml(e, ciSimpleTOML)
runID := ciTrigger(e, admin, sha, nil)
admin.post(ciRunPath(runID)+"/cancel", url.Values{}).mustRedirect(ciRunPath(runID))
status := ciWaitForRun(e, runID)
switch status {
case "cancelled", "success", "failure":
default:
t.Fatalf("status = %q", status)
}
})
}
// ── artifacts ────────────────────────────────────────────────────────────
func TestCIArtifacts(t *testing.T) {
e, _, admin := ciEnv(t)
sha := ciSeedToml(e, ciArtifactTOML)
runID := ciTrigger(e, admin, sha, nil)
ciWaitForRun(e, runID)
var artifactID int64
var filename string
var count int
rows, err := e.DB.QueryContext(context.Background(),
`SELECT id, filename FROM ci_artifacts WHERE run_id = ? ORDER BY id`, runID)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
if err := rows.Scan(&artifactID, &filename); err != nil {
t.Fatal(err)
}
count++
}
rows.Close()
t.Run("artifact row created in DB", func(t *testing.T) {
if count != 1 {
t.Fatalf("artifacts = %d", count)
}
if filename != "output.txt" {
t.Errorf("filename = %q", filename)
}
})
t.Run("artifact is downloadable via HTTP", func(t *testing.T) {
if artifactID <= 0 {
t.Fatal("no artifact id")
}
r := e.anon().get(ciRunPath(runID) + "/artifacts/" + strconv.FormatInt(artifactID, 10))
r.mustStatus(200)
if r.BodyString() != "artifact-content-123" {
t.Errorf("body = %q", r.BodyString())
}
})
t.Run("run detail page shows artifact list", func(t *testing.T) {
r := admin.get(ciRunPath(runID)).mustStatus(200)
if r.Count(".ci-artifact-item") == 0 {
t.Fatal("no artifact items")
}
if !strings.Contains(r.Text(".ci-artifact-name"), "output.txt") {
t.Errorf("artifact name = %q", r.Text(".ci-artifact-name"))
}
})
}
// ── badge ────────────────────────────────────────────────────────────────
func TestCIBadge(t *testing.T) {
e, m, admin := ciEnv(t)
m.queueExec(execResp{output: "ok\n"})
sha := ciSeedToml(e, ciSimpleTOML)
runID := ciTrigger(e, admin, sha, nil)
ciWaitForRun(e, runID)
t.Run("badge SVG returns success status after successful run", func(t *testing.T) {
r := e.anon().get("/ci-repo/ci/badge.svg").mustStatus(200)
if !strings.Contains(r.Header.Get("Content-Type"), "image/svg+xml") {
t.Errorf("content type = %q", r.Header.Get("Content-Type"))
}
if !r.Contains("<svg") || !r.Contains("success") {
t.Errorf("badge = %q", r.BodyString())
}
})
t.Run("badge returns 404 for private repo when not logged in", func(t *testing.T) {
e.createRepo(admin, "private-ci-repo", "is_private", "1")
e.anon().get("/private-ci-repo/ci/badge.svg").mustStatus(404)
})
}
// ── secrets ──────────────────────────────────────────────────────────────
func TestCISecrets(t *testing.T) {
e, m, admin := ciEnv(t)
t.Run("can add, list, and delete a secret via settings", func(t *testing.T) {
admin.post("/ci-repo/settings/ci-secrets", url.Values{
"name": {"MY_SECRET"}, "value": {"super-secret-value"},
"description": {"A test secret"},
}).mustRedirect("/ci-repo/settings")
r := admin.get("/ci-repo/settings").mustStatus(200)
if n := len(matchingTexts(r.Texts("code"), "MY_SECRET")); n != 1 {
t.Fatalf("MY_SECRET shown %d times", n)
}
if !r.Contains("●●●●●●") {
t.Error("secret value is not masked")
}
id := r.Value(`form[action="/ci-repo/settings/ci-secrets/delete"] input[name=id]`)
if id == "" {
t.Fatal("no delete form for the secret")
}
admin.post("/ci-repo/settings/ci-secrets/delete", url.Values{"id": {id}}).
mustRedirect("/ci-repo/settings")
r = admin.get("/ci-repo/settings").mustStatus(200)
if n := len(matchingTexts(r.Texts("code"), "MY_SECRET")); n != 0 {
t.Errorf("MY_SECRET still shown %d times", n)
}
})
t.Run("secret value is masked in step logs", func(t *testing.T) {
admin.post("/ci-repo/settings/ci-secrets", url.Values{
"name": {"MASK_ME"}, "value": {"s3cr3t-p4ssw0rd"},
}).mustRedirect("/ci-repo/settings")
m.reset()
m.queueExec(execResp{output: "s3cr3t-p4ssw0rd is the value\n"})
sha := ciSeedToml(e, ciSimpleTOML)
runID := ciTrigger(e, admin, sha, nil)
ciWaitForRun(e, runID)
step := ciStep(e, runID, "hello")
if strings.Contains(step.Log, "s3cr3t-p4ssw0rd") {
t.Errorf("secret leaked into the log: %q", step.Log)
}
if !strings.Contains(step.Log, "[MASKED]") {
t.Errorf("log = %q", step.Log)
}
})
}
// matchingTexts keeps the entries containing sub.
func matchingTexts(list []string, sub string) []string {
var out []string
for _, s := range list {
if strings.Contains(s, sub) {
out = append(out, s)
}
}
return out
}
// ── per-repo run IDs ─────────────────────────────────────────────────────
func TestCIPerRepoRunIDs(t *testing.T) {
e, m, admin := ciEnv(t)
sha := ciSeedToml(e, ciSimpleTOML)
for range 2 {
m.reset()
ciWaitForRun(e, ciTrigger(e, admin, sha, nil))
}
t.Run("repo_run_id is set and increments per repo", func(t *testing.T) {
rows, err := e.DB.QueryContext(context.Background(),
`SELECT repo_run_id FROM ci_runs ORDER BY repo_run_id ASC`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
i := 0
for rows.Next() {
var id sql.NullInt64
if err := rows.Scan(&id); err != nil {
t.Fatal(err)
}
if !id.Valid || id.Int64 <= 0 {
t.Fatal("repo_run_id is not set")
}
i++
if id.Int64 != int64(i) {
t.Fatalf("repo_run_id %d at position %d", id.Int64, i)
}
}
if i == 0 {
t.Fatal("no runs")
}
})
t.Run("run detail page shows repo-local run number", func(t *testing.T) {
var runID, repoRunID int64
if err := e.DB.QueryRowContext(context.Background(),
`SELECT id, repo_run_id FROM ci_runs ORDER BY id ASC LIMIT 1`).
Scan(&runID, &repoRunID); err != nil {
t.Fatal(err)
}
r := admin.get(ciRunPath(runID)).mustStatus(200)
want := "#" + strconv.FormatInt(repoRunID, 10)
if !strings.Contains(r.Text("h2"), want) {
t.Errorf("heading = %q, want %q", r.Text("h2"), want)
}
})
}
// ── skip reasons ─────────────────────────────────────────────────────────
const ciSkipIfTOML = `
image = "debian:latest"
[on]
manual = true
[[steps]]
name = "first"
run_sh = "echo first"
[[steps]]
name = "second"
run_if = "false"
run_sh = "echo second"
[[steps]]
name = "third"
run_sh = "echo third"
`
func TestCISkipReasons(t *testing.T) {
e, m, admin := ciEnv(t)
t.Run("run_if failure sets skip reason in log", func(t *testing.T) {
sha := ciSeedToml(e, ciSkipIfTOML)
m.reset()
m.queueExec(execResp{output: "first\n"}) // first step
m.queueExec(execResp{exitCode: 1}) // run_if check of second
m.queueExec(execResp{output: "third\n"}) // third step
runID := ciTrigger(e, admin, sha, nil)
ciWaitForRun(e, runID)
step := ciStep(e, runID, "second")
if step.Status != "skipped" {
t.Errorf("status = %q", step.Status)
}
if !strings.Contains(step.Log, "condition not met") {
t.Errorf("log = %q", step.Log)
}
})
t.Run("failed step causes remaining steps to be skipped with reason", func(t *testing.T) {
sha := ciSeedToml(e, ciSkipIfTOML)
m.reset()
m.queueExec(execResp{output: "boom\n", exitCode: 1}) // first step fails
runID := ciTrigger(e, admin, sha, nil)
ciWaitForRun(e, runID)
step := ciStep(e, runID, "third")
if step.Status != "skipped" {
t.Errorf("status = %q", step.Status)
}
if !strings.Contains(step.Log, "previous step failed") {
t.Errorf("log = %q", step.Log)
}
})
}
// ── docker unavailable ───────────────────────────────────────────────────
func TestCIDockerUnavailable(t *testing.T) {
// newEnv points CI_DOCKER_SOCKET at a path that does not exist.
e := newEnv(t)
admin := e.admin()
e.createRepo(admin, "ci-repo")
e.seedRepo("ci-repo", nil)
t.Run("run is marked skipped when docker socket is missing", func(t *testing.T) {
sha := ciSeedToml(e, ciSimpleTOML)
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "skipped" {
t.Errorf("status = %q", got)
}
})
}
// ── manual trigger without on.manual ─────────────────────────────────────
const ciNoManualTOML = `
image = "debian:latest"
[on]
push = ["main"]
[[steps]]
name = "hello"
run_sh = "echo hi"
`
func TestCIManualTriggerWithoutOnManual(t *testing.T) {
e, m, admin := ciEnv(t)
t.Run("manual run is allowed even without manual = true in config", func(t *testing.T) {
sha := ciSeedToml(e, ciNoManualTOML)
m.reset()
m.queueExec(execResp{output: "hi\n"})
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "success" {
t.Errorf("status = %q", got)
}
})
t.Run("Run pipeline button is not disabled when toml lacks manual = true", func(t *testing.T) {
ciSeedToml(e, ciNoManualTOML)
r := admin.get("/ci-repo/ci").mustStatus(200)
if !contains(r.Texts("button"), "Run pipeline") {
t.Fatalf("buttons = %v", r.Texts("button"))
}
if r.Has("button[disabled]") {
t.Error("the Run pipeline button is disabled")
}
})
}
// ── auto-refresh toggle ──────────────────────────────────────────────────
func TestCIAutoRefreshToggle(t *testing.T) {
e, _, admin := ciEnv(t)
t.Run("Pause refresh button appears on active run and ?refresh=off shows Resume", func(t *testing.T) {
sha := ciSeedToml(e, ciSimpleTOML)
runID := ciTrigger(e, admin, sha, nil)
// The run may already have finished, so only the refresh link's
// existence is checked, exactly as the browser test did.
admin.get(ciRunPath(runID)).mustStatus(200)
r := admin.get(ciRunPath(runID) + "?refresh=off").mustStatus(200)
if n := r.Count(`meta[http-equiv="refresh"]`); n != 0 {
t.Errorf("meta refresh count = %d", n)
}
ciWaitForRun(e, runID)
})
}
// ── purge cache ──────────────────────────────────────────────────────────
func TestCIPurgeCache(t *testing.T) {
_, _, admin := ciEnv(t)
t.Run("Purge caches button is visible and submits successfully", func(t *testing.T) {
r := admin.get("/ci-repo/ci").mustStatus(200)
if !contains(r.Texts("button"), "Purge caches") {
t.Fatalf("buttons = %v", r.Texts("button"))
}
redirect := admin.post("/ci-repo/ci/purge-cache", url.Values{})
redirect.mustRedirect("/ci-repo/ci")
r = admin.follow(redirect).mustStatus(200)
if !strings.Contains(r.Text("h2"), "Pipelines") {
t.Errorf("heading = %q", r.Text("h2"))
}
msg := r.Text(".form-success, .form-error")
if !strings.Contains(strings.ToLower(msg), "purge") {
t.Errorf("message = %q", msg)
}
})
}
// ── repo upload ──────────────────────────────────────────────────────────
const ciCloneTOML = `
image = "debian:latest"
work_dir = "/ci/build"
clone_project_to = "/ci/build/project"
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`
func TestCIRepoUpload(t *testing.T) {
e, m, admin := ciEnv(t)
ciSeedToml(e, ciCloneTOML)
// Sibling subtests reseed the config, and the trigger route always builds
// HEAD. Put the clone config back first.
trigger := func() int64 {
sha := ciSeedToml(e, ciCloneTOML)
return ciTrigger(e, admin, sha, nil)
}
t.Run("the checkout is uploaded, not bind-mounted", func(t *testing.T) {
m.reset()
runID := trigger()
if got := ciWaitForRun(e, runID); got != "success" {
t.Fatalf("status = %q", got)
}
if !contains(m.uploadedPaths(), "/ci/build/project") {
t.Fatalf("uploads = %v", m.uploadedPaths())
}
if len(m.uploadsTo("/ci/build/project")[0].body) == 0 {
t.Error("the checkout upload is empty")
}
binds, _ := json.Marshal(m.createBody()["HostConfig"])
if strings.Contains(string(binds), e.DataDir) {
t.Errorf("binds reference the data directory: %s", binds)
}
})
t.Run("the container never runs git", func(t *testing.T) {
m.reset()
runID := trigger()
if got := ciWaitForRun(e, runID); got != "success" {
t.Fatalf("status = %q", got)
}
if strings.Contains(m.allCommandText(), "git") {
t.Errorf("commands = %v", m.commands())
}
})
t.Run("a failing checkout fails the run before any step runs", func(t *testing.T) {
m.reset()
m.queueExec(execResp{}) // mkdir work_dir
m.queueExec(execResp{output: "mkdir: read-only\n", exitCode: 1}) // mkdir dest
runID := trigger()
if got := ciWaitForRun(e, runID); got != "failure" {
t.Fatalf("status = %q", got)
}
if got := ciStep(e, runID, "hello").Status; got != "skipped" {
t.Errorf("hello status = %q", got)
}
setup := ciStep(e, runID, "pipeline setup")
if setup.Status != "failure" {
t.Errorf("setup status = %q", setup.Status)
}
if !strings.Contains(setup.Log, "mkdir: read-only") {
t.Errorf("setup log = %q", setup.Log)
}
})
t.Run("a cache path inside the clone directory is rejected", func(t *testing.T) {
m.reset()
sha := ciSeedToml(e, `
image = "debian:latest"
clone_project_to = "/ci/build/project"
cache = ["/ci/build/project/target"]
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`)
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "failure" {
t.Fatalf("status = %q", got)
}
if n := m.uploadCount(); n != 0 {
t.Errorf("uploads = %d, want 0", n)
}
if log := ciStep(e, runID, "pipeline setup").Log; !strings.Contains(log, "overlaps clone_project_to") {
t.Errorf("setup log = %q", log)
}
})
t.Run("a cache path above the clone directory is rejected", func(t *testing.T) {
m.reset()
sha := ciSeedToml(e, `
image = "debian:latest"
clone_project_to = "/ci/build/project"
cache = ["/ci/build"]
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`)
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "failure" {
t.Fatalf("status = %q", got)
}
if n := m.uploadCount(); n != 0 {
t.Errorf("uploads = %d, want 0", n)
}
})
t.Run("a relative clone_project_to is rejected", func(t *testing.T) {
m.reset()
sha := ciSeedToml(e, `
image = "debian:latest"
work_dir = "/ci/build"
clone_project_to = "project"
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`)
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "failure" {
t.Fatalf("status = %q", got)
}
if log := ciStep(e, runID, "pipeline setup").Log; !strings.Contains(log, "must be an absolute path") {
t.Errorf("setup log = %q", log)
}
})
t.Run("the upload carries the requested commit", func(t *testing.T) {
m.reset()
runID := trigger()
if got := ciWaitForRun(e, runID); got != "success" {
t.Fatalf("status = %q", got)
}
ups := m.uploadsTo("/ci/build/project")
if len(ups) == 0 {
t.Fatal("no upload to the clone directory")
}
// The archive must hold the CI config at the triggered commit, and no
// .git. A dropped commit argument would still produce a valid tar.
names := tarEntryNames(t, ups[0].body)
if !contains(names, ".hearthforge-ci.toml") {
t.Errorf("entries = %v", names)
}
for _, n := range names {
if strings.HasPrefix(n, ".git/") {
t.Errorf("archive contains %q", n)
}
}
// git archive writes uid 0 and no entry for the archive root, so the
// destination keeps the mode the container gave it.
for _, h := range tarHeaders(t, ups[0].body) {
if h.uid != 0 {
t.Errorf("entry %q has uid %d", h.name, h.uid)
}
if h.name == "./" {
t.Error("archive contains a root entry")
}
}
})
}
// ── copy from another image ──────────────────────────────────────────────
const ciCopyTOML = `
image = "debian:latest"
[on]
manual = true
[[copy]]
image = "docker.io/oven/bun:1.4.0-alpine"
from = "/usr/local/bin/bun"
to = "/usr/local/bin"
[[steps]]
name = "hello"
run_sh = "bun --version"
`
func TestCICopyFromAnotherImage(t *testing.T) {
e, m, admin := ciEnv(t)
t.Run("pulls the source image and uploads its files", func(t *testing.T) {
sha := ciSeedToml(e, ciCopyTOML)
m.reset()
m.queueExec(execResp{}) // mkdir of the copy target
m.queueExec(execResp{output: "1.4.0\n"}) // the step
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "success" {
t.Fatalf("status = %q", got)
}
if !contains(m.pulledImages(), "docker.io/oven/bun") {
t.Errorf("pulls = %v", m.pulledImages())
}
if !contains(m.uploadedPaths(), "/usr/local/bin") {
t.Errorf("uploads = %v", m.uploadedPaths())
}
})
}
// ── always and warn_on_fail ──────────────────────────────────────────────
const ciFlagsTOML = `
image = "debian:latest"
[on]
manual = true
[[steps]]
name = "lint"
run_sh = "make lint"
warn_on_fail = true
[[steps]]
name = "build"
run_sh = "make"
[[steps]]
name = "cleanup"
run_sh = "rm -rf /scratch"
always = true
`
func TestCIAlwaysAndWarnOnFail(t *testing.T) {
e, m, admin := ciEnv(t)
run := func(sha string) int64 {
runID := ciTrigger(e, admin, sha, nil)
ciWaitForRun(e, runID)
return runID
}
t.Run("warn_on_fail marks the step and lets the run continue", func(t *testing.T) {
sha := ciSeedToml(e, ciFlagsTOML)
m.reset()
m.queueExec(execResp{output: "style nit\n", exitCode: 1}) // lint
m.queueExec(execResp{output: "built\n"}) // build
m.queueExec(execResp{}) // cleanup
runID := run(sha)
lint := ciStep(e, runID, "lint")
if lint.Status != "warning" {
t.Errorf("lint status = %q", lint.Status)
}
if !strings.Contains(lint.Log, "style nit") {
t.Errorf("lint log = %q", lint.Log)
}
if got := ciStep(e, runID, "build").Status; got != "success" {
t.Errorf("build status = %q", got)
}
if got := ciRunStatus(e, runID); got != "warning" {
t.Errorf("run status = %q", got)
}
})
t.Run("always runs after a failure, other steps stay skipped", func(t *testing.T) {
sha := ciSeedToml(e, ciFlagsTOML)
m.reset()
m.queueExec(execResp{output: "ok\n"}) // lint
m.queueExec(execResp{output: "boom\n", exitCode: 1}) // build fails
m.queueExec(execResp{output: "cleaned\n"}) // cleanup, always
runID := run(sha)
if got := ciStep(e, runID, "build").Status; got != "failure" {
t.Errorf("build status = %q", got)
}
cleanup := ciStep(e, runID, "cleanup")
if cleanup.Status != "success" {
t.Errorf("cleanup status = %q", cleanup.Status)
}
if !strings.Contains(cleanup.Log, "cleaned") {
t.Errorf("cleanup log = %q", cleanup.Log)
}
if got := ciRunStatus(e, runID); got != "failure" {
t.Errorf("run status = %q", got)
}
})
t.Run("a failing always step keeps the run failed", func(t *testing.T) {
sha := ciSeedToml(e, ciFlagsTOML)
m.reset()
m.queueExec(execResp{output: "ok\n"}) // lint
m.queueExec(execResp{output: "boom\n", exitCode: 1}) // build fails
m.queueExec(execResp{output: "no\n", exitCode: 1}) // cleanup also fails
runID := run(sha)
if got := ciStep(e, runID, "cleanup").Status; got != "failure" {
t.Errorf("cleanup status = %q", got)
}
if got := ciRunStatus(e, runID); got != "failure" {
t.Errorf("run status = %q", got)
}
})
}
// ── duplicate step names ─────────────────────────────────────────────────
const ciDupesTOML = `
image = "debian:latest"
[on]
manual = true
[[steps]]
name = "check"
run_sh = "echo one"
[[steps]]
name = "check"
run_sh = "echo two"
`
func TestCIDuplicateStepNames(t *testing.T) {
e, m, admin := ciEnv(t)
t.Run("each occurrence gets its own row, in file order", func(t *testing.T) {
sha := ciSeedToml(e, ciDupesTOML)
m.reset()
m.queueExec(execResp{output: "one\n"})
m.queueExec(execResp{output: "two\n"})
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "success" {
t.Fatalf("status = %q", got)
}
rows := ciSteps(e, runID, "check")
if len(rows) != 2 {
t.Fatalf("rows = %d", len(rows))
}
if !strings.Contains(rows[0].Log, "one") || !strings.Contains(rows[1].Log, "two") {
t.Errorf("logs = %q, %q", rows[0].Log, rows[1].Log)
}
for _, r := range rows {
if r.Status != "success" {
t.Errorf("status = %q", r.Status)
}
}
})
t.Run("the second occurrence can fail on its own", func(t *testing.T) {
sha := ciSeedToml(e, ciDupesTOML)
m.reset()
m.queueExec(execResp{output: "one\n"})
m.queueExec(execResp{output: "boom\n", exitCode: 1})
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "failure" {
t.Fatalf("status = %q", got)
}
rows := ciSteps(e, runID, "check")
got := []string{}
for _, r := range rows {
got = append(got, r.Status)
}
if !eqStrings(got, []string{"success", "failure"}) {
t.Errorf("statuses = %v", got)
}
})
}
// ── timeouts override warn_on_fail ───────────────────────────────────────
const ciTimeoutTOML = `
image = "debian:latest"
[on]
manual = true
[[steps]]
name = "lint"
run_sh = "make lint"
warn_on_fail = true
timeout = 1
[[steps]]
name = "build"
run_sh = "make"
`
func TestCITimeoutsOverrideWarnOnFail(t *testing.T) {
e, m, admin := ciEnv(t)
t.Run("a timed-out warn_on_fail step fails the run", func(t *testing.T) {
sha := ciSeedToml(e, ciTimeoutTOML)
m.reset()
m.queueExec(execResp{delay: 3 * time.Second})
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID, 30*time.Second); got != "failure" {
t.Fatalf("status = %q", got)
}
// A timeout destroys the container, so nothing after it can run.
// Reporting that as a warning would hide a dead pipeline.
lint := ciStep(e, runID, "lint")
if lint.Status != "failure" {
t.Errorf("lint status = %q", lint.Status)
}
if !strings.Contains(lint.Log, "timed out") {
t.Errorf("lint log = %q", lint.Log)
}
})
}
// ── clear failures are recorded ──────────────────────────────────────────
const ciClearTOML = `
image = "debian:latest"
work_dir = "/ci/build"
clone_project_to = "/ci/build/project"
[on]
manual = true
[[steps]]
name = "first"
run_sh = "false"
[[steps]]
name = "second"
always = true
clear = true
run_sh = "echo hi"
`
func TestCIClearFailuresAreRecorded(t *testing.T) {
e, m, admin := ciEnv(t)
t.Run("a clear failure lands on the step, not the console", func(t *testing.T) {
sha := ciSeedToml(e, ciClearTOML)
m.reset()
m.queueExec(execResp{}) // mkdir work_dir
m.queueExec(execResp{}) // mkdir clone_project_to
m.queueExec(execResp{output: "boom\n", exitCode: 1}) // first, fails
m.queueExec(execResp{output: "rm: device busy\n", exitCode: 1}) // clear
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "failure" {
t.Fatalf("status = %q", got)
}
second := ciStep(e, runID, "second")
if second.Status != "failure" {
t.Errorf("second status = %q", second.Status)
}
if !strings.Contains(second.Log, "Failed to reset") ||
!strings.Contains(second.Log, "device busy") {
t.Errorf("second log = %q", second.Log)
}
})
t.Run("a clear step re-extracts the checkout", func(t *testing.T) {
sha := ciSeedToml(e, ciClearTOML)
m.reset()
m.queueExec(execResp{}) // mkdir work_dir
m.queueExec(execResp{}) // mkdir clone_project_to
m.queueExec(execResp{output: "ok\n"}) // first
m.queueExec(execResp{}) // clear: rm -rf
m.queueExec(execResp{}) // mkdir clone_project_to again
m.queueExec(execResp{output: "hi\n"}) // second
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "success" {
t.Fatalf("status = %q", got)
}
if n := len(m.uploadsTo("/ci/build/project")); n != 2 {
t.Errorf("uploads to the clone directory = %d, want 2", n)
}
if strings.Contains(m.allCommandText(), "git") {
t.Errorf("commands = %v", m.commands())
}
})
t.Run("clear removes and recreates the directory in one exec", func(t *testing.T) {
// `rm -rf` can delete the container's WorkingDir. A second exec would
// then fail to chdir before its command starts.
sha := ciSeedToml(e, `
image = "debian:latest"
work_dir = "/ci/build"
clone_project_to = "/ci/build"
[on]
manual = true
[[steps]]
name = "first"
run_sh = "true"
[[steps]]
name = "second"
clear = true
run_sh = "echo hi"
`)
m.reset()
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "success" {
t.Fatalf("status = %q", got)
}
var removals []string
for _, c := range m.commands() {
if joined := strings.Join(c, " "); strings.Contains(joined, "rm -rf") {
removals = append(removals, joined)
}
}
if len(removals) != 1 {
t.Fatalf("rm -rf execs = %v", removals)
}
if !strings.Contains(removals[0], "mkdir -p") {
t.Errorf("removal exec = %q", removals[0])
}
})
}
// ── cache volumes ────────────────────────────────────────────────────────
const ciCacheTOML = `
image = "debian:latest"
cache = ["/ci/cache/target", "/ci/cache/registry"]
[on]
manual = true
push = ["main", "some-feature"]
[[steps]]
name = "hello"
run_sh = "echo hi"
`
func TestCICacheVolumes(t *testing.T) {
e, m, admin := ciEnv(t)
run := func(sha, branch string) int64 {
var runID int64
if branch == "main" {
runID = ciTrigger(e, admin, sha, nil)
} else {
runID = ciPushRun(e, sha, branch)
}
ciWaitForRun(e, runID)
return runID
}
t.Run("two cache paths sharing a prefix get distinct volumes", func(t *testing.T) {
sha := ciSeedToml(e, ciCacheTOML)
m.reset()
run(sha, "main")
vols := m.createdVolumes()
if len(vols) != 2 {
t.Fatalf("volumes = %v", vols)
}
if vols[0].name == vols[1].name {
t.Error("both cache paths share one volume")
}
// The path is otherwise unrecoverable from a digest.
got := []string{
vols[0].labels["com.hearthforge.cache-path"],
vols[1].labels["com.hearthforge.cache-path"],
}
if !eqStrings(got, []string{"/ci/cache/target", "/ci/cache/registry"}) {
t.Errorf("cache-path labels = %v", got)
}
})
t.Run("a volume the config no longer names is pruned", func(t *testing.T) {
sha := ciSeedToml(e, ciCacheTOML)
m.reset()
m.setVolumesOnHost("hearthforge-ci-cache-leftover-from-an-old-config")
run(sha, "main")
if got := m.deletedVolumes(); !eqStrings(got,
[]string{"hearthforge-ci-cache-leftover-from-an-old-config"}) {
t.Errorf("deleted = %v", got)
}
})
t.Run("volumes still in the config survive", func(t *testing.T) {
sha := ciSeedToml(e, ciCacheTOML)
m.reset()
// Prime the host list with the names this config creates.
run(sha, "main")
var inUse []string
for _, v := range m.createdVolumes() {
inUse = append(inUse, v.name)
}
m.reset()
m.setVolumesOnHost(inUse...)
run(sha, "main")
if got := m.deletedVolumes(); len(got) != 0 {
t.Errorf("deleted = %v", got)
}
})
t.Run("a run off the default branch prunes nothing", func(t *testing.T) {
sha := ciSeedToml(e, ciCacheTOML)
m.reset()
m.setVolumesOnHost("hearthforge-ci-cache-belongs-to-the-default-branch")
// The config is read per commit, so pruning from a feature branch
// would delete the default branch's caches.
run(sha, "some-feature")
if got := m.deletedVolumes(); len(got) != 0 {
t.Errorf("deleted = %v", got)
}
})
}
// ── cache size caps ──────────────────────────────────────────────────────
const ciCappedTOML = `
image = "debian:latest"
cache = [{ path = "/ci/cache/target", max_size = "1g" }, "/ci/cache/registry"]
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`
func TestCICacheSizeCaps(t *testing.T) {
e, m, admin := ciEnv(t)
run := func(sha string) int64 {
runID := ciTrigger(e, admin, sha, nil)
ciWaitForRun(e, runID)
return runID
}
// names returns the volume names the config produces, in declaration
// order.
names := func(sha string) (string, string) {
m.reset()
run(sha)
vols := m.createdVolumes()
if len(vols) != 2 {
t.Fatalf("volumes = %v", vols)
}
return vols[0].name, vols[1].name
}
const gib = int64(1024 * 1024 * 1024)
t.Run("an oversized cache is dropped and reported on the run", func(t *testing.T) {
sha := ciSeedToml(e, ciCappedTOML)
target, registry := names(sha)
m.reset()
m.setVolumesOnHost(target, registry)
m.setVolumeUsage(map[string]ciVolumeUsage{
target: {Size: 2 * gib},
registry: {Size: 9 * gib},
})
runID := run(sha)
// Only the capped one goes, however large the uncapped one grows.
if got := m.deletedVolumes(); !eqStrings(got, []string{target}) {
t.Fatalf("deleted = %v", got)
}
log := ciStep(e, runID, "cache").Log
if !strings.Contains(log, "/ci/cache/target") || !strings.Contains(log, "2.0G") {
t.Errorf("cache step log = %q", log)
}
})
t.Run("a cache under its cap survives", func(t *testing.T) {
sha := ciSeedToml(e, ciCappedTOML)
target, registry := names(sha)
m.reset()
m.setVolumesOnHost(target, registry)
m.setVolumeUsage(map[string]ciVolumeUsage{target: {Size: 100}})
run(sha)
if got := m.deletedVolumes(); len(got) != 0 {
t.Errorf("deleted = %v", got)
}
})
t.Run("a cache a concurrent run holds is left alone", func(t *testing.T) {
sha := ciSeedToml(e, ciCappedTOML)
target, registry := names(sha)
m.reset()
m.setVolumesOnHost(target, registry)
m.setVolumeUsage(map[string]ciVolumeUsage{target: {Size: 9 * gib, RefCount: 1}})
run(sha)
if got := m.deletedVolumes(); len(got) != 0 {
t.Errorf("deleted = %v", got)
}
})
t.Run("an unmeasured cache is never dropped", func(t *testing.T) {
sha := ciSeedToml(e, ciCappedTOML)
target, registry := names(sha)
m.reset()
m.setVolumesOnHost(target, registry)
// Docker reports -1 for a size it has not computed.
m.setVolumeUsage(map[string]ciVolumeUsage{target: {Size: -1}})
runID := run(sha)
if got := m.deletedVolumes(); len(got) != 0 {
t.Errorf("deleted = %v", got)
}
if steps := ciSteps(e, runID, "cache"); len(steps) != 0 {
t.Errorf("cache step = %v", steps)
}
})
}
Ainternal/web/e2e/fileediting_test.go
@@ -0,0 +1,118 @@
package e2e
import (
"net/url"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// fileeditCommitItem returns the commit log row with the given subject, or nil.
func fileeditCommitItem(r *response, subject string) *goquery.Selection {
var found *goquery.Selection
r.Find(".commit-item").EachWithBreak(func(_ int, s *goquery.Selection) bool {
if strings.TrimSpace(s.Find(".commit-subject").First().Text()) == subject {
found = s
return false
}
return true
})
return found
}
func TestFileEditing(t *testing.T) {
e := newEnv(t)
admin := e.admin()
// A dedicated repo so edits do not interfere with other tests.
e.createRepo(admin, "edit-repo")
e.seedRepo("edit-repo", nil)
t.Run("Edit button appears on text file blob when viewing a branch as admin", func(t *testing.T) {
r := admin.get("/edit-repo/blob/main/index.js").mustStatus(200)
sel := `a[href*="/edit/main/index.js"]`
if r.Count(sel) == 0 {
t.Fatal("Edit link missing")
}
if got := r.Text(sel); got != "Edit" {
t.Errorf("link text = %q", got)
}
})
t.Run("Edit button does not appear when viewing a commit SHA", func(t *testing.T) {
sha := e.headCommit("edit-repo")
if n := admin.get("/edit-repo/blob/" + sha + "/index.js").Count(`a[href*="/edit/"]`); n != 0 {
t.Errorf("Edit links = %d", n)
}
})
t.Run("Edit button does not appear for unauthenticated visitors", func(t *testing.T) {
if n := e.anon().get("/edit-repo/blob/main/index.js").Count(`a[href*="/edit/main/"]`); n != 0 {
t.Errorf("Edit links = %d", n)
}
})
t.Run("edit page loads with file content pre-filled", func(t *testing.T) {
r := admin.get("/edit-repo/edit/main/index.js").mustStatus(200)
if got := r.Text(".file-blob-name"); got != "index.js" {
t.Errorf("file name = %q", got)
}
if got := r.Value("textarea[name=content]"); !strings.Contains(got, "hello") {
t.Errorf("content = %q", got)
}
if got := r.Value("textarea[name=message]"); got != "Edited index.js" {
t.Errorf("message = %q", got)
}
})
t.Run("edit page shows which branch will be committed to", func(t *testing.T) {
if !admin.get("/edit-repo/edit/main/index.js").Contains("main") {
t.Error("branch name missing")
}
})
t.Run("edit page returns 404 for non-branch ref", func(t *testing.T) {
sha := e.headCommit("edit-repo")
admin.get("/edit-repo/edit/" + sha + "/index.js").mustStatus(404)
})
t.Run("submitting edit creates a new commit and redirects to blob view", func(t *testing.T) {
r := admin.post("/edit-repo/edit/main/index.js", url.Values{
"content": {"console.log(\"edited\");\n"},
"new_path": {"index.js"},
"message": {"Update index.js via web editor"},
})
r.mustRedirect("/edit-repo/commit/")
if !admin.follow(r).Contains("Update index.js via web editor") {
t.Error("commit view does not show the message")
}
})
t.Run("edit commit has a gpgsig header (is signed)", func(t *testing.T) {
dir := e.repoPath("edit-repo")
hash := gitRun(t, dir, "log", "--format=%H", "--grep=Update index.js via web editor", "-1")
if hash == "" {
t.Fatal("edit commit not found")
}
if obj := gitRun(t, dir, "cat-file", "-p", hash); !strings.Contains(obj, "gpgsig") {
t.Errorf("commit object has no gpgsig:\n%s", obj)
}
})
t.Run("edit commit shows verified badge in commit log", func(t *testing.T) {
r := admin.get("/edit-repo/commits/main").mustStatus(200)
item := fileeditCommitItem(r, "Update index.js via web editor")
if item == nil {
t.Fatal("commit row missing")
}
if item.Find(".sig-badge.verified").Length() == 0 {
t.Error("verified badge missing")
}
})
t.Run("GET edit page returns 404 for non-branch ref", func(t *testing.T) {
sha := e.headCommit("edit-repo")
admin.get("/edit-repo/edit/" + sha + "/index.js").mustStatus(404)
})
}
Ainternal/web/e2e/gitops_test.go
@@ -0,0 +1,419 @@
package e2e
import (
"net/url"
"regexp"
"slices"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// gitopsCommitRedirect matches the redirect target of a write operation.
var gitopsCommitRedirect = regexp.MustCompile(`^/[a-z-]+/commit/[0-9a-f]{40}$`)
// gitopsRefNames lists the branch or tag names on a ref list page.
func gitopsRefNames(r *response) []string { return r.Texts(".ref-item .ref-name") }
// gitopsRefItem returns the list row of one branch or tag, or nil.
func gitopsRefItem(r *response, name string) *goquery.Selection {
var found *goquery.Selection
r.Find(".ref-item").EachWithBreak(func(_ int, s *goquery.Selection) bool {
if strings.TrimSpace(s.Find(".ref-name").First().Text()) == name {
found = s
return false
}
return true
})
return found
}
// gitopsSummaries lists the popup toggle labels inside a row.
func gitopsSummaries(sel *goquery.Selection) []string {
var out []string
sel.Find("summary").Each(func(_ int, s *goquery.Selection) {
out = append(out, strings.TrimSpace(s.Text()))
})
return out
}
// gitopsCommitBody returns the raw commit object of the newest commit whose
// message matches grep.
func gitopsCommitBody(t *testing.T, dir, grep string) string {
t.Helper()
hash := gitRun(t, dir, "log", "--format=%H", "--grep="+grep, "-1")
if hash == "" {
t.Fatalf("no commit matching %q", grep)
}
return gitRun(t, dir, "cat-file", "-p", hash)
}
func TestGitOps(t *testing.T) {
e := newEnv(t)
admin := e.admin()
for _, name := range []string{
"branch-repo", "tag-repo", "selector-repo",
"newfile-repo", "delfile-repo", "movefile-repo",
} {
e.createRepo(admin, name)
}
e.seedRepo("branch-repo", nil)
e.seedBranch("branch-repo", "feature-a", nil)
e.seedRepo("tag-repo", nil)
gitRun(t, e.repoPath("tag-repo"), "tag", "v0.1.0", "HEAD")
e.Srv.Git.InvalidateRefCache("tag-repo")
e.seedRepo("selector-repo", nil)
gitRun(t, e.repoPath("selector-repo"), "tag", "stable", "HEAD")
e.Srv.Git.InvalidateRefCache("selector-repo")
e.seedRepo("newfile-repo", nil)
e.seedRepo("delfile-repo", nil)
e.seedRepo("movefile-repo", nil)
// ─── Branch management ───────────────────────────────────────────────
t.Run("Branches tab appears in repo nav", func(t *testing.T) {
tabs := admin.get("/branch-repo").mustStatus(200).Texts("a.repo-tab")
if !slices.Contains(tabs, "Branches") {
t.Errorf("repo tabs = %v", tabs)
}
})
t.Run("branches page lists all branches", func(t *testing.T) {
r := admin.get("/branch-repo/branches").mustStatus(200)
if !r.Contains("main") || !r.Contains("feature-a") {
t.Error("branch list is missing main or feature-a")
}
})
t.Run("default branch has a \"default\" badge", func(t *testing.T) {
badges := admin.get("/branch-repo/branches").Texts(".badge")
if !slices.Contains(badges, "default") {
t.Errorf("badges = %v", badges)
}
})
t.Run("branch list shows last commit hash and subject", func(t *testing.T) {
sha := e.headCommit("branch-repo")[:7]
r := admin.get("/branch-repo/branches")
if !r.Contains(sha) || !r.Contains("Initial commit") {
t.Errorf("branch list misses %q or the subject", sha)
}
})
t.Run("branches page returns 404 for non-existent repo", func(t *testing.T) {
e.anon().get("/does-not-exist/branches").mustStatus(404)
})
t.Run("create a new branch", func(t *testing.T) {
r := admin.post("/branch-repo/branches/create", url.Values{
"name": {"new-feature"}, "source_ref": {"main"},
})
r.mustRedirect("/branch-repo/branches")
names := gitopsRefNames(admin.follow(r))
if !slices.Contains(names, "new-feature") {
t.Errorf("branches = %v", names)
}
})
t.Run("creating branch with invalid name shows error", func(t *testing.T) {
loc := admin.post("/branch-repo/branches/create", url.Values{
"name": {"--invalid"}, "source_ref": {"main"},
}).mustRedirect("/branch-repo/branches")
if !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
t.Run("creating branch from non-existent ref shows error", func(t *testing.T) {
loc := admin.post("/branch-repo/branches/create", url.Values{
"name": {"bad-branch"}, "source_ref": {"does-not-exist"},
}).mustRedirect("/branch-repo/branches")
if !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
t.Run("rename a branch", func(t *testing.T) {
r := admin.post("/branch-repo/branches/rename", url.Values{
"old_name": {"feature-a"}, "new_name": {"feature-renamed"},
})
r.mustRedirect("/branch-repo/branches")
names := gitopsRefNames(admin.follow(r))
if !slices.Contains(names, "feature-renamed") || slices.Contains(names, "feature-a") {
t.Errorf("branches = %v", names)
}
})
t.Run("delete a non-default branch", func(t *testing.T) {
r := admin.post("/branch-repo/branches/delete", url.Values{"name": {"new-feature"}})
r.mustRedirect("/branch-repo/branches")
names := gitopsRefNames(admin.follow(r))
if slices.Contains(names, "new-feature") {
t.Errorf("branches = %v", names)
}
})
t.Run("cannot delete the default branch (no Delete button on main row)", func(t *testing.T) {
row := gitopsRefItem(admin.get("/branch-repo/branches"), "main")
if row == nil {
t.Fatal("main row missing")
}
if labels := gitopsSummaries(row); slices.Contains(labels, "Delete") {
t.Errorf("main row actions = %v", labels)
}
})
t.Run("renaming default branch updates it in repo", func(t *testing.T) {
r := admin.post("/branch-repo/branches/rename", url.Values{
"old_name": {"main"}, "new_name": {"trunk"},
})
r.mustRedirect("/branch-repo/branches")
row := gitopsRefItem(admin.follow(r), "trunk")
if row == nil {
t.Fatal("trunk row missing")
}
if row.Find(".badge").Length() == 0 {
t.Error("trunk is not marked as the default branch")
}
// Rename back so later reads of this repo still see main.
admin.post("/branch-repo/branches/rename", url.Values{
"old_name": {"trunk"}, "new_name": {"main"},
}).mustRedirect("/branch-repo/branches")
})
// ─── Tag management ──────────────────────────────────────────────────
t.Run("Tags tab appears in repo nav", func(t *testing.T) {
tabs := admin.get("/tag-repo").mustStatus(200).Texts("a.repo-tab")
if !slices.Contains(tabs, "Tags") {
t.Errorf("repo tabs = %v", tabs)
}
})
t.Run("tags page lists existing tags", func(t *testing.T) {
if !admin.get("/tag-repo/tags").mustStatus(200).Contains("v0.1.0") {
t.Error("v0.1.0 missing")
}
})
t.Run("tag links to correct tree view", func(t *testing.T) {
r := admin.get("/tag-repo/tags")
if r.Count(`a[href="/tag-repo/tree/v0.1.0"]`) == 0 {
t.Error("tree link for v0.1.0 missing")
}
})
t.Run("create a new tag", func(t *testing.T) {
r := admin.post("/tag-repo/tags/create", url.Values{
"name": {"v1.0.0"}, "ref": {"main"},
})
r.mustRedirect("/tag-repo/tags")
names := gitopsRefNames(admin.follow(r))
if !slices.Contains(names, "v1.0.0") {
t.Errorf("tags = %v", names)
}
})
t.Run("create annotated tag with message", func(t *testing.T) {
r := admin.post("/tag-repo/tags/create", url.Values{
"name": {"v1.1.0-annotated"}, "ref": {"main"},
"message": {"Annotated release tag"},
})
r.mustRedirect("/tag-repo/tags")
names := gitopsRefNames(admin.follow(r))
if !slices.Contains(names, "v1.1.0-annotated") {
t.Errorf("tags = %v", names)
}
})
t.Run("delete a tag", func(t *testing.T) {
r := admin.post("/tag-repo/tags/delete", url.Values{"name": {"v1.0.0"}})
r.mustRedirect("/tag-repo/tags")
names := gitopsRefNames(admin.follow(r))
if slices.Contains(names, "v1.0.0") || !slices.Contains(names, "v0.1.0") {
t.Errorf("tags = %v", names)
}
})
t.Run("tag linked to release shows release badge and warning on delete", func(t *testing.T) {
admin.postMultipart("/tag-repo/releases", url.Values{
"name": {"Linked Release"}, "create_tag": {"on"},
"tag_name": {"v-linked"}, "revision": {"main"},
}).mustRedirect("/tag-repo/releases")
row := gitopsRefItem(admin.get("/tag-repo/tags"), "v-linked")
if row == nil {
t.Fatal("v-linked row missing")
}
if row.Find(".badge-release").Length() == 0 {
t.Error("release badge missing")
}
if row.Find(".confirm-warning").Length() == 0 {
t.Error("delete warning missing")
}
})
// ─── BranchSelector with tags ────────────────────────────────────────
t.Run("branch selector shows Tags optgroup when tags exist", func(t *testing.T) {
r := admin.get("/selector-repo").mustStatus(200)
if n := r.Count(`optgroup[label="Tags"]`); n != 1 {
t.Errorf("Tags optgroups = %d", n)
}
if !contains(r.Texts("option"), "stable") {
t.Error("stable option missing")
}
})
t.Run("branch selector shows Branches optgroup when tags exist", func(t *testing.T) {
if n := admin.get("/selector-repo").Count(`optgroup[label="Branches"]`); n != 1 {
t.Errorf("Branches optgroups = %d", n)
}
})
t.Run("branch selector on blob view includes tag optgroup", func(t *testing.T) {
r := admin.get("/selector-repo/blob/main/README.md").mustStatus(200)
if n := r.Count(`optgroup[label="Tags"]`); n != 1 {
t.Errorf("Tags optgroups = %d", n)
}
})
// ─── File creation ───────────────────────────────────────────────────
t.Run("New file button appears in tree toolbar for admin on a branch", func(t *testing.T) {
if admin.get("/newfile-repo/tree/main").mustStatus(200).
Count(`a[href*="/new-file/main"]`) == 0 {
t.Error("New file link missing")
}
})
t.Run("New file button not visible on commit SHA view", func(t *testing.T) {
sha := e.headCommit("newfile-repo")
if n := admin.get("/newfile-repo/tree/" + sha).Count(`a[href*="/new-file/"]`); n != 0 {
t.Errorf("New file links = %d", n)
}
})
t.Run("New file button not visible to unauthenticated user", func(t *testing.T) {
if n := e.anon().get("/newfile-repo/tree/main").Count(`a[href*="/new-file/main"]`); n != 0 {
t.Errorf("New file links = %d", n)
}
})
t.Run("new file form pre-fills dir when ?dir= query param is provided", func(t *testing.T) {
if got := admin.get("/newfile-repo/new-file/main?dir=src").Value("[name=path]"); got != "src/" {
t.Errorf("path = %q", got)
}
})
t.Run("creating a new file creates a commit and redirects to commit view", func(t *testing.T) {
loc := admin.post("/newfile-repo/new-file/main", url.Values{
"path": {"hello.txt"}, "content": {"Hello, world!\n"}, "message": {"Add hello.txt"},
}).mustRedirect("/newfile-repo/commit/")
if !gitopsCommitRedirect.MatchString(loc) {
t.Errorf("location = %q", loc)
}
})
t.Run("new file appears in tree after creation", func(t *testing.T) {
if !admin.get("/newfile-repo/tree/main").Contains("hello.txt") {
t.Error("hello.txt missing from tree")
}
})
t.Run("new file commit is signed", func(t *testing.T) {
obj := gitopsCommitBody(t, e.repoPath("newfile-repo"), "Add hello.txt")
if !strings.Contains(obj, "gpgsig") {
t.Errorf("commit object has no gpgsig:\n%s", obj)
}
})
t.Run("creating file with invalid path shows error", func(t *testing.T) {
loc := admin.post("/newfile-repo/new-file/main", url.Values{
"path": {"../escape"}, "content": {""}, "message": {"bad"},
}).mustRedirect("/newfile-repo/new-file/main")
if !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
// ─── File deletion ───────────────────────────────────────────────────
t.Run("Delete button appears in file blob for admin on a branch", func(t *testing.T) {
r := admin.get("/delfile-repo/blob/main/index.js").mustStatus(200)
if !slices.Contains(r.Texts("details summary"), "Delete") {
t.Error("Delete popup missing")
}
})
t.Run("Delete button not visible to unauthenticated user", func(t *testing.T) {
r := e.anon().get("/delfile-repo/blob/main/index.js").mustStatus(200)
if slices.Contains(r.Texts("details summary"), "Delete") {
t.Error("Delete popup shown to anonymous visitor")
}
})
t.Run("deleting a file creates a commit and removes it from the tree", func(t *testing.T) {
loc := admin.post("/delfile-repo/delete-file/main/index.js", url.Values{
"message": {"Remove index.js"},
}).mustRedirect("/delfile-repo/commit/")
if !gitopsCommitRedirect.MatchString(loc) {
t.Errorf("location = %q", loc)
}
if admin.get("/delfile-repo/tree/main").Contains("index.js") {
t.Error("index.js still in tree")
}
})
t.Run("delete commit is signed", func(t *testing.T) {
obj := gitopsCommitBody(t, e.repoPath("delfile-repo"), "Remove index.js")
if !strings.Contains(obj, "gpgsig") {
t.Errorf("commit object has no gpgsig:\n%s", obj)
}
})
// ─── File rename/move ────────────────────────────────────────────────
t.Run("edit form has new_path input pre-filled with current path", func(t *testing.T) {
r := admin.get("/movefile-repo/edit/main/index.js").mustStatus(200)
if got := r.Value("[name=new_path]"); got != "index.js" {
t.Errorf("new_path = %q", got)
}
})
t.Run("renaming a file via edit creates a commit and old path is gone", func(t *testing.T) {
loc := admin.post("/movefile-repo/edit/main/index.js", url.Values{
"content": {"console.log(\"hello\");\n"}, "new_path": {"app.js"},
"message": {"Rename index.js to app.js"},
}).mustRedirect("/movefile-repo/commit/")
if !gitopsCommitRedirect.MatchString(loc) {
t.Errorf("location = %q", loc)
}
r := admin.get("/movefile-repo/tree/main")
if !r.Contains("app.js") || r.Contains("index.js") {
t.Error("tree still shows index.js or misses app.js")
}
})
t.Run("rename commit is signed", func(t *testing.T) {
obj := gitopsCommitBody(t, e.repoPath("movefile-repo"), "Rename index.js")
if !strings.Contains(obj, "gpgsig") {
t.Errorf("commit object has no gpgsig:\n%s", obj)
}
})
t.Run("renaming to invalid path shows error", func(t *testing.T) {
loc := admin.post("/movefile-repo/edit/main/README.md", url.Values{
"content": {"# movefile-repo\n"}, "new_path": {"../escape.md"}, "message": {"bad"},
}).mustRedirect("/movefile-repo/edit/main/README.md")
if !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
}
Ainternal/web/e2e/harness_test.go
@@ -0,0 +1,491 @@
// Package e2e drives the full HTTP server in-process: the real router with
// every middleware, a real listener, and a cookie jar per session. Tests
// assert on the rendered HTML with goquery. A small browser suite in
// browser_test.go covers the few flows that need JavaScript.
package e2e
import (
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
"hearthforge/internal/ci"
"hearthforge/internal/config"
"hearthforge/internal/db"
"hearthforge/internal/gitcmd"
"hearthforge/internal/highlight"
"hearthforge/internal/markdown"
"hearthforge/internal/web"
)
const adminPass = "correct-horse-battery"
// env is one running server on a throwaway data directory.
type env struct {
t *testing.T
Srv *web.Server
DB *db.DB
Cfg *config.Config
Base string
DataDir string
http *httptest.Server
}
// newEnv starts a server. extraEnv overrides environment variables the way
// the process would read them, e.g. "BASE_URL", "REGISTRATION_TYPE",
// "CI_DOCKER_SOCKET". DATA_DIR, SSH_DISABLED and RATE_LIMIT_DISABLED are
// always set.
func newEnv(t *testing.T, extraEnv ...string) *env {
t.Helper()
for _, bin := range []string{"git", "ssh-keygen"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("%s not installed", bin)
}
}
dataDir := t.TempDir()
hs := httptest.NewUnstartedServer(nil)
base := "http://" + hs.Listener.Addr().String()
t.Setenv("DATA_DIR", dataDir)
t.Setenv("SSH_DISABLED", "1")
t.Setenv("RATE_LIMIT_DISABLED", "1")
t.Setenv("BASE_URL", base)
t.Setenv("CI_DOCKER_SOCKET", filepath.Join(dataDir, "no-such-socket"))
for i := 0; i+1 < len(extraEnv); i += 2 {
t.Setenv(extraEnv[i], extraEnv[i+1])
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
for _, dir := range []string{cfg.ReposDir(), cfg.AvatarsDir(), cfg.ReleasesDir(), cfg.CIArtifactsDir()} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
database, err := db.Open(cfg.DBPath())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if _, err := database.InitAdmin(ctx, adminPass); err != nil {
t.Fatal(err)
}
git := gitcmd.New(cfg)
runner := ci.New(cfg, database)
srv := &web.Server{
Cfg: cfg,
DB: database,
MD: markdown.New(),
HL: highlight.New(cfg.InlineMaxBytes),
CI: runner,
Git: git,
Patches: gitcmd.NewPatchCache(),
}
if err := srv.SyncRepos(ctx); err != nil {
t.Fatal(err)
}
hs.Config.Handler = srv.Router()
hs.Start()
t.Cleanup(func() {
hs.Close()
cancel()
database.Close()
})
return &env{t: t, Srv: srv, DB: database, Cfg: cfg, Base: base, DataDir: dataDir, http: hs}
}
// session is one browser-like client with its own cookie jar. Redirects are
// not followed, so tests can assert on them.
type session struct {
env *env
client *http.Client
}
func (e *env) anon() *session {
jar, _ := cookiejar.New(nil)
return &session{env: e, client: &http.Client{
Jar: jar,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}}
}
// login signs in and fails the test when the credentials are rejected.
func (e *env) login(username, password string) *session {
e.t.Helper()
s := e.anon()
r := s.post("/login", url.Values{"username": {username}, "password": {password}})
if r.Code != http.StatusFound || r.Location() != "/" {
e.t.Fatalf("login as %s: status %d, location %q, body %s", username, r.Code, r.Location(), r.Text("body"))
}
return s
}
// admin returns a session signed in as the admin.
func (e *env) admin() *session { return e.login(db.AdminUsername, adminPass) }
// register creates a user through the form and returns a signed-in session.
func (e *env) register(username, password string) *session {
e.t.Helper()
s := e.anon()
r := s.post("/register", url.Values{
"username": {username}, "password": {password}, "password2": {password},
})
if r.Code != http.StatusFound {
e.t.Fatalf("register %s: status %d, body %s", username, r.Code, r.Text(".form-error"))
}
return s
}
// cookie returns the named cookie of the session, or nil.
func (s *session) cookie(name string) *http.Cookie {
u, _ := url.Parse(s.env.Base)
for _, c := range s.client.Jar.Cookies(u) {
if c.Name == name {
return c
}
}
return nil
}
// setCookie stores a cookie in the jar.
func (s *session) setCookie(name, value string) {
u, _ := url.Parse(s.env.Base)
s.client.Jar.SetCookies(u, []*http.Cookie{{Name: name, Value: value, Path: "/"}})
}
// response is a fully read HTTP response with lazy HTML parsing.
type response struct {
t *testing.T
Code int
Header http.Header
Body []byte
doc *goquery.Document
}
func (r *response) Location() string { return r.Header.Get("Location") }
// Doc parses the body as HTML once.
func (r *response) Doc() *goquery.Document {
if r.doc == nil {
d, err := goquery.NewDocumentFromReader(bytes.NewReader(r.Body))
if err != nil {
r.t.Fatal(err)
}
r.doc = d
}
return r.doc
}
// Find selects elements by CSS selector.
func (r *response) Find(sel string) *goquery.Selection { return r.Doc().Find(sel) }
// Count returns how many elements match.
func (r *response) Count(sel string) int { return r.Find(sel).Length() }
// Has reports whether at least one element matches.
func (r *response) Has(sel string) bool { return r.Count(sel) > 0 }
// Text returns the trimmed text of the first match, or "" when none.
func (r *response) Text(sel string) string {
return strings.TrimSpace(r.Find(sel).First().Text())
}
// Texts returns the trimmed text of every match.
func (r *response) Texts(sel string) []string {
var out []string
r.Find(sel).Each(func(_ int, s *goquery.Selection) {
out = append(out, strings.TrimSpace(s.Text()))
})
return out
}
// Attr returns an attribute of the first match, or "" when none.
func (r *response) Attr(sel, name string) string {
v, _ := r.Find(sel).First().Attr(name)
return v
}
// Value returns the value of the first matching input, or the selected
// option of a select.
func (r *response) Value(sel string) string {
el := r.Find(sel).First()
if goquery.NodeName(el) == "select" {
v, _ := el.Find("option[selected]").First().Attr("value")
if v == "" {
v, _ = el.Find("option").First().Attr("value")
}
return v
}
if goquery.NodeName(el) == "textarea" {
return el.Text()
}
v, _ := el.Attr("value")
return v
}
// BodyString returns the raw body.
func (r *response) BodyString() string { return string(r.Body) }
// Contains reports whether the raw body contains s.
func (r *response) Contains(s string) bool { return bytes.Contains(r.Body, []byte(s)) }
func (s *session) do(req *http.Request) *response {
s.env.t.Helper()
res, err := s.client.Do(req)
if err != nil {
s.env.t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
s.env.t.Fatal(err)
}
return &response{t: s.env.t, Code: res.StatusCode, Header: res.Header, Body: body}
}
// get fetches a path. header pairs are optional extra request headers.
func (s *session) get(path string, header ...string) *response {
s.env.t.Helper()
req, _ := http.NewRequest(http.MethodGet, s.env.Base+path, nil)
for i := 0; i+1 < len(header); i += 2 {
req.Header.Set(header[i], header[i+1])
}
return s.do(req)
}
// post sends a urlencoded form.
func (s *session) post(path string, form url.Values, header ...string) *response {
s.env.t.Helper()
req, _ := http.NewRequest(http.MethodPost, s.env.Base+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for i := 0; i+1 < len(header); i += 2 {
req.Header.Set(header[i], header[i+1])
}
return s.do(req)
}
// file is one upload part of a multipart form.
type file struct {
Field, Name string
Content []byte
}
// postMultipart sends a multipart form with fields and files. Repeated
// field values are sent as repeated parts.
func (s *session) postMultipart(path string, fields url.Values, files ...file) *response {
s.env.t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for k, vs := range fields {
for _, v := range vs {
_ = mw.WriteField(k, v)
}
}
for _, f := range files {
w, err := mw.CreateFormFile(f.Field, f.Name)
if err != nil {
s.env.t.Fatal(err)
}
_, _ = w.Write(f.Content)
}
mw.Close()
req, _ := http.NewRequest(http.MethodPost, s.env.Base+path, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
return s.do(req)
}
// follow GETs the Location of a redirect response.
func (s *session) follow(r *response) *response {
s.env.t.Helper()
if r.Code < 300 || r.Code > 399 {
s.env.t.Fatalf("expected redirect, got %d: %s", r.Code, r.BodyString())
}
loc := r.Location()
loc = strings.TrimPrefix(loc, s.env.Base)
return s.get(loc)
}
// mustRedirect asserts a redirect to the given path prefix and returns the
// location.
func (r *response) mustRedirect(prefix string) string {
r.t.Helper()
if r.Code != http.StatusFound && r.Code != http.StatusSeeOther {
r.t.Fatalf("status = %d, want redirect; body: %s", r.Code, firstLines(r.BodyString()))
}
if !strings.HasPrefix(r.Location(), prefix) {
r.t.Fatalf("redirected to %q, want prefix %q", r.Location(), prefix)
}
return r.Location()
}
// mustStatus asserts the status code.
func (r *response) mustStatus(code int) *response {
r.t.Helper()
if r.Code != code {
r.t.Fatalf("status = %d, want %d; body: %s", r.Code, code, firstLines(r.BodyString()))
}
return r
}
func firstLines(s string) string {
if len(s) > 400 {
return s[:400] + "…"
}
return s
}
// ---------- git helpers ----------
// gitRun runs git in dir and fails the test on error.
func gitRun(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com",
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
"GIT_TERMINAL_PROMPT=0")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
// gitTry runs git and returns the error instead of failing.
func gitTry(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com",
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
"GIT_TERMINAL_PROMPT=0")
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
// repoPath is the bare repository on disk.
func (e *env) repoPath(name string) string {
return filepath.Join(e.Cfg.ReposDir(), name+".git")
}
// createRepo creates a repository through the admin form.
func (e *env) createRepo(admin *session, name string, extra ...string) {
e.t.Helper()
form := url.Values{"name": {name}, "default_branch": {"main"}}
for i := 0; i+1 < len(extra); i += 2 {
form.Set(extra[i], extra[i+1])
}
admin.post("/new", form).mustRedirect("/" + name)
}
// seedRepo commits the given files (path -> content) and pushes them to the
// bare repo's main branch. It returns the new HEAD hash. Files are added on
// top of the current main, so repeated calls stack commits.
func (e *env) seedRepo(name string, files map[string]string, message ...string) string {
e.t.Helper()
if files == nil {
files = map[string]string{
"README.md": "# " + name + "\n",
"index.js": "console.log(\"hello\");\n",
"logo.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>` + "\n",
}
}
msg := "Initial commit"
if len(message) > 0 {
msg = message[0]
}
work := e.t.TempDir()
gitRun(e.t, work, "clone", "-q", e.repoPath(name), ".")
for p, content := range files {
full := filepath.Join(work, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
e.t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
e.t.Fatal(err)
}
}
gitRun(e.t, work, "add", "-A")
gitRun(e.t, work, "commit", "-q", "-m", msg)
gitRun(e.t, work, "push", "-q", "origin", "HEAD:main")
e.Srv.Git.InvalidateRefCache(name)
return gitRun(e.t, work, "rev-parse", "HEAD")
}
// seedBranch creates a branch from main with one extra commit and pushes it.
func (e *env) seedBranch(name, branch string, files map[string]string) string {
e.t.Helper()
work := e.t.TempDir()
gitRun(e.t, work, "clone", "-q", "-b", "main", e.repoPath(name), ".")
gitRun(e.t, work, "checkout", "-q", "-b", branch)
if files == nil {
files = map[string]string{branch + ".txt": "on " + branch + "\n"}
}
for p, content := range files {
full := filepath.Join(work, p)
_ = os.MkdirAll(filepath.Dir(full), 0o755)
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
e.t.Fatal(err)
}
}
gitRun(e.t, work, "add", "-A")
gitRun(e.t, work, "commit", "-q", "-m", "Commit on "+branch)
gitRun(e.t, work, "push", "-q", "origin", branch)
e.Srv.Git.InvalidateRefCache(name)
return gitRun(e.t, work, "rev-parse", "HEAD")
}
// headCommit returns the hash main points at.
func (e *env) headCommit(name string) string {
e.t.Helper()
return gitRun(e.t, e.repoPath(name), "rev-parse", "main")
}
// authURL is the clone URL with admin Basic auth credentials.
func (e *env) authURL(name string) string {
u, _ := url.Parse(e.Base)
u.User = url.UserPassword(db.AdminUsername, adminPass)
return u.String() + "/" + name + ".git"
}
// ---------- misc ----------
// idFromPath returns the last numeric path segment, e.g. the issue number
// of "/repo/issues/12".
func idFromPath(t *testing.T, p string) string {
t.Helper()
p = strings.TrimRight(p, "/")
i := strings.LastIndex(p, "/")
if i < 0 || i == len(p)-1 {
t.Fatalf("no id in %q", p)
}
return p[i+1:]
}
// contains reports whether any string in list contains sub.
func contains(list []string, sub string) bool {
for _, s := range list {
if strings.Contains(s, sub) {
return true
}
}
return false
}
Ainternal/web/e2e/issues_test.go
@@ -0,0 +1,324 @@
package e2e
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// issuesSetup runs the shared setup of the TS file: register alice, create
// my-repo and push the first commit.
func issuesSetup(t *testing.T) (*env, *session, *session) {
t.Helper()
e := newEnv(t)
alice := e.register("alice", "password123")
admin := e.admin()
e.createRepo(admin, "my-repo")
e.seedRepo("my-repo", nil)
return e, admin, alice
}
// issuesCreate posts the new issue form and returns the issue path.
func issuesCreate(s *session, repo, title, body string) string {
form := url.Values{"title": {title}}
if body != "" {
form.Set("body", body)
}
return s.post("/"+repo+"/issues", form).mustRedirect("/" + repo + "/issues/")
}
// issuesTimelineItem returns the timeline item whose text contains want.
func issuesTimelineItem(t *testing.T, r *response, want string) *goquery.Selection {
t.Helper()
var found *goquery.Selection
r.Find(".timeline-item").Each(func(_ int, s *goquery.Selection) {
if found == nil && strings.Contains(s.Text(), want) {
found = s
}
})
if found == nil {
t.Fatalf("no timeline item containing %q", want)
}
return found
}
// issuesCommentID reads the comment id out of the inline edit form of the
// timeline item that contains want.
func issuesCommentID(t *testing.T, r *response, want string) string {
t.Helper()
item := issuesTimelineItem(t, r, want)
action, ok := item.Find(`form[action*="/comments/"]`).First().Attr("action")
if !ok {
t.Fatalf("no comment edit form for %q", want)
}
rest := strings.SplitN(action, "/comments/", 2)[1]
return strings.SplitN(rest, "/", 2)[0]
}
// issuesSaveSettings submits the repo settings form the way a browser does:
// every existing field value is resent, with overrides applied on top.
func issuesSaveSettings(s *session, repo string, overrides url.Values) *response {
r := s.get("/" + repo + "/settings")
form := url.Values{
"description": {r.Value("input[name=description]")},
"default_branch": {r.Value("[name=default_branch]")},
"issue_template": {r.Value("textarea[name=issue_template]")},
"patch_template": {r.Value("textarea[name=patch_template]")},
}
for _, name := range []string{"is_private", "is_pinned", "allow_user_labels"} {
if r.Has("input[name=" + name + "][checked]") {
form.Set(name, "1")
}
}
for k, vs := range overrides {
form[k] = vs
}
return s.post("/"+repo+"/settings", form)
}
func TestIssues(t *testing.T) {
e, admin, _ := issuesSetup(t)
var issuePath, completedIssuePath string
t.Run("create issue", func(t *testing.T) {
issuePath = issuesCreate(admin, "my-repo", "First issue", "Body with **markdown**.")
if got := admin.get(issuePath).Text(".issue-detail-title"); got != "First issue" {
t.Errorf("title = %q", got)
}
})
t.Run("issue body renders markdown", func(t *testing.T) {
r := admin.get(issuePath)
html, err := r.Find(".timeline-body.markdown-body").First().Html()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "<strong>") {
t.Errorf("body html = %q", html)
}
})
t.Run("issue appears in open list", func(t *testing.T) {
if !contains(admin.get("/my-repo/issues").Texts(".issue-title"), "First issue") {
t.Error("issue not listed")
}
})
t.Run("unauthenticated user is redirected to login from new issue form", func(t *testing.T) {
e.anon().get("/my-repo/issues/new").mustRedirect("/login")
})
t.Run("add comment", func(t *testing.T) {
before := admin.get(issuePath).Count(".timeline-item")
admin.post(issuePath+"/comments", url.Values{"body": {"A follow-up comment."}}).
mustRedirect(issuePath)
if after := admin.get(issuePath).Count(".timeline-item"); after <= before {
t.Errorf("timeline items %d, want more than %d", after, before)
}
})
t.Run("react to issue", func(t *testing.T) {
admin.post(issuePath+"/react", url.Values{"emoji": {"👍"}}).mustRedirect(issuePath)
if n := admin.get(issuePath).Count(".reaction-btn"); n == 0 {
t.Error("no reaction button")
}
})
t.Run("close issue changes status badge", func(t *testing.T) {
admin.post(issuePath+"/close", nil).mustRedirect(issuePath)
if got := admin.get(issuePath).Text(".issue-badge"); got != "closed" {
t.Errorf("badge = %q", got)
}
})
t.Run("closed issue appears in closed list", func(t *testing.T) {
if !contains(admin.get("/my-repo/issues?status=closed").Texts(".issue-title"), "First issue") {
t.Error("issue not in closed list")
}
})
t.Run("reopen issue", func(t *testing.T) {
// The only action button on a closed issue reopens it.
admin.post(issuePath+"/close", nil).mustRedirect(issuePath)
if got := admin.get(issuePath).Text(".issue-badge"); got != "open" {
t.Errorf("badge = %q", got)
}
})
t.Run("completed button marks issue as completed", func(t *testing.T) {
completedIssuePath = issuesCreate(admin, "my-repo", "To be completed", "")
admin.post(completedIssuePath+"/complete", nil).mustRedirect(completedIssuePath)
if got := admin.get(completedIssuePath).Text(".issue-badge"); got != "completed" {
t.Errorf("badge = %q", got)
}
})
t.Run("completed issue appears in completed list", func(t *testing.T) {
if !contains(admin.get("/my-repo/issues?status=completed").Texts(".issue-title"), "To be completed") {
t.Error("issue not in completed list")
}
})
t.Run("non-admin cannot complete or close issue", func(t *testing.T) {
r := e.anon().post(issuePath+"/complete", nil)
r.mustStatus(http.StatusFound)
if !strings.Contains(r.Location(), "/login") {
t.Errorf("location = %q", r.Location())
}
})
t.Run("reacting with same emoji toggles it off", func(t *testing.T) {
if n := admin.get(issuePath).Count(".reaction-btn"); n == 0 {
t.Fatal("no reaction to toggle")
}
admin.post(issuePath+"/react", url.Values{"emoji": {"👍"}}).mustRedirect(issuePath)
if n := admin.get(issuePath).Count(".reaction-btn"); n != 0 {
t.Errorf("reaction buttons = %d, want 0", n)
}
})
t.Run("react to issue comment", func(t *testing.T) {
id := issuesCommentID(t, admin.get(issuePath), "A follow-up comment.")
admin.post(issuePath+"/react", url.Values{"emoji": {"👍"}, "comment_id": {id}}).
mustRedirect(issuePath)
item := issuesTimelineItem(t, admin.get(issuePath), "A follow-up comment.")
if n := item.Find(".reaction-btn").Length(); n == 0 {
t.Error("comment has no reaction button")
}
})
}
func TestIssueEditing(t *testing.T) {
e, admin, alice := issuesSetup(t)
issuePath := issuesCreate(admin, "my-repo", "Issue to edit", "Original body.")
t.Run("author can edit issue title and body", func(t *testing.T) {
// The title form resubmits the unchanged body alongside the new title.
admin.post(issuePath+"/edit", url.Values{
"title": {"Edited issue title"}, "edit_body": {"Original body."},
}).mustRedirect(issuePath)
if got := admin.get(issuePath).Text(".issue-detail-title"); got != "Edited issue title" {
t.Errorf("title = %q", got)
}
admin.post(issuePath+"/edit", url.Values{
"title": {"Edited issue title"}, "edit_body": {"Updated body text."},
}).mustRedirect(issuePath)
})
t.Run("edited marker appears after editing", func(t *testing.T) {
if n := admin.get(issuePath).Count(".edited-indicator"); n == 0 {
t.Error("no edited indicator")
}
})
t.Run("non-author non-admin cannot edit issue", func(t *testing.T) {
alice.post(issuePath+"/edit", url.Values{"title": {"Hacked title"}, "edit_body": {""}}).
mustStatus(http.StatusForbidden)
})
t.Run("author can edit issue comment", func(t *testing.T) {
admin.post(issuePath+"/comments", url.Values{"body": {"Comment to edit."}}).
mustRedirect(issuePath)
id := issuesCommentID(t, admin.get(issuePath), "Comment to edit.")
admin.post(issuePath+"/comments/"+id+"/edit", url.Values{"edit_body": {"Edited comment text."}}).
mustRedirect(issuePath)
bodies := admin.get(issuePath).Texts(".timeline-body")
if len(bodies) == 0 || !strings.Contains(bodies[len(bodies)-1], "Edited comment text.") {
t.Errorf("last body = %q", bodies)
}
})
t.Run("non-admin user can create an issue", func(t *testing.T) {
p := issuesCreate(alice, "my-repo", "Alice's issue", "")
if got := alice.get(p).Text(".issue-detail-title"); got != "Alice's issue" {
t.Errorf("title = %q", got)
}
})
t.Run("non-admin cannot comment on a closed issue", func(t *testing.T) {
admin.post(issuePath+"/close", nil)
alice.post(issuePath+"/comments", url.Values{"body": {"comment on closed issue"}}).
mustStatus(http.StatusFound)
if contains(admin.get(issuePath).Texts(".timeline-body"), "comment on closed issue") {
t.Error("comment was stored")
}
})
t.Run("cannot edit comment via wrong repo url (cross-repo bypass)", func(t *testing.T) {
e.createRepo(admin, "other-repo")
id := issuesCommentID(t, admin.get(issuePath), "Edited comment text.")
number := idFromPath(t, issuePath)
admin.post("/other-repo/issues/"+number+"/comments/"+id+"/edit",
url.Values{"edit_body": {"cross-repo bypass attempt"}}).
mustStatus(http.StatusNotFound)
if contains(admin.get(issuePath).Texts(".timeline-body"), "cross-repo bypass attempt") {
t.Error("comment was changed")
}
})
t.Run("admin can delete issue", func(t *testing.T) {
admin.post(issuePath+"/delete", nil).mustStatus(http.StatusFound)
admin.get(issuePath).mustStatus(http.StatusNotFound)
})
}
func TestRepoDescription(t *testing.T) {
_, admin, _ := issuesSetup(t)
t.Run("updating repo description is reflected on list page", func(t *testing.T) {
r := issuesSaveSettings(admin, "my-repo",
url.Values{"description": {"A freshly updated description"}})
if !admin.follow(r).Has(".form-success") {
t.Error("no success message")
}
if !contains(admin.get("/").Texts(".repo-description"), "freshly updated description") {
t.Error("description not on list page")
}
})
}
func TestIssueAndPatchTemplates(t *testing.T) {
_, admin, _ := issuesSetup(t)
save := func(t *testing.T, field, value string) {
t.Helper()
r := issuesSaveSettings(admin, "my-repo", url.Values{field: {value}})
if !admin.follow(r).Has(".form-success") {
t.Error("no success message")
}
}
t.Run("issue template can be saved and is prefilled on new issue form", func(t *testing.T) {
save(t, "issue_template", "## Steps to reproduce\n\n## Expected behavior")
body := admin.get("/my-repo/issues/new").Value("[name=body]")
if !strings.Contains(body, "## Steps to reproduce") || !strings.Contains(body, "## Expected behavior") {
t.Errorf("body = %q", body)
}
})
t.Run("patch template can be saved and is prefilled on new patch form", func(t *testing.T) {
save(t, "patch_template", "## Summary\n\n## Testing")
desc := admin.get("/my-repo/patches/new").Value("[name=description]")
if !strings.Contains(desc, "## Summary") || !strings.Contains(desc, "## Testing") {
t.Errorf("description = %q", desc)
}
})
t.Run("clearing the issue template removes prefill", func(t *testing.T) {
save(t, "issue_template", "")
if body := admin.get("/my-repo/issues/new").Value("[name=body]"); body != "" {
t.Errorf("body = %q", body)
}
})
t.Run("clearing the patch template removes prefill", func(t *testing.T) {
save(t, "patch_template", "")
if desc := admin.get("/my-repo/patches/new").Value("[name=description]"); desc != "" {
t.Errorf("description = %q", desc)
}
})
}
Ainternal/web/e2e/labels_test.go
@@ -0,0 +1,379 @@
package e2e
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// labelsPatch is a minimal but complete format-patch file.
func labelsPatch(file string) string {
return strings.Join([]string{
"From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001",
"From: Test User <test@example.com>",
"Date: Mon, 01 Jan 2024 12:00:00 +0000",
"Subject: [PATCH] Add " + file,
"",
"---",
"diff --git a/" + file + " b/" + file,
"new file mode 100644",
"index 0000000..9daeafb",
"--- /dev/null",
"+++ b/" + file,
"@@ -0,0 +1 @@",
"+x",
"",
}, "\n")
}
// labelsCreate posts the label form on the repo settings page.
func labelsCreate(s *session, repo, name, color string) *response {
return s.post("/"+repo+"/settings/labels", url.Values{"name": {name}, "color": {color}})
}
// labelsSettingsID returns the id of the named label, read from its delete
// form on the settings page.
func labelsSettingsID(t *testing.T, s *session, repo, name string) string {
t.Helper()
id := ""
s.get("/" + repo + "/settings").Find(".label-settings-item").Each(func(_ int, sel *goquery.Selection) {
if id == "" && strings.TrimSpace(sel.Find(".label-settings-name").Text()) == name {
id, _ = sel.Find("input[name=id]").Attr("value")
}
})
if id == "" {
t.Fatalf("label %q not found in settings", name)
}
return id
}
// labelsFilterID returns the label id of the named filter checkbox on a list
// page such as /repo/issues.
func labelsFilterID(t *testing.T, s *session, path, name string) string {
t.Helper()
id := ""
s.get(path).Find(".label-filter-item").Each(func(_ int, sel *goquery.Selection) {
if id == "" && strings.Contains(sel.Text(), name) {
id, _ = sel.Find("input[name=labels]").Attr("value")
}
})
if id == "" {
t.Fatalf("label %q not found in filter popup of %s", name, path)
}
return id
}
// labelsItemWith returns the list item whose text contains want.
func labelsItemWith(t *testing.T, r *response, want string) *goquery.Selection {
t.Helper()
var found *goquery.Selection
r.Find(".issue-item").Each(func(_ int, s *goquery.Selection) {
if found == nil && strings.Contains(s.Text(), want) {
found = s
}
})
if found == nil {
t.Fatalf("no list item containing %q", want)
}
return found
}
// labelsTexts returns the trimmed text of every match inside a selection.
func labelsTexts(sel *goquery.Selection, css string) []string {
var out []string
sel.Find(css).Each(func(_ int, s *goquery.Selection) {
out = append(out, strings.TrimSpace(s.Text()))
})
return out
}
func labelsHas(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
func TestLabels(t *testing.T) {
e := newEnv(t)
e.register("alice", "password123")
admin := e.admin()
e.createRepo(admin, "label-repo")
issuePath := issuesCreate(admin, "label-repo", "Labelled issue", "")
patchPath := admin.postMultipart("/label-repo/patches",
url.Values{"title": {"Labelled patch"}},
file{Field: "patch_file", Name: "label-test.patch", Content: []byte(labelsPatch("label-test.txt"))},
).mustRedirect("/label-repo/patches/")
t.Run("create label in repo settings", func(t *testing.T) {
r := admin.follow(labelsCreate(admin, "label-repo", "bug", "#ff0000"))
if !contains(r.Texts(".label-settings-name"), "bug") {
t.Error("label not listed")
}
})
t.Run("create a second label", func(t *testing.T) {
r := admin.follow(labelsCreate(admin, "label-repo", "enhancement", "#00aa00"))
names := r.Texts(".label-settings-name")
if !labelsHas(names, "bug") || !labelsHas(names, "enhancement") {
t.Errorf("labels = %q", names)
}
})
t.Run("duplicate label name is rejected", func(t *testing.T) {
if !admin.follow(labelsCreate(admin, "label-repo", "bug", "#0000ff")).Has(".form-error") {
t.Error("no error message")
}
})
t.Run("non-admin cannot create labels", func(t *testing.T) {
r := e.anon().post("/label-repo/settings/labels",
url.Values{"name": {"nope"}, "color": {"#123456"}})
r.mustStatus(http.StatusFound)
if !strings.Contains(r.Location(), "/login") {
t.Errorf("location = %q", r.Location())
}
})
t.Run("assign label to issue", func(t *testing.T) {
bug := labelsSettingsID(t, admin, "label-repo", "bug")
admin.post(issuePath+"/labels/add", url.Values{"label_id": {bug}}).mustRedirect(issuePath)
if !labelsHas(admin.get(issuePath).Texts(".label-badge"), "bug") {
t.Error("label badge missing")
}
})
t.Run("label appears on issue list", func(t *testing.T) {
item := labelsItemWith(t, admin.get("/label-repo/issues"), "Labelled issue")
if !labelsHas(labelsTexts(item, ".label-badge"), "bug") {
t.Error("label badge missing on list")
}
})
t.Run("filter issues by label shows only matching issues", func(t *testing.T) {
issuesCreate(admin, "label-repo", "Unlabelled issue", "")
id := labelsFilterID(t, admin, "/label-repo/issues", "bug")
titles := admin.get("/label-repo/issues?labels=" + id).Texts(".issue-title")
if !contains(titles, "Labelled issue") || contains(titles, "Unlabelled issue") {
t.Errorf("titles = %q", titles)
}
})
t.Run("filter popup is visible without JS", func(t *testing.T) {
r := admin.get("/label-repo/issues")
if !r.Has("details.label-filter") || !r.Has("details.label-filter summary") {
t.Error("filter popup not rendered")
}
})
t.Run("remove label from issue", func(t *testing.T) {
id := admin.get(issuePath).Attr(".issue-labels-row .label-remove-form input[name=label_id]", "value")
admin.post(issuePath+"/labels/remove", url.Values{"label_id": {id}}).mustRedirect(issuePath)
if labelsHas(admin.get(issuePath).Texts(".issue-labels-row .label-badge"), "bug") {
t.Error("label still attached")
}
})
t.Run("assign label to patch", func(t *testing.T) {
enh := labelsSettingsID(t, admin, "label-repo", "enhancement")
admin.post(patchPath+"/labels/add", url.Values{"label_id": {enh}}).mustRedirect(patchPath)
if !labelsHas(admin.get(patchPath).Texts(".label-badge"), "enhancement") {
t.Error("label badge missing")
}
})
t.Run("label appears on patch list", func(t *testing.T) {
item := labelsItemWith(t, admin.get("/label-repo/patches"), "Labelled patch")
if !labelsHas(labelsTexts(item, ".label-badge"), "enhancement") {
t.Error("label badge missing on list")
}
})
t.Run("filter patches by label", func(t *testing.T) {
id := labelsFilterID(t, admin, "/label-repo/patches", "enhancement")
titles := admin.get("/label-repo/patches?labels=" + id).Texts(".issue-title")
if !contains(titles, "Labelled patch") {
t.Errorf("titles = %q", titles)
}
})
t.Run("remove label from patch", func(t *testing.T) {
id := admin.get(patchPath).Attr(".issue-labels-row .label-remove-form input[name=label_id]", "value")
admin.post(patchPath+"/labels/remove", url.Values{"label_id": {id}}).mustRedirect(patchPath)
if labelsHas(admin.get(patchPath).Texts(".issue-labels-row .label-badge"), "enhancement") {
t.Error("label still attached")
}
})
t.Run("delete label removes it from settings list", func(t *testing.T) {
bug := labelsSettingsID(t, admin, "label-repo", "bug")
r := admin.follow(admin.post("/label-repo/settings/labels/delete", url.Values{"id": {bug}}))
if labelsHas(r.Texts(".label-settings-name"), "bug") {
t.Error("label still listed")
}
})
t.Run("deleted label no longer appears in filter popup", func(t *testing.T) {
if contains(admin.get("/label-repo/issues").Texts(".label-filter-item"), "bug") {
t.Error("deleted label still in filter popup")
}
})
}
func TestUserLabelManagement(t *testing.T) {
e := newEnv(t)
alice := e.register("alice", "password123")
admin := e.admin()
e.createRepo(admin, "ulm-repo")
for _, name := range []string{"bug", "feature"} {
labelsCreate(admin, "ulm-repo", name, "#808080").mustRedirect("/ulm-repo/settings")
}
adminIssuePath := issuesCreate(admin, "ulm-repo", "Admin's issue", "")
aliceIssuePath := issuesCreate(alice, "ulm-repo", "Alice's issue", "")
bugLabelID := labelsFilterID(t, admin, "/ulm-repo/issues", "bug")
checked := "input[name=allow_user_labels][checked]"
t.Run("allow_user_labels checkbox is present in repo settings", func(t *testing.T) {
if n := admin.get("/ulm-repo/settings").Count("input[name=allow_user_labels]"); n != 1 {
t.Errorf("checkbox count = %d", n)
}
})
t.Run("allow_user_labels is off by default", func(t *testing.T) {
if admin.get("/ulm-repo/settings").Has(checked) {
t.Error("checkbox is checked")
}
})
t.Run("label checkboxes not shown to non-admin on new issue form when allow_user_labels is off", func(t *testing.T) {
if n := alice.get("/ulm-repo/issues/new").Count(".label-checkbox-list"); n != 0 {
t.Errorf("label lists = %d", n)
}
})
t.Run("label checkboxes not shown to non-admin on new patch form when allow_user_labels is off", func(t *testing.T) {
if n := alice.get("/ulm-repo/patches/new").Count(".label-checkbox-list"); n != 0 {
t.Errorf("label lists = %d", n)
}
})
t.Run("label checkboxes shown to admin on new issue form regardless of setting", func(t *testing.T) {
if !admin.get("/ulm-repo/issues/new").Has(".label-checkbox-list") {
t.Error("label list missing")
}
})
t.Run("admin can enable allow_user_labels", func(t *testing.T) {
issuesSaveSettings(admin, "ulm-repo", url.Values{"allow_user_labels": {"1"}}).
mustRedirect("/ulm-repo/settings")
if !admin.get("/ulm-repo/settings").Has(checked) {
t.Error("setting did not persist")
}
})
t.Run("label checkboxes shown to non-admin on new issue form when allow_user_labels is on", func(t *testing.T) {
r := alice.get("/ulm-repo/issues/new")
if !r.Has(".label-checkbox-list") {
t.Fatal("label list missing")
}
labels := r.Texts(".label-checkbox-list .label-badge")
if !labelsHas(labels, "bug") || !labelsHas(labels, "feature") {
t.Errorf("labels = %q", labels)
}
})
t.Run("label checkboxes shown to non-admin on new patch form when allow_user_labels is on", func(t *testing.T) {
if !alice.get("/ulm-repo/patches/new").Has(".label-checkbox-list") {
t.Error("label list missing")
}
})
t.Run("non-admin can create issue with label selected", func(t *testing.T) {
p := alice.post("/ulm-repo/issues", url.Values{
"title": {"Issue with label"}, "label_ids": {bugLabelID},
}).mustRedirect("/ulm-repo/issues/")
if !labelsHas(alice.get(p).Texts(".label-badge"), "bug") {
t.Error("label not applied")
}
})
t.Run("non-admin can create patch with label selected", func(t *testing.T) {
featureID := labelsFilterID(t, admin, "/ulm-repo/issues", "feature")
p := alice.postMultipart("/ulm-repo/patches",
url.Values{"title": {"Patch with label"}, "label_ids": {featureID}},
file{Field: "patch_file", Name: "ulm-test.patch", Content: []byte(labelsPatch("ulm-test.txt"))},
).mustRedirect("/ulm-repo/patches/")
if !labelsHas(alice.get(p).Texts(".label-badge"), "feature") {
t.Error("label not applied")
}
})
t.Run("label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)", func(t *testing.T) {
// Post the settings form without allow_user_labels to switch it off.
admin.post("/ulm-repo/settings", url.Values{"description": {""}, "default_branch": {"main"}})
p := alice.post("/ulm-repo/issues", url.Values{
"title": {"Issue sneaking labels"}, "label_ids": {bugLabelID},
}).mustRedirect("/ulm-repo/issues/")
if labelsHas(alice.get(p).Texts(".label-badge"), "bug") {
t.Error("label was applied")
}
admin.post("/ulm-repo/settings", url.Values{
"description": {""}, "default_branch": {"main"}, "allow_user_labels": {"1"},
})
})
t.Run("non-admin can add label to their own issue", func(t *testing.T) {
alice.post(aliceIssuePath+"/labels/add", url.Values{"label_id": {bugLabelID}}).
mustRedirect(aliceIssuePath)
if !labelsHas(alice.get(aliceIssuePath).Texts(".label-badge"), "bug") {
t.Error("label not applied")
}
})
t.Run("non-admin can remove label from their own issue", func(t *testing.T) {
alice.post(aliceIssuePath+"/labels/remove", url.Values{"label_id": {bugLabelID}}).
mustRedirect(aliceIssuePath)
if labelsHas(alice.get(aliceIssuePath).Texts(".issue-labels-row .label-badge"), "bug") {
t.Error("label still attached")
}
})
t.Run("non-admin cannot add label to another user's issue", func(t *testing.T) {
alice.post(adminIssuePath+"/labels/add", url.Values{"label_id": {bugLabelID}}).
mustStatus(http.StatusForbidden)
})
t.Run("unauthenticated user gets 401 adding a label", func(t *testing.T) {
e.anon().post(aliceIssuePath+"/labels/add", url.Values{"label_id": {bugLabelID}}).
mustStatus(http.StatusUnauthorized)
})
t.Run("admin can disable allow_user_labels", func(t *testing.T) {
issuesSaveSettings(admin, "ulm-repo", url.Values{"allow_user_labels": nil}).
mustRedirect("/ulm-repo/settings")
if admin.get("/ulm-repo/settings").Has(checked) {
t.Error("setting still on")
}
})
t.Run("non-admin gets 403 adding label to own issue when allow_user_labels is off", func(t *testing.T) {
alice.post(aliceIssuePath+"/labels/add", url.Values{"label_id": {bugLabelID}}).
mustStatus(http.StatusForbidden)
})
t.Run("label checkboxes hidden on new issue form after allow_user_labels disabled", func(t *testing.T) {
if n := alice.get("/ulm-repo/issues/new").Count(".label-checkbox-list"); n != 0 {
t.Errorf("label lists = %d", n)
}
})
}
Ainternal/web/e2e/pagination_test.go
@@ -0,0 +1,109 @@
package e2e
import (
"net/url"
"strconv"
"strings"
"testing"
)
// pageValidPatch is a minimal but well-formed format-patch file.
var pageValidPatch = strings.Join([]string{
"From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001",
"From: Test User <test@example.com>",
"Date: Mon, 01 Jan 2024 12:00:00 +0000",
"Subject: [PATCH] Add f.txt",
"",
"---",
"diff --git a/f.txt b/f.txt",
"new file mode 100644",
"index 0000000..9daeafb",
"--- /dev/null",
"+++ b/f.txt",
"@@ -0,0 +1 @@",
"+x",
"",
}, "\n")
func TestPagination(t *testing.T) {
e := newEnv(t)
admin := e.admin()
e.createRepo(admin, "paged-repo")
// 21 issues and 21 patches each spill onto a second page at 20 per page.
for i := 1; i <= 21; i++ {
admin.post("/paged-repo/issues", url.Values{
"title": {"Issue number " + strconv.Itoa(i)}, "body": {""},
}).mustRedirect("/paged-repo/issues/")
}
for i := 1; i <= 21; i++ {
admin.postMultipart("/paged-repo/patches",
url.Values{"title": {"Patch number " + strconv.Itoa(i)}},
file{Field: "patch_file", Name: "bulk.patch", Content: []byte(pageValidPatch)},
).mustRedirect("/paged-repo/patches/")
}
t.Run("repo list page 1 shows repos and no pagination when few repos", func(t *testing.T) {
if n := admin.get("/").Count(".repo-name"); n == 0 {
t.Error("no repos listed")
}
})
t.Run("issue list page 1 shows at most 20 items", func(t *testing.T) {
if n := admin.get("/paged-repo/issues").Count(".issue-item"); n > 20 {
t.Errorf("issues on page 1 = %d", n)
}
})
t.Run("issue list pagination nav appears when more than 20 issues", func(t *testing.T) {
if !admin.get("/paged-repo/issues").Has(".pagination") {
t.Error("pagination missing")
}
})
t.Run("issue list page 2 shows remaining issues", func(t *testing.T) {
n := admin.get("/paged-repo/issues?page=2").Count(".issue-item")
if n == 0 || n > 20 {
t.Errorf("issues on page 2 = %d", n)
}
})
t.Run("issue list page 2 prev link goes to page 1", func(t *testing.T) {
href := admin.get("/paged-repo/issues?page=2").Attr(".pagination-prev .pagination-btn", "href")
if !strings.Contains(href, "page=1") {
t.Errorf("prev href = %q", href)
}
})
t.Run("issue list page 1 next link goes to page 2", func(t *testing.T) {
href := admin.get("/paged-repo/issues").Attr(".pagination-next .pagination-btn", "href")
if !strings.Contains(href, "page=2") {
t.Errorf("next href = %q", href)
}
})
t.Run("patch list page 1 shows at most 20 items", func(t *testing.T) {
if n := admin.get("/paged-repo/patches").Count(".issue-item"); n > 20 {
t.Errorf("patches on page 1 = %d", n)
}
})
t.Run("patch list pagination nav appears when more than 20 patches", func(t *testing.T) {
if !admin.get("/paged-repo/patches").Has(".pagination") {
t.Error("pagination missing")
}
})
t.Run("patch list page 2 shows remaining patches", func(t *testing.T) {
if n := admin.get("/paged-repo/patches?page=2").Count(".issue-item"); n == 0 {
t.Error("no patches on page 2")
}
})
t.Run("commit log with few commits shows no cursor nav", func(t *testing.T) {
// paged-repo has no commits, so there is nothing to paginate.
if n := admin.get("/paged-repo").Count(".commit-cursor-nav"); n != 0 {
t.Errorf("commit-cursor-nav count = %d", n)
}
})
}
Ainternal/web/e2e/patches_test.go
@@ -0,0 +1,563 @@
package e2e
import (
"net/http"
"net/url"
"os"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// patchFile is the upload part every patch form expects.
func patchFile(content string) file {
return file{Field: "patch_file", Name: "test.patch", Content: []byte(content)}
}
// patchCreate uploads a new patch and returns the response of the form POST.
func patchCreate(s *session, repo, title, description, content string) *response {
return s.postMultipart("/"+repo+"/patches",
url.Values{"title": {title}, "description": {description}},
patchFile(content))
}
// patchFilterText returns the elements matching sel whose text contains sub.
func patchFilterText(r *response, sel, sub string) *goquery.Selection {
return r.Find(sel).FilterFunction(func(_ int, s *goquery.Selection) bool {
return strings.Contains(s.Text(), sub)
})
}
// patchGitLog reads one formatted field of the newest commit.
func patchGitLog(t *testing.T, repoDir, format string) string {
t.Helper()
return gitRun(t, repoDir, "log", "-1", "--format="+format)
}
// Adds a new file — applies cleanly to my-repo.
const cleanPatch = `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001
From: Test User <test@example.com>
Date: Mon, 01 Jan 2024 12:00:00 +0000
Subject: [PATCH] Add patch-test.txt
---
diff --git a/patch-test.txt b/patch-test.txt
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/patch-test.txt
@@ -0,0 +1 @@
+patch test content
`
// References non-existent lines in README.md — always conflicts.
const conflictPatch = `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001
From: Test User <test@example.com>
Date: Mon, 01 Jan 2024 12:00:00 +0000
Subject: [PATCH] Modify README
---
diff --git a/README.md b/README.md
index abc1234..def5678 100644
--- a/README.md
+++ b/README.md
@@ -50,3 +50,3 @@
nonexistent context line
-nonexistent old line
+nonexistent new line
`
// Adds another new file — for testing close flow.
const closePatch = `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001
From: Test User <test@example.com>
Date: Mon, 01 Jan 2024 12:00:00 +0000
Subject: [PATCH] Add patch-close.txt
---
diff --git a/patch-close.txt b/patch-close.txt
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/patch-close.txt
@@ -0,0 +1 @@
+close test
`
// Adds upload-test.txt — applies cleanly to my-repo.
const uploadTestPatch = `From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001
From: Original Author <original@example.com>
Date: Wed, 03 Jan 2024 10:00:00 +0000
Subject: [PATCH] Add upload-test.txt
---
diff --git a/upload-test.txt b/upload-test.txt
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/upload-test.txt
@@ -0,0 +1 @@
+upload test
`
// Replacement: different author, same diff target.
const replacementPatch = `From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001
From: Replaced Author <replaced@example.com>
Date: Thu, 04 Jan 2024 10:00:00 +0000
Subject: [PATCH] Add upload-test.txt (v2)
---
diff --git a/upload-test.txt b/upload-test.txt
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/upload-test.txt
@@ -0,0 +1 @@
+upload test v2
`
func TestPatches(t *testing.T) {
e := newEnv(t)
e.register("alice", "password123")
admin := e.admin()
e.createRepo(admin, "my-repo")
e.seedRepo("my-repo", nil)
var cleanURL, conflictURL, closeURL, uploadTestURL string
t.Run("reject file without patch markers", func(t *testing.T) {
r := admin.postMultipart("/my-repo/patches", url.Values{"title": {"Bad patch"}},
file{Field: "patch_file", Name: "not-a-patch.txt", Content: []byte("this is just plain text")})
if !strings.Contains(r.Text(".form-error"), "valid patch") {
t.Errorf("error = %q", r.Text(".form-error"))
}
})
t.Run("reject patch missing Subject header", func(t *testing.T) {
r := patchCreate(admin, "my-repo", "No subject", "", `From: Test User <test@example.com>
Date: Mon, 01 Jan 2024 12:00:00 +0000
---
diff --git a/f.txt b/f.txt
new file mode 100644
--- /dev/null
+++ b/f.txt
@@ -0,0 +1 @@
+x
`)
if !strings.Contains(r.Text(".form-error"), "Subject") {
t.Errorf("error = %q", r.Text(".form-error"))
}
})
t.Run("reject patch missing From header", func(t *testing.T) {
r := patchCreate(admin, "my-repo", "No from", "", `Date: Mon, 01 Jan 2024 12:00:00 +0000
Subject: [PATCH] Add f.txt
---
diff --git a/f.txt b/f.txt
new file mode 100644
--- /dev/null
+++ b/f.txt
@@ -0,0 +1 @@
+x
`)
if !strings.Contains(r.Text(".form-error"), "From") {
t.Errorf("error = %q", r.Text(".form-error"))
}
})
t.Run("reject patch missing Date header", func(t *testing.T) {
r := patchCreate(admin, "my-repo", "No date", "", `From: Test User <test@example.com>
Subject: [PATCH] Add f.txt
---
diff --git a/f.txt b/f.txt
new file mode 100644
--- /dev/null
+++ b/f.txt
@@ -0,0 +1 @@
+x
`)
if !strings.Contains(r.Text(".form-error"), "Date") {
t.Errorf("error = %q", r.Text(".form-error"))
}
})
t.Run("upload clean patch", func(t *testing.T) {
r := patchCreate(admin, "my-repo", "Add patch-test.txt",
"Adds a file with **markdown** desc.", cleanPatch)
cleanURL = r.mustRedirect("/my-repo/patches/")
if got := admin.get(cleanURL).Text(".issue-detail-title"); got != "Add patch-test.txt" {
t.Errorf("title = %q", got)
}
})
t.Run("changes tab shows commit metadata card", func(t *testing.T) {
r := admin.get(cleanURL + "?tab=changes")
if !r.Has(".commit-card") {
t.Fatal("commit-card missing")
}
if !strings.Contains(r.Text(".commit-card-subject"), "Add patch-test.txt") {
t.Errorf("subject = %q", r.Text(".commit-card-subject"))
}
meta := r.Text(".commit-card-meta")
if !strings.Contains(meta, "Test User") || !strings.Contains(meta, "test@example.com") {
t.Errorf("meta = %q", meta)
}
if !r.Has(".commit-card-meta time") {
t.Error("commit-card-meta time missing")
}
})
t.Run("patch description renders markdown", func(t *testing.T) {
html, err := admin.get(cleanURL).Find(".timeline-body.markdown-body").First().Html()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "<strong>") {
t.Errorf("description html = %q", html)
}
})
t.Run("clean patch shows apply-clean status immediately", func(t *testing.T) {
r := admin.get(cleanURL)
if !r.Has(".apply-result") || !r.Has(".apply-clean") {
t.Error("apply-clean status missing")
}
})
t.Run("merge button appears for clean patch", func(t *testing.T) {
if !admin.get(cleanURL).Has(`form[action*="/merge"] button`) {
t.Error("merge button missing")
}
})
t.Run("patch diff is displayed with highlighted table", func(t *testing.T) {
r := admin.get(cleanURL + "?tab=changes")
if !r.Has(".diff-table") {
t.Error("diff-table missing")
}
if r.Count(".diff-row-add") == 0 {
t.Error("no added diff rows")
}
})
t.Run("patch appears in open list", func(t *testing.T) {
if !contains(admin.get("/my-repo/patches").Texts(".issue-title"), "Add patch-test.txt") {
t.Error("patch not in open list")
}
})
t.Run("unauthenticated user is redirected to login from patch upload", func(t *testing.T) {
e.anon().get("/my-repo/patches/new").mustRedirect("/login")
})
t.Run("upload conflict patch", func(t *testing.T) {
conflictURL = patchCreate(admin, "my-repo", "Conflict patch", "", conflictPatch).
mustRedirect("/my-repo/patches/")
})
t.Run("conflict patch shows apply-conflict status", func(t *testing.T) {
if !admin.get(conflictURL).Has(".apply-conflict") {
t.Error("apply-conflict missing")
}
})
t.Run("merge button absent for conflict patch", func(t *testing.T) {
if n := admin.get(conflictURL).Count(`form[action*="/merge"] button`); n != 0 {
t.Errorf("merge buttons = %d", n)
}
})
t.Run("merge clean patch changes status to merged", func(t *testing.T) {
version := admin.get(cleanURL).Value(`form[action*="/merge"] input[name=version]`)
admin.post(cleanURL+"/merge", url.Values{"version": {version}}).mustRedirect(cleanURL)
if got := admin.get(cleanURL).Text(".patch-badge"); got != "merged" {
t.Errorf("badge = %q", got)
}
})
t.Run("merge uses patch From header as git author", func(t *testing.T) {
dir := e.repoPath("my-repo")
if got := patchGitLog(t, dir, "%aN"); got != "Test User" {
t.Errorf("author name = %q", got)
}
if got := patchGitLog(t, dir, "%aE"); got != "test@example.com" {
t.Errorf("author email = %q", got)
}
if got := patchGitLog(t, dir, "%s"); got != "Add patch-test.txt" {
t.Errorf("subject = %q", got)
}
})
t.Run("merged patch appears in merged list", func(t *testing.T) {
if !contains(admin.get("/my-repo/patches?status=merged").Texts(".issue-title"), "Add patch-test.txt") {
t.Error("patch not in merged list")
}
})
t.Run("upload and close a patch", func(t *testing.T) {
closeURL = patchCreate(admin, "my-repo", "Close me", "", closePatch).
mustRedirect("/my-repo/patches/")
admin.post(closeURL+"/close", nil).mustRedirect(closeURL)
if got := admin.get(closeURL).Text(".patch-badge"); got != "closed" {
t.Errorf("badge = %q", got)
}
})
t.Run("closed patch appears in closed list", func(t *testing.T) {
if !contains(admin.get("/my-repo/patches?status=closed").Texts(".issue-title"), "Close me") {
t.Error("patch not in closed list")
}
})
t.Run("closed patch can be reopened", func(t *testing.T) {
if got := admin.get(closeURL).Text(".patch-badge"); got != "closed" {
t.Fatalf("badge = %q", got)
}
admin.post(closeURL+"/close", nil).mustRedirect(closeURL)
if got := admin.get(closeURL).Text(".patch-badge"); got != "open" {
t.Errorf("badge = %q", got)
}
})
t.Run("patch title and description can be edited", func(t *testing.T) {
// The title form resubmits the unchanged description.
desc := admin.get(conflictURL).Value(`.title-edit-form [name=edit_description]`)
admin.post(conflictURL+"/edit", url.Values{
"title": {"Edited Conflict Patch"}, "edit_description": {desc},
}).mustRedirect(conflictURL)
if got := admin.get(conflictURL).Text(".issue-detail-title"); got != "Edited Conflict Patch" {
t.Errorf("title = %q", got)
}
// The inline description form resubmits the unchanged title.
title := admin.get(conflictURL).Value(`.inline-edit-form [name=title]`)
admin.post(conflictURL+"/edit", url.Values{
"title": {title}, "edit_description": {"Updated desc"},
}).mustRedirect(conflictURL)
if !strings.Contains(admin.get(conflictURL).Text(".timeline-body"), "Updated desc") {
t.Error("description not updated")
}
})
t.Run("patch comment: add and edit", func(t *testing.T) {
admin.post(conflictURL+"/comments", url.Values{"body": {"My patch comment"}}).
mustRedirect(conflictURL)
r := admin.get(conflictURL)
bodies := r.Texts(".timeline-body")
if len(bodies) == 0 || !strings.Contains(bodies[len(bodies)-1], "My patch comment") {
t.Fatalf("comment bodies = %q", bodies)
}
// Edit the comment through its own edit form.
action, ok := patchFilterText(r, ".timeline-item", "My patch comment").
Find(".inline-edit-form").First().Attr("action")
if !ok {
t.Fatal("comment edit form missing")
}
admin.post(action, url.Values{"edit_body": {"Edited patch comment"}}).mustRedirect(conflictURL)
bodies = admin.get(conflictURL).Texts(".timeline-body")
if !strings.Contains(bodies[len(bodies)-1], "Edited patch comment") {
t.Errorf("comment bodies = %q", bodies)
}
})
t.Run("patch reaction on description", func(t *testing.T) {
// The picker of the first timeline item reacts on the description.
emoji := admin.get(conflictURL).
Find(".timeline-item").First().
Find(".reaction-picker-dropdown input[name=emoji]").First().AttrOr("value", "")
if emoji == "" {
t.Fatal("reaction picker empty")
}
admin.post(conflictURL+"/react", url.Values{"emoji": {emoji}}).mustRedirect(conflictURL)
if got := admin.get(conflictURL).Text(".reaction-btn"); !strings.ContainsAny(got, "0123456789") {
t.Errorf("reaction button = %q", got)
}
})
t.Run("admin can delete a patch", func(t *testing.T) {
r := admin.post(conflictURL+"/delete", nil)
r.mustStatus(http.StatusFound)
if !strings.Contains(r.Location(), "/patches") {
t.Errorf("location = %q", r.Location())
}
admin.get(conflictURL).mustStatus(http.StatusNotFound)
})
// ── Patch file re-upload & version protection ──────────────────────────
t.Run("create patch for re-upload tests", func(t *testing.T) {
uploadTestURL = patchCreate(admin, "my-repo", "Upload test patch", "", uploadTestPatch).
mustRedirect("/my-repo/patches/")
})
t.Run("upload patch file button is visible for admin on open patch", func(t *testing.T) {
if n := admin.get(uploadTestURL).Count("details:has([name=patch_file])"); n != 1 {
t.Errorf("upload details = %d", n)
}
})
t.Run("non-author non-admin cannot upload patch file", func(t *testing.T) {
alice := e.login("alice", "password123")
alice.postMultipart(uploadTestURL+"/upload", nil, patchFile(uploadTestPatch)).
mustStatus(http.StatusForbidden)
})
t.Run("upload button hidden for non-author non-admin", func(t *testing.T) {
alice := e.login("alice", "password123")
if n := alice.get(uploadTestURL).Count("details:has([name=patch_file])"); n != 0 {
t.Errorf("upload details = %d", n)
}
})
t.Run("admin can upload replacement patch file", func(t *testing.T) {
admin.postMultipart(uploadTestURL+"/upload", nil, patchFile(replacementPatch)).
mustRedirect(uploadTestURL)
})
t.Run("merge fails when version token is stale", func(t *testing.T) {
stalePatch := `From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001
From: Test User <test@example.com>
Date: Fri, 05 Jan 2024 10:00:00 +0000
Subject: [PATCH] Add stale-version.txt
---
diff --git a/stale-version.txt b/stale-version.txt
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/stale-version.txt
@@ -0,0 +1 @@
+stale
`
stalePatchV2 := strings.ReplaceAll(
strings.ReplaceAll(stalePatch, "Add stale-version.txt", "Add stale-version.txt (v2)"),
"+stale", "+stale v2",
)
staleURL := patchCreate(admin, "my-repo", "Stale version test", "", stalePatch).
mustRedirect("/my-repo/patches/")
// The version the admin sees on the page.
staleVersion := admin.get(staleURL).Value(`form[action*="/merge"] input[name=version]`)
// The author uploads a new patch file, bumping the version.
admin.postMultipart(staleURL+"/upload", nil, patchFile(stalePatchV2)).mustRedirect(staleURL)
r := admin.post(staleURL+"/merge", url.Values{"version": {staleVersion}})
r.mustStatus(http.StatusConflict)
if !r.Contains("updated") {
t.Errorf("body = %q", r.BodyString())
}
check := admin.get(staleURL).mustStatus(http.StatusOK)
if !check.Contains("open") {
t.Error("patch is no longer open")
}
})
t.Run("merge succeeds with current version token after replacement upload", func(t *testing.T) {
version := admin.get(uploadTestURL).Value(`form[action*="/merge"] input[name=version]`)
admin.post(uploadTestURL+"/merge", url.Values{"version": {version}}).mustRedirect(uploadTestURL)
if got := admin.get(uploadTestURL).Text(".patch-badge"); got != "merged" {
t.Errorf("badge = %q", got)
}
if got := patchGitLog(t, e.repoPath("my-repo"), "%aN"); got != "Replaced Author" {
t.Errorf("author name = %q", got)
}
})
t.Run("upload patch file button hidden on merged patch", func(t *testing.T) {
if n := admin.get(uploadTestURL).Count("details:has([name=patch_file])"); n != 0 {
t.Errorf("upload details = %d", n)
}
})
t.Run("POST to upload on merged patch returns 400", func(t *testing.T) {
admin.postMultipart(uploadTestURL+"/upload", nil, patchFile(uploadTestPatch)).
mustStatus(http.StatusBadRequest)
})
}
// TestCommitSigning checks the signature of a patch merged by the server.
// It repeats the merge the patches suite did, because each test gets a fresh
// server and data directory.
func TestCommitSigning(t *testing.T) {
e := newEnv(t)
admin := e.admin()
e.createRepo(admin, "my-repo")
e.seedRepo("my-repo", nil)
patchURL := patchCreate(admin, "my-repo", "Add patch-test.txt", "", cleanPatch).
mustRedirect("/my-repo/patches/")
version := admin.get(patchURL).Value(`form[action*="/merge"] input[name=version]`)
admin.post(patchURL+"/merge", url.Values{"version": {version}}).mustRedirect(patchURL)
repoDir := e.repoPath("my-repo")
hashOf := func(grep string) string {
t.Helper()
h := gitRun(t, repoDir, "log", "--format=%H", "--grep="+grep, "-1")
if h == "" {
t.Fatalf("no commit matching %q", grep)
}
return h
}
t.Run("allowed_signers file is generated at startup", func(t *testing.T) {
content, err := os.ReadFile(e.Cfg.AllowedSignersPath())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), `namespaces="git"`) ||
!strings.Contains(string(content), "ssh-ed25519") {
t.Errorf("allowed_signers = %q", content)
}
})
t.Run("merged commit has a gpgsig header", func(t *testing.T) {
obj := gitRun(t, repoDir, "cat-file", "-p", hashOf("Add patch-test.txt"))
if !strings.Contains(obj, "gpgsig") {
t.Error("gpgsig header missing")
}
})
t.Run("unsigned commits have no gpgsig header", func(t *testing.T) {
obj := gitRun(t, repoDir, "cat-file", "-p", hashOf("Initial commit"))
if strings.Contains(obj, "gpgsig") {
t.Error("gpgsig header present on unsigned commit")
}
})
t.Run("commit log shows verified badge on signed commit", func(t *testing.T) {
r := admin.get("/my-repo/commits/main")
item := patchFilterText(r, ".commit-item", "Add patch-test.txt")
if item.Find(".sig-badge.verified").Length() == 0 {
t.Error("verified badge missing")
}
})
t.Run("commit log shows no sig badge on unsigned commit", func(t *testing.T) {
r := admin.get("/my-repo/commits/main")
item := patchFilterText(r, ".commit-item", "Initial commit")
if n := item.Find(".sig-badge").Length(); n != 0 {
t.Errorf("sig badges = %d", n)
}
})
t.Run("commit detail shows verified signature row for signed commit", func(t *testing.T) {
r := admin.get("/my-repo/commit/" + hashOf("Add patch-test.txt"))
row := patchFilterText(r, ".commit-card-meta-row", "Signature")
if row.Length() == 0 {
t.Fatal("signature row missing")
}
if row.Find(".sig-badge.verified").Length() == 0 {
t.Error("verified badge missing")
}
})
t.Run("commit detail shows no signature row for unsigned commit", func(t *testing.T) {
r := admin.get("/my-repo/commit/" + hashOf("Initial commit"))
if n := patchFilterText(r, ".commit-card-meta-row", "Signature").Length(); n != 0 {
t.Errorf("signature rows = %d", n)
}
})
}
Ainternal/web/e2e/releases_test.go
@@ -0,0 +1,254 @@
package e2e
import (
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
"testing"
)
// releaseCreate posts the multipart create form of the release page.
func releaseCreate(s *session, repo string, fields url.Values, files ...file) *response {
return s.postMultipart("/"+repo+"/releases", fields, files...)
}
// releaseTagged builds the form fields of a release that also creates a tag.
func releaseTagged(name, tag string, extra ...string) url.Values {
f := url.Values{
"name": {name}, "create_tag": {"on"}, "tag_name": {tag}, "revision": {"main"},
}
for i := 0; i+1 < len(extra); i += 2 {
f.Set(extra[i], extra[i+1])
}
return f
}
func TestReleases(t *testing.T) {
e := newEnv(t)
e.register("alice", "password123")
admin := e.admin()
alice := e.login("alice", "password123")
// A dedicated repo with at least one commit.
e.createRepo(admin, "releases-repo")
e.seedRepo("releases-repo", nil)
var releaseURL, srcReleaseURL, assetReleaseURL string
// ── Navigation ──────────────────────────────────────────────────────────
t.Run("releases tab visible in repo nav", func(t *testing.T) {
if !contains(admin.get("/releases-repo").Texts(".repo-tab"), "Releases") {
t.Error("releases tab missing")
}
})
t.Run("releases list shows empty state when no releases", func(t *testing.T) {
if !admin.get("/releases-repo/releases").Has(".empty-state") {
t.Error("empty state missing")
}
})
// ── Access control ──────────────────────────────────────────────────────
t.Run("non-admin cannot access /releases/new", func(t *testing.T) {
alice.get("/releases-repo/releases/new").mustStatus(http.StatusForbidden)
})
t.Run("non-admin POST to /releases returns 403", func(t *testing.T) {
releaseCreate(alice, "releases-repo", url.Values{"name": {"Test"}, "tag_name": {"v0.1.0"}}).
mustStatus(http.StatusForbidden)
})
t.Run("unauthenticated user is redirected to login from /releases/new", func(t *testing.T) {
e.anon().get("/releases-repo/releases/new").mustRedirect("/login")
})
// ── Validation ──────────────────────────────────────────────────────────
t.Run("missing release title shows error", func(t *testing.T) {
r := releaseCreate(admin, "releases-repo", nil)
if !r.Contains("Release title is required") {
t.Errorf("body = %q", r.Text(".form-error"))
}
})
t.Run("create_tag checked but no tag name shows error", func(t *testing.T) {
r := releaseCreate(admin, "releases-repo", url.Values{"name": {"Test"}, "create_tag": {"on"}})
if !r.Contains("Tag name is required") {
t.Errorf("body = %q", r.Text(".form-error"))
}
})
// ── Create ──────────────────────────────────────────────────────────────
t.Run("create a basic release", func(t *testing.T) {
r := releaseCreate(admin, "releases-repo", releaseTagged("First release", "v1.0.0",
"notes", "Initial stable release.\n\n- Feature A\n- Feature B"))
r.mustStatus(http.StatusFound)
releaseURL = r.mustRedirect("/releases-repo/releases/")
if _, err := strconv.Atoi(idFromPath(t, releaseURL)); err != nil {
t.Errorf("location = %q", releaseURL)
}
})
t.Run("duplicate tag name shows error", func(t *testing.T) {
r := releaseCreate(admin, "releases-repo", releaseTagged("Duplicate", "v1.0.0"))
if !r.Contains("already exists") {
t.Errorf("body = %q", r.Text(".form-error"))
}
})
// ── List ────────────────────────────────────────────────────────────────
t.Run("release appears in list with tag badge", func(t *testing.T) {
r := admin.get("/releases-repo/releases")
if !strings.Contains(r.Text(".release-item-title"), "First release") {
t.Errorf("title = %q", r.Text(".release-item-title"))
}
if !strings.Contains(r.Text(".badge"), "v1.0.0") {
t.Errorf("badge = %q", r.Text(".badge"))
}
})
t.Run("release list shows tag badge", func(t *testing.T) {
if !strings.Contains(admin.get("/releases-repo/releases").Text(".badge"), "v1.0.0") {
t.Error("tag badge missing")
}
})
t.Run("new release button hidden for non-admin", func(t *testing.T) {
if n := alice.get("/releases-repo/releases").Count(`a[href$="/releases/new"]`); n != 0 {
t.Errorf("new release links = %d", n)
}
})
// ── Detail ──────────────────────────────────────────────────────────────
t.Run("release detail shows title and tag badge", func(t *testing.T) {
r := admin.get(releaseURL)
if got := r.Text("h2.page-title"); got != "First release" {
t.Errorf("title = %q", got)
}
if !strings.Contains(r.Text(".badge"), "v1.0.0") {
t.Errorf("badge = %q", r.Text(".badge"))
}
})
t.Run("release notes rendered in detail view", func(t *testing.T) {
if !strings.Contains(admin.get(releaseURL).Text(".markdown-body"), "Initial stable release") {
t.Error("notes missing")
}
})
// ── Source archives ─────────────────────────────────────────────────────
t.Run("create release with source code archives", func(t *testing.T) {
r := releaseCreate(admin, "releases-repo",
releaseTagged("Source release", "v1.1.0", "include_source_code", "on"))
r.mustStatus(http.StatusFound)
srcReleaseURL = r.mustRedirect("/releases-repo/releases/")
})
t.Run("zip and tar.gz archives appear in downloads", func(t *testing.T) {
names := admin.get(srcReleaseURL).Texts(".asset-name")
if !contains(names, ".zip") || !contains(names, ".tar.gz") {
t.Errorf("asset names = %q", names)
}
})
t.Run("source archive download responds with 200", func(t *testing.T) {
r := admin.get(srcReleaseURL)
admin.get(releaseAssetLink(t, r.Texts(".asset-name"), r, ".zip")).mustStatus(http.StatusOK)
})
// ── File upload ─────────────────────────────────────────────────────────
t.Run("create release with attached file", func(t *testing.T) {
r := releaseCreate(admin, "releases-repo", releaseTagged("Asset release", "v1.2.0"),
file{
Field: "files", Name: "release-asset.txt",
Content: []byte("binary-like content for testing\n"),
})
r.mustStatus(http.StatusFound)
assetReleaseURL = r.mustRedirect("/releases-repo/releases/")
})
t.Run("uploaded asset appears in downloads with filename and size", func(t *testing.T) {
r := admin.get(assetReleaseURL)
if !contains(r.Texts(".asset-name"), "release-asset.txt") {
t.Errorf("asset names = %q", r.Texts(".asset-name"))
}
if !r.Has(".asset-size") {
t.Error("asset size missing")
}
})
t.Run("asset download responds with 200", func(t *testing.T) {
r := admin.get(assetReleaseURL)
admin.get(releaseAssetLink(t, r.Texts(".asset-name"), r, "release-asset.txt")).
mustStatus(http.StatusOK)
})
// ── Delete ──────────────────────────────────────────────────────────────
t.Run("non-admin cannot delete a release", func(t *testing.T) {
alice.post(releaseURL+"/delete", nil).mustStatus(http.StatusForbidden)
})
t.Run("admin can delete a release", func(t *testing.T) {
admin.post(releaseURL+"/delete", nil).mustStatus(http.StatusFound)
if contains(admin.get("/releases-repo/releases").Texts(".release-item-title"), "First release") {
t.Error("deleted release still listed")
}
})
// ── Pagination ──────────────────────────────────────────────────────────
t.Run("release list shows at most 20 per page", func(t *testing.T) {
// Bulk-create 25 releases so the total is above one page.
for i := 1; i <= 25; i++ {
releaseCreate(admin, "releases-repo",
releaseTagged("Page test release "+strconv.Itoa(i), "v9."+strconv.Itoa(i)+".0"))
}
if n := admin.get("/releases-repo/releases").Count(".issue-item"); n > 20 {
t.Errorf("items = %d", n)
}
})
t.Run("pagination nav appears with more than 20 releases", func(t *testing.T) {
if !admin.get("/releases-repo/releases").Has(".pagination") {
t.Error("pagination missing")
}
})
t.Run("release list page 2 shows remaining releases", func(t *testing.T) {
n := admin.get("/releases-repo/releases?page=2").Count(".issue-item")
if n == 0 || n > 20 {
t.Errorf("items = %d", n)
}
})
t.Run("source archive tar.zst appears in downloads", func(t *testing.T) {
if _, err := exec.LookPath("zstd"); err != nil {
t.Skip("zstd not available")
}
if !contains(admin.get(srcReleaseURL).Texts(".asset-name"), ".tar.zst") {
t.Error("tar.zst archive missing")
}
})
}
// releaseAssetLink returns the href of the asset link whose name contains sub.
func releaseAssetLink(t *testing.T, names []string, r *response, sub string) string {
t.Helper()
for i, n := range names {
if strings.Contains(n, sub) {
return r.Find(".asset-name").Eq(i).AttrOr("href", "")
}
}
t.Fatalf("no asset matching %q in %q", sub, names)
return ""
}
Ainternal/web/e2e/repos_test.go
@@ -0,0 +1,341 @@
package e2e
import (
"context"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// repoAttrs returns the named attribute of every element matching sel.
func repoAttrs(r *response, sel, name string) []string {
var out []string
r.Find(sel).Each(func(_ int, s *goquery.Selection) {
v, _ := s.Attr(name)
out = append(out, v)
})
return out
}
// repoInitBare creates a bare repository directly on disk, bypassing the web
// form. The startup scan is what registers it.
func repoInitBare(t *testing.T, dir string) {
t.Helper()
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
gitRun(t, filepath.Dir(dir), "init", "--bare", dir)
}
func TestRepos(t *testing.T) {
e := newEnv(t)
admin := e.admin()
alice := e.register("alice", "password123")
// Set by the commit log test and read by every commit detail test.
var commitURL string
t.Run("non-admin gets 403 on /new", func(t *testing.T) {
alice.get("/new").mustStatus(http.StatusForbidden)
})
t.Run("create repository", func(t *testing.T) {
admin.post("/new", url.Values{
"name": {"my-repo"}, "description": {"A test repo"}, "default_branch": {"main"},
}).mustRedirect("/my-repo")
if !admin.get("/my-repo").Has(".empty-state") {
t.Error("empty-state missing on fresh repo")
}
})
t.Run("repository appears in list", func(t *testing.T) {
if names := admin.get("/").Texts(".repo-name"); !contains(names, "my-repo") {
t.Errorf("repo names = %v", names)
}
})
t.Run("search finds matching repo", func(t *testing.T) {
if names := admin.get("/?q=my-repo").Texts(".repo-name"); !contains(names, "my-repo") {
t.Errorf("repo names = %v", names)
}
})
t.Run("search returns empty for unknown term", func(t *testing.T) {
if !admin.get("/?q=zzz-nothing-here").Has(".empty-state") {
t.Error("empty-state missing for unknown search term")
}
})
t.Run("browse file tree after seeding content", func(t *testing.T) {
e.seedRepo("my-repo", nil)
files := admin.get("/my-repo/tree/main").Texts(".file-name a")
if !contains(files, "README.md") || !contains(files, "index.js") {
t.Errorf("files = %v", files)
}
})
t.Run("view file blob with syntax highlighting", func(t *testing.T) {
r := admin.get("/my-repo/blob/main/index.js")
if got := r.Text(".file-blob-name"); got != "index.js" {
t.Errorf("file name = %q", got)
}
if !r.Has(".file-blob-body") {
t.Error("file-blob-body missing")
}
})
t.Run("raw file download responds 200", func(t *testing.T) {
r := admin.get("/my-repo/raw/main/README.md").mustStatus(200)
if cd := r.Header.Get("Content-Disposition"); !strings.Contains(cd, "README.md") {
t.Errorf("Content-Disposition = %q", cd)
}
})
t.Run("raw svg is served as an image under a sandbox policy", func(t *testing.T) {
r := admin.get("/my-repo/raw/main/logo.svg").mustStatus(200)
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") {
t.Errorf("Content-Type = %q", ct)
}
if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "sandbox;") {
t.Errorf("CSP = %q", csp)
}
txt := admin.get("/my-repo/raw/main/README.md")
if csp := txt.Header.Get("Content-Security-Policy"); strings.Contains(csp, "sandbox") {
t.Errorf("text file CSP = %q", csp)
}
})
t.Run("commit log shows initial commit", func(t *testing.T) {
r := admin.get("/my-repo/commits/main")
if subjects := r.Texts(".commit-subject"); !contains(subjects, "Initial commit") {
t.Errorf("subjects = %v", subjects)
}
commitURL = r.Attr(".commit-hash", "href")
if !strings.Contains(commitURL, "/my-repo/commit/") {
t.Fatalf("commit link = %q", commitURL)
}
})
t.Run("commit detail shows metadata card", func(t *testing.T) {
r := admin.get(commitURL)
if !r.Has(".commit-card") {
t.Fatal("commit-card missing")
}
if got := r.Text(".commit-card-subject"); !strings.Contains(got, "Initial commit") {
t.Errorf("subject = %q", got)
}
meta := r.Text(".commit-card-meta")
for _, want := range []string{"Test", "Author", "Date", "Commit"} {
if !strings.Contains(meta, want) {
t.Errorf("meta %q missing %q", meta, want)
}
}
})
t.Run("commit detail full SHA is shown", func(t *testing.T) {
sha := strings.TrimPrefix(commitURL, "/my-repo/commit/")
if got := admin.get(commitURL).Text(".commit-sha-full"); got != sha {
t.Errorf("sha = %q, want %q", got, sha)
}
})
t.Run("commit detail shows file nav sidebar", func(t *testing.T) {
r := admin.get(commitURL)
if !r.Has(".file-nav-details") {
t.Fatal("file-nav-details missing")
}
items := r.Texts(".file-nav-item")
if !contains(items, "README.md") || !contains(items, "index.js") {
t.Errorf("nav items = %v", items)
}
})
t.Run("commit detail file nav items are anchor links to diff sections", func(t *testing.T) {
hrefs := repoAttrs(admin.get(commitURL), ".file-nav-item", "href")
if len(hrefs) == 0 {
t.Fatal("no file nav items")
}
for _, h := range hrefs {
if !strings.HasPrefix(h, "#") {
t.Errorf("href = %q, want anchor", h)
}
}
})
t.Run("commit detail shows diff table with added lines", func(t *testing.T) {
r := admin.get(commitURL)
if !r.Has(".diff-table") {
t.Error("diff-table missing")
}
if n := r.Count(".diff-row-add"); n == 0 {
t.Error("no added rows")
}
if n := r.Count(".diff-row-del"); n != 0 {
t.Errorf("deleted rows = %d, want 0", n)
}
})
t.Run("commit detail diff table has line numbers", func(t *testing.T) {
r := admin.get(commitURL)
if got := r.Text(".diff-row-add .diff-ln-new"); got != "1" {
t.Errorf("first new line number = %q", got)
}
})
t.Run("commit detail shows added stats on file header", func(t *testing.T) {
stats := admin.get(commitURL).Texts(".diff-stat-add")
if len(stats) == 0 {
t.Fatal("no add stats")
}
for _, s := range stats {
if !strings.HasPrefix(s, "+") {
t.Errorf("stat = %q", s)
}
}
})
t.Run("commit detail view-at-sha button links to blob at that commit", func(t *testing.T) {
sha := strings.TrimPrefix(commitURL, "/my-repo/commit/")
href := admin.get(commitURL).Attr(".btn-xs", "href")
if !strings.Contains(href, "/blob/"+sha+"/") {
t.Errorf("href = %q", href)
}
})
t.Run("commit detail view-at-branch button links to blob at default branch", func(t *testing.T) {
r := admin.get(commitURL)
texts := r.Texts(".btn-xs")
if !contains(texts, "@ main") {
t.Fatalf("buttons = %v", texts)
}
found := false
r.Find(".btn-xs").Each(func(_ int, sel *goquery.Selection) {
if !strings.Contains(sel.Text(), "@ main") {
return
}
found = true
href, _ := sel.Attr("href")
if !strings.Contains(href, "/blob/main/") {
t.Errorf("branch button href = %q", href)
}
})
if !found {
t.Error("no @ main button")
}
})
t.Run("commit detail file diff is open by default", func(t *testing.T) {
// Collapsing needs a browser click; only the initial state is checked.
if _, ok := admin.get(commitURL).Find(".diff-file").First().Attr("open"); !ok {
t.Error("diff-file is not open")
}
})
t.Run("commit detail file nav sidebar is open by default", func(t *testing.T) {
if _, ok := admin.get(commitURL).Find(".file-nav-details").First().Attr("open"); !ok {
t.Error("file-nav-details is not open")
}
})
t.Run("readme renders on repo home", func(t *testing.T) {
r := admin.get("/my-repo")
if !r.Has(".readme-header") {
t.Error("readme-header missing")
}
if html, _ := r.Find(".readme-section .markdown-body").Html(); !strings.Contains(html, "my-repo") {
t.Errorf("readme html = %q", html)
}
})
t.Run("private repo hidden from other users", func(t *testing.T) {
r := admin.follow(admin.post("/my-repo/settings", url.Values{"is_private": {"1"}}))
if !r.Has(".form-success") {
t.Fatal("form-success missing after saving settings")
}
alice.get("/my-repo").mustStatus(http.StatusNotFound)
if names := alice.get("/").Texts(".repo-name"); contains(names, "my-repo") {
t.Errorf("private repo listed for alice: %v", names)
}
admin.post("/my-repo/settings", url.Values{}).mustRedirect("/my-repo/settings")
})
t.Run("settings tab visible for admin, hidden for others", func(t *testing.T) {
if !admin.get("/my-repo").Has(`.repo-tab[href$="/settings"]`) {
t.Error("settings tab missing for admin")
}
if n := alice.get("/my-repo").Count(`.repo-tab[href$="/settings"]`); n != 0 {
t.Errorf("settings tabs for alice = %d", n)
}
})
t.Run("create repository with invalid name shows error", func(t *testing.T) {
r := admin.post("/new", url.Values{
"name": {"has spaces!"}, "description": {""}, "default_branch": {"main"},
}).mustStatus(200)
if !r.Contains("Invalid repository name") {
t.Error("error message missing")
}
})
t.Run("auto-scanned repo is private by default", func(t *testing.T) {
const name = "auto-private-repo"
dir := e.repoPath(name)
repoInitBare(t, dir)
work := t.TempDir()
gitRun(t, work, "clone", "-q", dir, ".")
gitRun(t, work, "commit", "-q", "--allow-empty", "-m", "init")
gitRun(t, work, "push", "-q", "origin", "HEAD:main")
// The server adopts repos found on disk at startup, not per request.
if err := e.Srv.SyncRepos(context.Background()); err != nil {
t.Fatal(err)
}
repo, err := e.DB.RepoByName(context.Background(), name)
if err != nil {
t.Fatal(err)
}
if repo == nil || !repo.IsPrivate {
t.Fatalf("repo = %+v, want private", repo)
}
if err := e.DB.DeleteRepoByName(context.Background(), name); err != nil {
t.Fatal(err)
}
os.RemoveAll(dir)
})
t.Run("repo is registered even with a stale config.lock", func(t *testing.T) {
const name = "stale-lock-repo"
dir := e.repoPath(name)
repoInitBare(t, dir)
// Drop core.bare so the startup scan has to attempt a write, then
// block that write with a leftover lock file.
gitRun(t, dir, "config", "--file", filepath.Join(dir, "config"), "--unset", "core.bare")
if err := os.WriteFile(filepath.Join(dir, "config.lock"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := e.Srv.SyncRepos(context.Background()); err != nil {
t.Fatal(err)
}
repo, err := e.DB.RepoByName(context.Background(), name)
if err != nil {
t.Fatal(err)
}
if repo == nil || repo.Name != name {
t.Fatalf("repo = %+v, want %q", repo, name)
}
if err := e.DB.DeleteRepoByName(context.Background(), name); err != nil {
t.Fatal(err)
}
os.RemoveAll(dir)
})
}
Ainternal/web/e2e/settings_test.go
@@ -0,0 +1,298 @@
package e2e
import (
"crypto/ed25519"
"crypto/rand"
"net/url"
"strings"
"testing"
gossh "golang.org/x/crypto/ssh"
)
// settingsPubKey returns a throwaway ed25519 public key in authorized_keys
// form. The TS suite shelled out to ssh-keygen for the same thing.
func settingsPubKey(t *testing.T) string {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
key, err := gossh.NewPublicKey(pub)
if err != nil {
t.Fatal(err)
}
return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key))) + " e2e@hearthforge"
}
// settingsLocation asserts a redirect and returns the decoded Location.
func settingsLocation(t *testing.T, r *response) string {
t.Helper()
r.mustRedirect("")
loc, err := url.QueryUnescape(r.Location())
if err != nil {
t.Fatal(err)
}
return loc
}
func TestSettings(t *testing.T) {
e := newEnv(t)
admin := e.admin()
alice := e.register("alice", "password123")
e.createRepo(admin, "my-repo")
e.seedRepo("my-repo", nil)
testPubKey := settingsPubKey(t)
t.Run("settings", func(t *testing.T) {
t.Run("settings page requires auth", func(t *testing.T) {
e.anon().get("/settings").mustRedirect("/login")
})
t.Run("settings page loads for logged-in user", func(t *testing.T) {
if got := admin.get("/settings").Text("h1.page-title"); got != "Settings" {
t.Errorf("page title = %q", got)
}
})
// ── Password ──────────────────────────────────────────────────────
t.Run("password change with mismatched passwords shows error", func(t *testing.T) {
r := alice.post("/settings/password", url.Values{
"new_password": {"newpass123"}, "confirm_password": {"different456"},
})
if loc := settingsLocation(t, r); !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
t.Run("password change with wrong current password shows error", func(t *testing.T) {
r := alice.post("/settings/password", url.Values{
"current_password": {"wrongpassword"},
"new_password": {"newpass123"}, "confirm_password": {"newpass123"},
})
if loc := settingsLocation(t, r); !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
t.Run("password change too short shows error", func(t *testing.T) {
r := alice.post("/settings/password", url.Values{
"current_password": {"password123"},
"new_password": {"short"}, "confirm_password": {"short"},
})
if loc := settingsLocation(t, r); !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
// ── SSH keys ──────────────────────────────────────────────────────
t.Run("add SSH key with unsupported key type shows error", func(t *testing.T) {
r := admin.post("/settings/ssh-keys", url.Values{
"name": {"Bad key"}, "public_key": {"ssh-invalid AAAABBBBCCCC test@test"},
})
if loc := settingsLocation(t, r); !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
t.Run("add valid SSH key shows success and key appears in list", func(t *testing.T) {
r := admin.post("/settings/ssh-keys", url.Values{
"name": {"My Laptop"}, "public_key": {testPubKey},
})
if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=ssh_key_added") {
t.Errorf("location = %q", loc)
}
if got := admin.get("/settings").Text(".ssh-key-name"); !strings.Contains(got, "My Laptop") {
t.Errorf("ssh key name = %q", got)
}
})
t.Run("add duplicate SSH key shows error", func(t *testing.T) {
r := admin.post("/settings/ssh-keys", url.Values{
"name": {"Duplicate"}, "public_key": {testPubKey},
})
if loc := settingsLocation(t, r); !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
t.Run("delete SSH key removes it from list", func(t *testing.T) {
page := admin.get("/settings")
id := page.Attr(`form[action="/settings/ssh-keys/delete"] input[name=id]`, "value")
if id == "" {
t.Fatal("no ssh key delete form")
}
r := admin.post("/settings/ssh-keys/delete", url.Values{"id": {id}})
if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=ssh_key_deleted") {
t.Errorf("location = %q", loc)
}
if n := admin.get("/settings").Count(".ssh-key-name"); n != 0 {
t.Errorf("ssh keys left = %d", n)
}
})
// ── Admin user management ────────────────────────────────────────
t.Run("admin can create a new user account", func(t *testing.T) {
r := admin.post("/admin/users", url.Values{
"username": {"charlie"}, "password": {"charliepw1"},
})
if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=user_created") {
t.Errorf("location = %q", loc)
}
})
t.Run("admin cannot create duplicate username", func(t *testing.T) {
r := admin.post("/admin/users", url.Values{
"username": {"charlie"}, "password": {"charliepw1"},
})
if loc := settingsLocation(t, r); !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
t.Run("admin cannot create user with invalid username characters", func(t *testing.T) {
r := admin.post("/admin/users", url.Values{
"username": {"bad user!"}, "password": {"password123"},
})
r.mustStatus(302)
if !strings.Contains(r.Location(), "error") {
t.Errorf("location = %q", r.Location())
}
})
t.Run("non-admin gets 403 when creating user", func(t *testing.T) {
alice.post("/admin/users", url.Values{
"username": {"hacker"}, "password": {"password123"},
}).mustStatus(403)
})
t.Run("admin can delete user account", func(t *testing.T) {
r := admin.post("/admin/users/delete", url.Values{"username": {"charlie"}})
r.mustStatus(302)
if !strings.Contains(r.Location(), "success=user_deleted") {
t.Errorf("location = %q", r.Location())
}
})
t.Run("admin cannot delete the admin account", func(t *testing.T) {
r := admin.post("/admin/users/delete", url.Values{"username": {"admin"}})
r.mustStatus(302)
if !strings.Contains(r.Location(), "error") {
t.Errorf("location = %q", r.Location())
}
})
t.Run("settings page has no git identity section", func(t *testing.T) {
r := admin.get("/settings")
if r.Contains("Git Identity") {
t.Error("git identity section present")
}
if r.Has("[name=git_name]") || r.Has("[name=git_email]") {
t.Error("git identity fields present")
}
})
t.Run("git identity route no longer exists", func(t *testing.T) {
admin.post("/settings/git-identity", url.Values{
"git_name": {"Test"}, "git_email": {"test@example.com"},
}).mustStatus(404)
})
})
t.Run("repository deletion", func(t *testing.T) {
e.createRepo(admin, "deleteme-repo")
t.Run("admin can delete repository", func(t *testing.T) {
r := admin.post("/deleteme-repo/settings/delete", nil)
r.mustStatus(302)
if r.Location() != "/" {
t.Errorf("location = %q", r.Location())
}
})
t.Run("deleted repository returns 404", func(t *testing.T) {
admin.get("/deleteme-repo").mustStatus(404)
})
t.Run("deleted repository no longer appears in list", func(t *testing.T) {
for _, name := range admin.get("/").Texts(".repo-name") {
if name == "deleteme-repo" {
t.Error("deleted repo still listed")
}
}
})
t.Run("non-admin cannot delete repository", func(t *testing.T) {
alice.post("/my-repo/settings/delete", nil).mustStatus(403)
})
})
t.Run("repository rename", func(t *testing.T) {
e.createRepo(admin, "renameme-repo")
e.createRepo(admin, "rename-other")
t.Run("rejects invalid name", func(t *testing.T) {
r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"bad name"}})
r.mustStatus(302)
if !strings.Contains(r.Location(), "/renameme-repo/settings?error=") {
t.Errorf("location = %q", r.Location())
}
if loc := settingsLocation(t, r); !strings.Contains(loc, "Invalid") {
t.Errorf("location = %q", loc)
}
})
t.Run("rejects no-op rename", func(t *testing.T) {
r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"renameme-repo"}})
if loc := settingsLocation(t, r); !strings.Contains(loc, "same as the current name") {
t.Errorf("location = %q", loc)
}
})
t.Run("rejects duplicate name", func(t *testing.T) {
r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"rename-other"}})
if loc := settingsLocation(t, r); !strings.Contains(loc, "already taken") {
t.Errorf("location = %q", loc)
}
})
t.Run("non-admin cannot rename", func(t *testing.T) {
alice.post("/renameme-repo/settings/rename", url.Values{"new_name": {"hijack"}}).mustStatus(403)
})
t.Run("admin can rename repository", func(t *testing.T) {
r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"renamed-repo"}})
r.mustStatus(302)
if !strings.Contains(r.Location(), "/renamed-repo/settings?success=") {
t.Errorf("location = %q", r.Location())
}
admin.get("/renameme-repo").mustStatus(404)
admin.get("/renamed-repo").mustStatus(200)
})
})
t.Run("404 handling", func(t *testing.T) {
t.Run("non-existent repository returns 404", func(t *testing.T) {
admin.get("/no-such-repo").mustStatus(404)
})
t.Run("non-existent issue returns 404", func(t *testing.T) {
admin.get("/my-repo/issues/99999").mustStatus(404)
})
t.Run("non-existent commit returns 404", func(t *testing.T) {
admin.get("/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").mustStatus(404)
})
t.Run("non-existent file blob returns 404", func(t *testing.T) {
admin.get("/my-repo/blob/main/no-such-file.txt").mustStatus(404)
})
t.Run("non-existent patch returns 404", func(t *testing.T) {
admin.get("/my-repo/patches/99999").mustStatus(404)
})
t.Run("non-existent release returns 404", func(t *testing.T) {
admin.get("/my-repo/releases/99999").mustStatus(404)
})
})
}
Ainternal/web/e2e/sorting_test.go
@@ -0,0 +1,132 @@
package e2e
import (
"net/url"
"slices"
"strings"
"testing"
)
// sortCard returns the repo card whose name link is exactly name.
func sortCard(r *response, name string) *goquerySel {
var found *goquerySel
r.Find(".repo-card").Each(func(_ int, card *goquerySel) {
if strings.TrimSpace(card.Find(".repo-name").First().Text()) == name {
found = card
}
})
return found
}
func TestSorting(t *testing.T) {
e := newEnv(t)
admin := e.admin()
// sort-aaa is created first (older), sort-zzz second (newer). That lets
// name order and creation order be told apart. my-repo is navigated to in
// the cookie persistence test.
e.createRepo(admin, "my-repo")
e.createRepo(admin, "sort-aaa")
e.createRepo(admin, "sort-zzz")
t.Run("sort dropdown is visible on repo list page", func(t *testing.T) {
opts := admin.get("/").Texts(".repo-sort-select option")
if !contains(opts, "Newest") || !contains(opts, "Name") {
t.Errorf("options = %v", opts)
}
})
t.Run("newest option is selected by default", func(t *testing.T) {
// A fresh session has no repo_sort cookie.
fresh := e.admin()
if got := fresh.get("/").Value(".repo-sort-select"); got != "created" {
t.Errorf("selected sort = %q", got)
}
})
t.Run("Go button is rendered inside noscript and the sort form works", func(t *testing.T) {
// The button's visibility toggling needs a browser; the no-JS path is
// the POST to /sort, which is what is asserted here.
r := admin.get("/")
// The HTML parser keeps noscript content as raw text, so match on it.
raw := r.Find(`form[action="/sort"] noscript`).First().Text()
if !strings.Contains(raw, `type="submit"`) || !strings.Contains(raw, ">Go<") {
t.Errorf("noscript Go button missing: %q", raw)
}
s := e.admin()
s.post("/sort", url.Values{"sort": {"name"}}).mustRedirect("/")
if got := s.get("/").Value(".repo-sort-select"); got != "name" {
t.Errorf("selected sort = %q", got)
}
})
t.Run("selecting name sort sets cookie and persists on next visit", func(t *testing.T) {
s := e.admin()
s.post("/sort", url.Values{"sort": {"name"}}).mustRedirect("/")
if c := s.cookie("repo_sort"); c == nil || c.Value != "name" {
t.Fatalf("repo_sort cookie = %v", c)
}
if got := s.get("/").Value(".repo-sort-select"); got != "name" {
t.Errorf("selected sort = %q", got)
}
// Navigate away and back to confirm the cookie persists.
s.get("/my-repo")
if got := s.get("/").Value(".repo-sort-select"); got != "name" {
t.Errorf("selected sort after navigation = %q", got)
}
})
t.Run("default sort shows newer repo before older repo", func(t *testing.T) {
admin.setCookie("repo_sort", "created")
names := admin.get("/?q=sort-").Texts(".repo-name")
if slices.Index(names, "sort-zzz") >= slices.Index(names, "sort-aaa") {
t.Errorf("names = %v", names)
}
})
t.Run("name sort shows repos in alphabetical order", func(t *testing.T) {
admin.setCookie("repo_sort", "name")
names := admin.get("/?q=sort-").Texts(".repo-name")
admin.setCookie("repo_sort", "created")
if slices.Index(names, "sort-aaa") >= slices.Index(names, "sort-zzz") {
t.Errorf("names = %v", names)
}
})
t.Run("pinning a repo shows pinned badge on list page", func(t *testing.T) {
r := admin.follow(admin.post("/sort-aaa/settings", url.Values{"is_pinned": {"1"}}))
if !r.Has(".form-success") {
t.Fatal("form-success missing")
}
card := sortCard(admin.get("/"), "sort-aaa")
if card == nil {
t.Fatal("sort-aaa card missing")
}
if card.Find(".badge-pinned").Length() == 0 {
t.Error("pinned badge missing")
}
})
t.Run("pinned repo appears before unpinned repos regardless of creation order", func(t *testing.T) {
// sort-zzz is newer, so the default sort would list it first. Pinned
// repos float above that.
names := admin.get("/?q=sort-").Texts(".repo-name")
if slices.Index(names, "sort-aaa") >= slices.Index(names, "sort-zzz") {
t.Errorf("names = %v", names)
}
})
t.Run("unpinning a repo removes the pinned badge", func(t *testing.T) {
r := admin.follow(admin.post("/sort-aaa/settings", url.Values{}))
if !r.Has(".form-success") {
t.Fatal("form-success missing")
}
card := sortCard(admin.get("/"), "sort-aaa")
if card == nil {
t.Fatal("sort-aaa card missing")
}
if n := card.Find(".badge-pinned").Length(); n != 0 {
t.Errorf("pinned badges = %d", n)
}
})
}
Ainternal/web/e2e/validation_test.go
@@ -0,0 +1,186 @@
// Tests for input validation: body size limits, username/password limits,
// tag name validation, and LIKE search wildcard escaping.
package e2e
import (
"net/http"
"net/url"
"strings"
"testing"
)
func TestValidation(t *testing.T) {
e := newEnv(t)
admin := e.admin()
cfg := e.Cfg
e.createRepo(admin, "val-repo")
e.seedRepo("val-repo", nil)
// A baseline issue gives the comment tests a URL.
issueURL := admin.post("/val-repo/issues", url.Values{
"title": {"Baseline issue"}, "body": {"ok"},
}).mustRedirect("/val-repo/issues/")
// ─── Body size limits ──────────────────────────────────────────────────
t.Run("issue body at limit is accepted", func(t *testing.T) {
admin.post("/val-repo/issues", url.Values{
"title": {"Body at limit"}, "body": {strings.Repeat("x", cfg.MaxTextBodyBytes)},
}).mustStatus(http.StatusFound)
})
t.Run("issue body over limit is rejected", func(t *testing.T) {
admin.post("/val-repo/issues", url.Values{
"title": {"Body over limit"}, "body": {strings.Repeat("x", cfg.MaxTextBodyBytes+1)},
}).mustStatus(http.StatusUnprocessableEntity)
})
t.Run("issue title at limit is accepted", func(t *testing.T) {
admin.post("/val-repo/issues", url.Values{
"title": {strings.Repeat("x", cfg.MaxTitleBytes)}, "body": {"ok"},
}).mustStatus(http.StatusFound)
})
t.Run("issue title over limit is rejected", func(t *testing.T) {
admin.post("/val-repo/issues", url.Values{
"title": {strings.Repeat("x", cfg.MaxTitleBytes+1)}, "body": {"ok"},
}).mustStatus(http.StatusUnprocessableEntity)
})
t.Run("issue comment body at limit is accepted", func(t *testing.T) {
admin.post(issueURL+"/comments", url.Values{
"body": {strings.Repeat("x", cfg.MaxTextBodyBytes)},
}).mustStatus(http.StatusFound)
})
t.Run("issue comment body over limit is rejected", func(t *testing.T) {
admin.post(issueURL+"/comments", url.Values{
"body": {strings.Repeat("x", cfg.MaxTextBodyBytes+1)},
}).mustStatus(http.StatusUnprocessableEntity)
})
t.Run("patch description at limit is accepted", func(t *testing.T) {
// No patch file, so the business logic rejects it. The schema check
// must still pass, which means anything but 422.
r := admin.post("/val-repo/patches", url.Values{
"title": {"Patch ok"}, "description": {strings.Repeat("x", cfg.MaxTextBodyBytes)},
})
if r.Code == http.StatusUnprocessableEntity {
t.Errorf("status = %d", r.Code)
}
})
t.Run("patch description over limit is rejected", func(t *testing.T) {
admin.post("/val-repo/patches", url.Values{
"title": {"Patch bad"}, "description": {strings.Repeat("x", cfg.MaxTextBodyBytes+1)},
}).mustStatus(http.StatusUnprocessableEntity)
})
t.Run("patch title over limit is rejected", func(t *testing.T) {
admin.post("/val-repo/patches", url.Values{
"title": {strings.Repeat("x", cfg.MaxTitleBytes+1)},
}).mustStatus(http.StatusUnprocessableEntity)
})
// ─── Auth limits ───────────────────────────────────────────────────────
t.Run("username over limit is rejected at registration", func(t *testing.T) {
e.anon().post("/register", url.Values{
"username": {strings.Repeat("u", cfg.MaxUsernameBytes+1)},
"password": {"validpass1"},
"password2": {"validpass1"},
}).mustStatus(http.StatusUnprocessableEntity)
})
t.Run("username at limit is not schema-rejected", func(t *testing.T) {
// A username at exactly the limit passes the schema check. It may
// still fail on uniqueness or format, so only 422 is wrong.
r := e.anon().post("/register", url.Values{
"username": {strings.Repeat("a", cfg.MaxUsernameBytes)},
"password": {"validpass1"},
"password2": {"validpass1"},
})
if r.Code == http.StatusUnprocessableEntity {
t.Errorf("status = %d", r.Code)
}
})
t.Run("password over limit is rejected at registration", func(t *testing.T) {
long := strings.Repeat("p", cfg.MaxPasswordBytes+1)
e.anon().post("/register", url.Values{
"username": {"newuser"}, "password": {long}, "password2": {long},
}).mustStatus(http.StatusUnprocessableEntity)
})
t.Run("new_password over limit is rejected at settings/password", func(t *testing.T) {
long := strings.Repeat("p", cfg.MaxPasswordBytes+1)
admin.post("/settings/password", url.Values{
"current_password": {adminPass}, "new_password": {long}, "confirm_password": {long},
}).mustStatus(http.StatusUnprocessableEntity)
})
// ─── Tag name validation ───────────────────────────────────────────────
for _, tag := range []string{"v1.0.0", "release-2", "1.0+build.1", "v1_alpha"} {
t.Run("valid tag "+tag+" is accepted", func(t *testing.T) {
// 302 on success, 200 with a form error (e.g. tag exists) is also
// fine. Only a 422 schema error is wrong.
r := admin.post("/val-repo/releases", url.Values{
"create_tag": {"on"}, "tag_name": {tag},
"revision": {"main"}, "name": {"Release " + tag},
})
if r.Code == http.StatusUnprocessableEntity {
t.Errorf("status = %d", r.Code)
}
})
}
for _, tag := range []string{"v1.0~1", "tag with space", "v1:2", "v1^2", "ref/head", "v1?", "v1*"} {
t.Run("invalid tag "+tag+" is rejected", func(t *testing.T) {
r := admin.post("/val-repo/releases", url.Values{
"create_tag": {"on"}, "tag_name": {tag},
"revision": {"main"}, "name": {"Release " + tag},
}).mustStatus(http.StatusOK)
if !r.Contains("may only contain") {
t.Error("inline form error missing")
}
})
}
// ─── LIKE wildcard escaping in repo search ─────────────────────────────
t.Run("search for _ returns only repos with literal underscore", func(t *testing.T) {
e.createRepo(admin, "search-under_score")
e.createRepo(admin, "search-nodash")
body := admin.get("/?q=" + url.QueryEscape("_")).BodyString()
if !strings.Contains(body, "search-under_score") {
t.Error("search-under_score missing")
}
for _, bad := range []string{"search-nodash", "val-repo"} {
if strings.Contains(body, bad) {
t.Errorf("body contains %q", bad)
}
}
})
t.Run("search for % returns no repos", func(t *testing.T) {
body := admin.get("/?q=" + url.QueryEscape("%")).BodyString()
for _, bad := range []string{"search-under_score", "search-nodash", "val-repo"} {
if strings.Contains(body, bad) {
t.Errorf("body contains %q", bad)
}
}
})
t.Run("normal substring search still works", func(t *testing.T) {
body := admin.get("/?q=search-under").BodyString()
if !strings.Contains(body, "search-under_score") {
t.Error("search-under_score missing")
}
if strings.Contains(body, "search-nodash") {
t.Error("body contains search-nodash")
}
})
}
Dtests/bun.lock-26
@@ -1,26 +0,0 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "hearthforge-tests",
"devDependencies": {
"@types/bun": "^1.4.0",
"playwright": "^1.58.2",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
}
}
Dtests/bunfig.toml-8
@@ -1,8 +0,0 @@
[test]
# The preload repoints DATA_DIR at a per-worker directory before any test
# module runs. It lives here as well as in the "test" script so that a bare
# `bun test` cannot run against the real ./data.
preload = ["./preload.ts"]
# Note: `parallel` and `isolate` are CLI-only. Use `bun run test` for the
# whole suite, otherwise the e2e files share one data directory.
Dtests/e2e.auth.test.ts-126
@@ -1,126 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
logout,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
// ─── Auth ─────────────────────────────────────────────────────────────────────
describe('auth', () => {
test('homepage loads', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(BASE);
expect(await page.title()).toContain('Hearthforge');
} finally { await ctx.close(); }
});
test('wrong password shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/login`);
await page.fill('[name=username]', 'admin');
await page.fill('[name=password]', 'wrongpassword');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Invalid');
} finally { await ctx.close(); }
});
test('correct credentials redirect to homepage', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page);
expect(page.url()).toBe(BASE + '/');
expect(await page.locator('.nav-user').isVisible()).toBe(true);
} finally { await ctx.close(); }
});
test('register new user', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
await page.waitForURL(BASE + '/');
expect(await page.locator('.nav-user').textContent()).toBe('alice');
} finally { await ctx.close(); }
});
test('register with mismatched passwords shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'bob');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'different456');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('match');
} finally { await ctx.close(); }
});
test('register with duplicate username shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice'); // already registered above
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('taken');
} finally { await ctx.close(); }
});
test('cross-origin POST is rejected (CSRF defense)', async () => {
const ctx = await browser.newContext();
try {
// Forge an Origin from a different host; server should refuse the POST.
const resp = await ctx.request.post(`${BASE}/login`, {
headers: { Origin: 'http://evil.example' },
form: { username: 'admin', password: ADMIN_PASS },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
// Other tests (login, register) cover the same-origin success path.
} finally { await ctx.close(); }
});
test('logout clears session', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page);
await logout(page);
expect(await page.locator('.nav-user').count()).toBe(0);
expect(await page.locator('a[href="/login"]').isVisible()).toBe(true);
} finally { await ctx.close(); }
});
});
Dtests/e2e.ci.test.ts-1811
@@ -1,1811 +0,0 @@
/**
* CI pipeline E2E tests.
*
* Uses a mock Docker API server (Bun.serve over a Unix socket) so no real
* Docker/Podman installation is required. The mock handles every endpoint
* the CI service calls and lets individual tests queue custom exec responses
* (output + exit code) to simulate success, failure, and specific log output.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
import { chromium, type Browser, type BrowserContext } from "playwright";
import { existsSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import path from "node:path";
import {
BASE,
ADMIN_PASS,
DATA_DIR,
setupTestEnv,
spawnServer,
killServer,
seedRepo,
getHeadCommit,
login,
db,
} from "./helpers.ts";
// ── Mock Docker server ────────────────────────────────────────────────────────
const SOCKET_PATH = `/tmp/test-docker-ci-${process.pid}.sock`;
interface ExecResp {
output: string;
exitCode: number;
/** Hold the response open, so the caller's timeout can fire. */
delayMs?: number;
}
/** Parse the 512-byte headers of an uncompressed tar. */
function tarHeaders(tar: Uint8Array): Array<{ name: string; uid: number }> {
const dec = new TextDecoder();
const out: Array<{ name: string; uid: number }> = [];
for (let off = 0; off + 512 <= tar.length; ) {
const name = dec.decode(tar.subarray(off, off + 100)).replace(/\0.*$/, "");
if (name === "") break; // end-of-archive padding
const uid = Number.parseInt(
dec.decode(tar.subarray(off + 108, off + 116)).replace(/\0.*$/, "").trim() ||
"0",
8,
);
const size = Number.parseInt(
dec.decode(tar.subarray(off + 124, off + 136)).replace(/\0.*$/, "").trim() ||
"0",
8,
);
out.push({ name, uid });
off += 512 + Math.ceil(size / 512) * 512;
}
return out;
}
function tarEntryNames(tar: Uint8Array): string[] {
return tarHeaders(tar).map((h) => h.name);
}
// Repo archive uploads (PUT /containers/*/archive)
const uploads: Array<{ path: string; bytes: number; body: Uint8Array }> = [];
// Images passed to POST /images/create
const pulls: string[] = [];
// Volumes created, and the ones deleted, so cache pruning can be asserted
const volumesCreated: Array<{ name: string; labels: Record<string, string> }> =
[];
const volumesDeleted: string[] = [];
// Volumes the mock reports as existing for GET /volumes
let volumesOnHost: string[] = [];
// Sizes the mock reports from GET /system/df, keyed by volume name
let volumeUsage: Record<string, { Size: number; RefCount: number }> = {};
// Body of the last POST /containers/create
let lastCreateBody: Record<string, any> | null = null;
// Per-exec-ID response map, populated when exec is created
const execMap = new Map<string, ExecResp>();
// Queue consumed in order when execs are created — allows tests to pre-program
// specific step responses
const execQueue: ExecResp[] = [];
/** Every command run inside a container, in order. */
const execCmds: string[][] = [];
let execCounter = 0;
function queueExec(resp: ExecResp) {
execQueue.push(resp);
}
function resetMock() {
execMap.clear();
execQueue.length = 0;
execCmds.length = 0;
execCounter = 0;
uploads.length = 0;
pulls.length = 0;
volumesCreated.length = 0;
volumesDeleted.length = 0;
volumesOnHost = [];
volumeUsage = {};
lastCreateBody = null;
}
/** Build a Docker multiplexed stream frame from a string. */
function muxFrame(text: string, stream = 1): Uint8Array {
const payload = Buffer.from(text, "utf-8");
const hdr = Buffer.alloc(8);
hdr[0] = stream;
hdr.writeUInt32BE(payload.length, 4);
return Buffer.concat([hdr, payload]);
}
/** Build a minimal tar archive containing one file. */
function makeTar(filename: string, content: string): Uint8Array {
const data = Buffer.from(content, "utf-8");
const hdr = Buffer.alloc(512);
hdr.write(path.basename(filename).slice(0, 100), 0, "ascii");
hdr.write("0000644\0", 100, "ascii"); // mode
hdr.write("0000000\0", 108, "ascii"); // uid
hdr.write("0000000\0", 116, "ascii"); // gid
hdr.write(data.length.toString(8).padStart(11, "0") + "\0", 124, "ascii");
hdr.write("00000000000\0", 136, "ascii"); // mtime
hdr[156] = 0x30; // type flag: regular file
// Checksum: fill with spaces, compute, write back
hdr.fill(0x20, 148, 156);
let sum = 0;
for (let i = 0; i < 512; i++) sum += hdr[i]!;
hdr.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii");
// Pad file content to 512-byte block
const paddedLen = Math.ceil(Math.max(data.length, 1) / 512) * 512;
const padded = Buffer.alloc(paddedLen);
data.copy(padded);
return Buffer.concat([hdr, padded]);
}
let mockServer: ReturnType<typeof Bun.serve>;
function startMockDocker() {
rmSync(SOCKET_PATH, { force: true });
mockServer = Bun.serve({
unix: SOCKET_PATH,
async fetch(req: Request): Promise<Response> {
const p = new URL(req.url).pathname;
const qs = new URL(req.url).searchParams;
// Health check
if (req.method === "GET" && p === "/v1.47/info") {
return Response.json({ ServerVersion: "mock" });
}
// Pull image (streaming, just needs to resolve)
if (req.method === "POST" && p.startsWith("/v1.47/images/create")) {
pulls.push(qs.get("fromImage") ?? "");
return new Response('{"status":"Pull complete"}\n');
}
// Create container
if (req.method === "POST" && /\/containers\/create/.test(p)) {
const body = (await req.json()) as Record<string, any>;
const name = qs.get("name") ?? "mock-ctr-001";
// A copy creates its own source container, so the run's
// container must keep its identity.
if (!name.includes("-copy-")) lastCreateBody = body;
return Response.json({ Id: name });
}
// Start container
if (
req.method === "POST" &&
/\/containers\/[^/]+\/start$/.test(p)
) {
return new Response(null, { status: 204 });
}
// Create exec — pop next queued response and assign to this exec ID
if (
req.method === "POST" &&
/\/containers\/[^/]+\/exec$/.test(p)
) {
execCounter++;
const execId = `mock-exec-${execCounter}`;
const cmd = ((await req.json()) as { Cmd?: string[] }).Cmd;
execCmds.push(cmd ?? []);
execMap.set(
execId,
execQueue.shift() ?? { output: "", exitCode: 0 },
);
return Response.json({ Id: execId });
}
// Start exec — return queued output as mux stream
if (req.method === "POST" && /\/exec\/[^/]+\/start$/.test(p)) {
const id = p.match(/\/exec\/([^/]+)\/start/)![1]!;
const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
if (resp.delayMs) await Bun.sleep(resp.delayMs);
return new Response(
resp.output ? muxFrame(resp.output) : new Uint8Array(0),
);
}
// Inspect exec — return exit code
if (req.method === "GET" && /\/exec\/[^/]+\/json$/.test(p)) {
const id = p.match(/\/exec\/([^/]+)\/json/)![1]!;
const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
return Response.json({ ExitCode: resp.exitCode });
}
// Archive upload (the checkout, and [[copy]] sources)
if (
req.method === "PUT" &&
/\/containers\/[^/]+\/archive/.test(p)
) {
const body = new Uint8Array(await req.arrayBuffer());
uploads.push({
path: qs.get("path") ?? "",
bytes: body.length,
body,
});
return new Response(null, { status: 200 });
}
// Archive (used by publish_file artifact collection)
if (
req.method === "GET" &&
/\/containers\/[^/]+\/archive/.test(p)
) {
const filePath = qs.get("path") ?? "file.txt";
return new Response(
makeTar(path.basename(filePath), "artifact-content-123"),
{ headers: { "Content-Type": "application/x-tar" } },
);
}
// Delete container
if (req.method === "DELETE" && /\/containers\//.test(p)) {
return new Response(null, { status: 204 });
}
// Volume create (used for cache volumes)
if (req.method === "POST" && p === "/v1.47/volumes/create") {
const body = (await req.json()) as {
Name: string;
Labels: Record<string, string>;
};
volumesCreated.push({
name: body.Name,
labels: body.Labels ?? {},
});
return Response.json({ Name: body.Name });
}
// Disk usage (used by the cache size caps)
if (req.method === "GET" && p === "/v1.47/system/df") {
return Response.json({
Volumes: Object.entries(volumeUsage).map(
([Name, UsageData]) => ({ Name, UsageData }),
),
});
}
// Volume list (used by purge cache)
if (req.method === "GET" && p === "/v1.47/volumes") {
return Response.json({
Volumes: volumesOnHost.map((name) => ({ Name: name })),
});
}
// Volume delete
if (req.method === "DELETE" && /\/volumes\//.test(p)) {
volumesDeleted.push(p.split("/").pop() ?? "");
return new Response(null, { status: 204 });
}
return new Response("Not found", { status: 404 });
},
});
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Push a .hearthforge-ci.toml into an existing repo; return the commit SHA. */
function seedCiToml(repoName: string, toml: string): string {
const repoDir = path.join(process.cwd(), DATA_DIR, "repos", `${repoName}.git`);
const tmp = `/tmp/hf-ci-seed-${Date.now()}`;
try {
spawnSync("git", ["clone", repoDir, tmp], { stdio: "ignore" });
spawnSync("git", ["-C", tmp, "config", "user.email", "ci@test.com"], {
stdio: "ignore",
});
spawnSync("git", ["-C", tmp, "config", "user.name", "CI Test"], {
stdio: "ignore",
});
writeFileSync(path.join(tmp, ".hearthforge-ci.toml"), toml);
spawnSync("git", ["-C", tmp, "add", ".hearthforge-ci.toml"], {
stdio: "ignore",
});
spawnSync("git", ["-C", tmp, "commit", "-m", "Add CI config"], {
stdio: "ignore",
});
spawnSync("git", ["-C", tmp, "push", "origin", "HEAD:main"], {
stdio: "ignore",
});
const r = spawnSync(
"git",
["-C", tmp, "rev-parse", "HEAD"],
{ stdio: ["ignore", "pipe", "ignore"] },
);
return r.stdout.toString().trim();
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
/** Poll until a CI run leaves pending/running state, then return its status. */
async function waitForRun(runId: number, timeoutMs = 10_000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const row = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
if (row && row.status !== "pending" && row.status !== "running") {
return row.status;
}
await Bun.sleep(100);
}
throw new Error(`Run ${runId} did not complete within ${timeoutMs}ms`);
}
async function loggedInContext(
browser: Browser,
username = "admin",
password = ADMIN_PASS,
): Promise<BrowserContext> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ── Test setup ────────────────────────────────────────────────────────────────
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
let adminCtx: BrowserContext;
let adminUserId: number;
let ciRepoSha: string; // SHA of commit with .hearthforge-ci.toml
const SIMPLE_TOML = `
image = "debian:latest"
[on]
manual = true
push = ["main"]
[[steps]]
name = "hello"
run_sh = "echo hello"
`;
const ARTIFACT_TOML = `
image = "debian:latest"
work_dir = "/ci"
[on]
manual = true
[[steps]]
name = "build"
run_sh = "echo building"
publish_file = ["/ci/output.txt"]
`;
/**
* Restart the server against a different docker socket, then log back in.
* The Go server reads CI_DOCKER_SOCKET once at startup.
*/
async function restartServer(socketPath: string) {
await killServer(server);
server = await spawnServer({ CI_DOCKER_SOCKET: socketPath });
await adminCtx.close();
adminCtx = await loggedInContext(browser);
}
/**
* Trigger a manual run over HTTP and return its id.
*
* The route always builds HEAD of the default branch, so the caller must have
* seeded the config it wants. `sha` is that expected HEAD; it is checked so a
* stale fixture fails loudly instead of silently running another config.
*/
async function triggerRun(
sha: string,
variableOverrides: Record<string, string> = {},
): Promise<number> {
const head = getHeadCommit("ci-repo");
if (sha !== head) {
throw new Error(`triggerRun: expected HEAD ${sha}, repo HEAD is ${head}`);
}
const res = await adminCtx.request.post(`${BASE}/ci-repo/ci/run`, {
form: variableOverrides,
maxRedirects: 0,
});
const loc = res.headers()["location"];
if (!loc) {
throw new Error(`trigger failed: ${res.status()} ${await res.text()}`);
}
return Number(loc.split("/").pop());
}
/**
* Start a run on a branch other than the default one. The manual trigger
* route always builds the default branch, so push the commit over HTTP and
* let the push trigger create the run, the way a real one is created.
*/
async function pushTriggeredRun(sha: string, branch: string): Promise<number> {
const before = await latestRunId();
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/ci-repo.git`;
const url = `http://admin:${encodeURIComponent(ADMIN_PASS)}@localhost:${new URL(BASE).port}/ci-repo.git`;
const r = spawnSync(
"git",
["-C", repoPath, "push", "--force", url, `${sha}:refs/heads/${branch}`],
{ stdio: ["ignore", "ignore", "pipe"] },
);
if (r.status !== 0) {
throw new Error(`push to ${branch} failed: ${r.stderr?.toString()}`);
}
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const id = await latestRunId();
if (id > before) return id;
await Bun.sleep(100);
}
throw new Error(`push to ${branch} did not create a run`);
}
/** Highest CI run id currently in the database, or 0 when there are none. */
async function latestRunId(): Promise<number> {
const row = await db
.selectFrom("ci_runs")
.select("id")
.orderBy("id", "desc")
.executeTakeFirst();
return (row?.id as number) ?? 0;
}
beforeAll(async () => {
await setupTestEnv();
// The mock socket must exist before the server starts: it reads
// CI_DOCKER_SOCKET once, from the environment.
startMockDocker();
server = await spawnServer({ CI_DOCKER_SOCKET: SOCKET_PATH });
browser = await chromium.launch();
adminCtx = await loggedInContext(browser);
// Get admin user ID
const row = await db
.selectFrom("users")
.select("id")
.where("username", "=", "admin")
.executeTakeFirst();
adminUserId = row!.id;
// Create ci-repo via UI and seed it
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill("[name=name]", "ci-repo");
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/ci-repo`);
} finally {
await page.close();
}
seedRepo("ci-repo");
ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
});
afterAll(async () => {
await adminCtx.close();
await browser.close();
await killServer(server);
mockServer.stop(true);
rmSync(SOCKET_PATH, { force: true });
});
beforeEach(() => {
resetMock();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("pipelines tab", () => {
test("tab is visible in repo nav", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo`);
const tab = page.locator('.repo-tab', { hasText: 'Pipelines' });
expect(await tab.isVisible()).toBe(true);
} finally {
await page.close();
}
});
test("history page shows empty state when no runs", async () => {
// Use a separate repo that has never had a run
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
expect(await page.locator(".empty-state").isVisible()).toBe(true);
expect(await page.locator(".empty-state").textContent()).toContain(
"No pipeline runs yet",
);
} finally {
await page.close();
}
});
test("the run form posts and overrides a declared variable", async () => {
// Regression: an input-less form posts an empty body, and Elysia
// leaves `body` undefined. Indexing it crashed the route whenever the
// config declared a variable. Nothing rendered a var_ input either.
seedCiToml(
"ci-repo",
`
image = "debian:latest"
[on]
manual = true
[variables]
[variables.GREETING]
default = "hello"
description = "What to echo"
[[steps]]
name = "say"
run_sh = "echo $GREETING"
`,
);
queueExec({ output: "hi\n", exitCode: 0 });
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
const trigger = page.locator("details.ci-run-details");
await trigger.locator("summary").click();
const field = page.locator('input[name="var_GREETING"]');
expect(await field.inputValue()).toBe("hello");
await field.fill("goodbye");
await trigger.locator('button[type="submit"]').click();
await page.waitForURL(/\/ci\/\d+$/);
const runId = Number(page.url().split("/").pop());
const row = await db
.selectFrom("ci_runs")
.select("variable_overrides")
.where("id", "=", runId)
.executeTakeFirst();
expect(JSON.parse(row!.variable_overrides!)).toEqual({
GREETING: "goodbye",
});
} finally {
await page.close();
}
});
test("an untouched variable field is not recorded as an override", async () => {
seedCiToml(
"ci-repo",
`
image = "debian:latest"
[on]
manual = true
[variables]
[variables.GREETING]
default = "hello"
[[steps]]
name = "say"
run_sh = "echo $GREETING"
`,
);
queueExec({ output: "hi\n", exitCode: 0 });
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
const trigger = page.locator("details.ci-run-details");
await trigger.locator("summary").click();
await trigger.locator('button[type="submit"]').click();
await page.waitForURL(/\/ci\/\d+$/);
const runId = Number(page.url().split("/").pop());
const row = await db
.selectFrom("ci_runs")
.select("variable_overrides")
.where("id", "=", runId)
.executeTakeFirst();
expect(JSON.parse(row!.variable_overrides ?? "{}")).toEqual({});
} finally {
await page.close();
}
});
test("an empty POST to the run route does not crash", async () => {
// A form with no filled inputs sends no body, and Elysia then leaves
// `body` undefined. Indexing it threw "undefined is not an object".
// The UI no longer produces this shape, so post it directly.
seedCiToml(
"ci-repo",
`
image = "debian:latest"
[on]
manual = true
[variables]
[variables.GREETING]
default = "hello"
[[steps]]
name = "say"
run_sh = "echo $GREETING"
`,
);
queueExec({ output: "hi\n", exitCode: 0 });
const resp = await adminCtx.request.post(`${BASE}/ci-repo/ci/run`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
data: "",
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
});
test("help section is collapsible and contains template download", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
const help = page.locator("details.ci-help");
expect(await help.isVisible()).toBe(true);
await help.locator("summary").click();
const dlLink = page.locator('a[download=".hearthforge-ci.toml"]');
expect(await dlLink.isVisible()).toBe(true);
} finally {
await page.close();
}
});
});
describe("successful run", () => {
let runId: number;
beforeAll(async () => {
queueExec({ output: "hello from mock CI\n", exitCode: 0 });
ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
runId = await triggerRun(ciRepoSha);
await waitForRun(runId);
});
test("run status is success", async () => {
const run = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
expect(run?.status).toBe("success");
});
test("step status is success and log is captured", async () => {
const step = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "hello")
.executeTakeFirst();
expect(step?.status).toBe("success");
expect(step?.log).toContain("hello from mock CI");
});
test("history page shows the completed run", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
expect(
await page.locator(".ci-status-pill.ci-status-success").count(),
).toBeGreaterThan(0);
} finally {
await page.close();
}
});
test("run detail page shows step and log", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
// Setup is a real step and sorts before the config's steps.
const first = await page
.locator(".ci-step")
.first()
.textContent();
expect(first).toContain("pipeline setup");
expect(first).toContain("success");
const hello = page.locator(".ci-step").nth(1);
expect(await hello.textContent()).toContain("hello");
// Open step details to see log
await hello.click();
expect(await page.locator(".ci-step-log").textContent()).toContain(
"hello from mock CI",
);
} finally {
await page.close();
}
});
test("retry re-executes the same run in-place", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
await page.click('button:text("Retry")');
// Should redirect back to the same run URL
await page.waitForURL(`${BASE}/ci-repo/ci/${runId}`);
// Wait for the run to complete (uses default exit 0)
const status = await waitForRun(runId);
expect(status).toBe("success");
// Confirm no new run was created — DB count for this repo should be unchanged
const run = await db
.selectFrom("ci_runs")
.select("id")
.where("id", "=", runId)
.executeTakeFirst();
expect(run?.id).toBe(runId);
} finally {
await page.close();
}
});
});
describe("failing run", () => {
let runId: number;
beforeAll(async () => {
// Step exec: non-zero exit code
queueExec({ output: "build error: file not found\n", exitCode: 1 });
ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
runId = await triggerRun(ciRepoSha);
await waitForRun(runId);
});
test("run status is failure", async () => {
const run = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
expect(run?.status).toBe("failure");
});
test("step status is failure and error log captured", async () => {
const step = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "hello")
.executeTakeFirst();
expect(step?.status).toBe("failure");
expect(step?.log).toContain("build error");
});
test("run detail page shows failure status", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
expect(
await page.locator(".ci-status-pill.ci-status-failure").count(),
).toBeGreaterThan(0);
} finally {
await page.close();
}
});
});
describe("cancel", () => {
test("cancelling a pending run marks it cancelled", async () => {
// Trigger without queuing — run will start and eventually succeed,
// but we cancel immediately before it gets far
ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
const runId = await triggerRun(ciRepoSha);
// Cancel via API before it completes
const resp = await fetch(`${BASE}/ci-repo/ci/${runId}/cancel`, {
method: "POST",
redirect: "manual",
});
expect(resp.status).toBe(302);
// Wait and check final status
const status = await waitForRun(runId);
expect(["cancelled", "success", "failure"]).toContain(status);
// If we got there first, it's cancelled
if (status === "cancelled") {
const run = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
expect(run?.status).toBe("cancelled");
}
});
});
describe("artifacts", () => {
let runId: number;
let artifactId: number;
beforeAll(async () => {
// Seed repo with artifact TOML
const sha = seedCiToml("ci-repo", ARTIFACT_TOML);
// work_dir causes 1 mkdir exec before the step
// defaults: {output:'', exitCode:0} for both
runId = await triggerRun(sha);
await waitForRun(runId);
const artifact = await db
.selectFrom("ci_artifacts")
.select("id")
.where("run_id", "=", runId)
.executeTakeFirst();
artifactId = artifact?.id ?? 0;
});
test("artifact row created in DB", async () => {
const artifacts = await db
.selectFrom("ci_artifacts")
.selectAll()
.where("run_id", "=", runId)
.execute();
expect(artifacts.length).toBe(1);
expect(artifacts[0]!.filename).toBe("output.txt");
});
test("artifact is downloadable via HTTP", async () => {
expect(artifactId).toBeGreaterThan(0);
const resp = await fetch(
`${BASE}/ci-repo/ci/${runId}/artifacts/${artifactId}`,
);
expect(resp.status).toBe(200);
const body = await resp.text();
expect(body).toBe("artifact-content-123");
});
test("run detail page shows artifact list", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
expect(
await page.locator(".ci-artifact-item").count(),
).toBeGreaterThan(0);
expect(
await page.locator(".ci-artifact-name").textContent(),
).toContain("output.txt");
} finally {
await page.close();
}
});
});
describe("badge", () => {
test("badge SVG returns success status after successful run", async () => {
const resp = await fetch(`${BASE}/ci-repo/ci/badge.svg`);
expect(resp.status).toBe(200);
expect(resp.headers.get("Content-Type")).toContain("image/svg+xml");
const body = await resp.text();
expect(body).toContain("<svg");
expect(body).toContain("success");
});
test("badge returns 404 for private repo when not logged in", async () => {
// Create a private repo
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill("[name=name]", "private-ci-repo");
await page.check("[name=is_private]");
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/private-ci-repo`);
} finally {
await page.close();
}
const resp = await fetch(`${BASE}/private-ci-repo/ci/badge.svg`);
expect(resp.status).toBe(404);
});
});
describe("secrets", () => {
test("can add, list, and delete a secret via settings", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/settings`);
// Add secret — scope to the CI secrets form
const secretsForm = page.locator('form[action$="/settings/ci-secrets"]');
await secretsForm.locator('[name=name]').fill("MY_SECRET");
await secretsForm.locator('[name=value]').fill("super-secret-value");
await secretsForm.locator('[name=description]').fill("A test secret");
await secretsForm.locator('button[type=submit]').click();
await page.waitForURL(/settings/);
// Secret name is shown, value masked
expect(await page.locator('code:text("MY_SECRET")').count()).toBe(1);
expect(await page.getByText("●●●●●●").count()).toBeGreaterThan(0);
// Delete it
const deleteBtn = page
.locator(".label-settings-item")
.filter({ hasText: "MY_SECRET" })
.locator('button:text("Delete")');
await deleteBtn.click();
await page.waitForURL(/settings/);
expect(await page.locator('code:text("MY_SECRET")').count()).toBe(0);
} finally {
await page.close();
}
});
test("secret value is masked in step logs", async () => {
// Add secret
await db
.insertInto("ci_secrets")
.values({
repo_id: (await db
.selectFrom("repositories")
.select("id")
.where("name", "=", "ci-repo")
.executeTakeFirstOrThrow()).id,
name: "MASK_ME",
value: "s3cr3t-p4ssw0rd",
})
.execute();
// Step echoes the secret value; mock returns it as output
queueExec({ output: "s3cr3t-p4ssw0rd is the value\n", exitCode: 0 });
ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
const runId = await triggerRun(ciRepoSha);
await waitForRun(runId);
const step = await db
.selectFrom("ci_steps")
.select("log")
.where("run_id", "=", runId)
.where("name", "=", "hello")
.executeTakeFirst();
expect(step?.log).not.toContain("s3cr3t-p4ssw0rd");
expect(step?.log).toContain("[MASKED]");
// Cleanup
await db
.deleteFrom("ci_secrets")
.where("name", "=", "MASK_ME")
.execute();
});
});
describe("per-repo run IDs", () => {
test("repo_run_id is set and increments per repo", async () => {
const runs = await db
.selectFrom("ci_runs")
.select(["id", "repo_run_id"])
.orderBy("id", "asc")
.execute();
// Every run should have a repo_run_id set
for (const run of runs) {
expect(run.repo_run_id).not.toBeNull();
expect(run.repo_run_id).toBeGreaterThan(0);
}
// repo_run_ids within the same repo should be sequential (no gaps, no duplicates)
const ids = runs.map((r) => r.repo_run_id!).sort((a, b) => a - b);
for (let i = 0; i < ids.length; i++) {
expect(ids[i]).toBe(i + 1);
}
});
test("run detail page shows repo-local run number", async () => {
const run = await db
.selectFrom("ci_runs")
.select(["id", "repo_run_id"])
.orderBy("id", "asc")
.executeTakeFirst();
if (!run?.repo_run_id) return;
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${run.id}`);
const heading = await page.locator("h2").first().textContent();
expect(heading).toContain(`#${run.repo_run_id}`);
} finally {
await page.close();
}
});
});
describe("skip reasons", () => {
const SKIP_IF_TOML = `
image = "debian:latest"
[on]
manual = true
[[steps]]
name = "first"
run_sh = "echo first"
[[steps]]
name = "second"
run_if = "false"
run_sh = "echo second"
[[steps]]
name = "third"
run_sh = "echo third"
`;
test("run_if failure sets skip reason in log", async () => {
const sha = seedCiToml("ci-repo", SKIP_IF_TOML);
// first step succeeds, second is skipped via run_if (exitCode 1), third runs
queueExec({ output: "first\n", exitCode: 0 }); // first step
queueExec({ output: "", exitCode: 1 }); // run_if check for second
queueExec({ output: "third\n", exitCode: 0 }); // third step
const runId = await triggerRun(sha);
await waitForRun(runId);
const skipped = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "second")
.executeTakeFirst();
expect(skipped?.status).toBe("skipped");
expect(skipped?.log).toContain("condition not met");
});
test("failed step causes remaining steps to be skipped with reason", async () => {
const sha = seedCiToml("ci-repo", SKIP_IF_TOML);
queueExec({ output: "boom\n", exitCode: 1 }); // first step fails
const runId = await triggerRun(sha);
await waitForRun(runId);
const skipped = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "third")
.executeTakeFirst();
expect(skipped?.status).toBe("skipped");
expect(skipped?.log).toContain("previous step failed");
});
});
describe("docker unavailable", () => {
test("run is marked skipped when docker socket is missing", async () => {
// The server reads CI_DOCKER_SOCKET at startup and has no in-process
// reset, so restart it pointed at a socket that does not exist.
await restartServer("/tmp/no-such-socket.sock");
try {
ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
const runId = await triggerRun(ciRepoSha);
const status = await waitForRun(runId);
expect(status).toBe("skipped");
} finally {
await restartServer(SOCKET_PATH);
}
});
});
describe("manual trigger without on.manual", () => {
const NO_MANUAL_TOML = `
image = "debian:latest"
[on]
push = ["main"]
[[steps]]
name = "hello"
run_sh = "echo hi"
`;
test("manual run is allowed even without manual = true in config", async () => {
const sha = seedCiToml("ci-repo", NO_MANUAL_TOML);
queueExec({ output: "hi\n", exitCode: 0 });
// Trigger directly (the route check was removed)
const runId = await triggerRun(sha);
const status = await waitForRun(runId);
expect(status).toBe("success");
});
test("Run pipeline button is not disabled when toml lacks manual = true", async () => {
const sha = seedCiToml("ci-repo", NO_MANUAL_TOML);
void sha;
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
const btn = page.locator('button:text("Run pipeline")');
expect(await btn.isDisabled()).toBe(false);
} finally {
await page.close();
}
});
});
describe("auto-refresh toggle", () => {
test("Pause refresh button appears on active run and ?refresh=off shows Resume", async () => {
// Trigger a run that won't complete immediately by not pre-queuing output
// (the exec queue will block until the mock returns, which is instant, so
// we just check the in-progress URL before it finishes)
ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
const runId = await triggerRun(ciRepoSha);
const page = await adminCtx.newPage();
try {
// Visit with default refresh (on) — run may still be pending/running
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
// The "Pause refresh" link is shown when run is active and autoRefresh=true
// (It may not be visible if run already completed — that's acceptable)
const pauseLink = page.locator('a:text("Pause refresh")');
const resumeLink = page.locator('a:text("Resume refresh")');
const isPaused = await resumeLink.isVisible();
const isRefreshing = await pauseLink.isVisible();
// One of the two states must be present, or run completed
expect(isPaused || isRefreshing || true).toBe(true); // always passes — existence check
// Visit with ?refresh=off — meta refresh must be absent
await page.goto(`${BASE}/ci-repo/ci/${runId}?refresh=off`);
const metaRefreshCount = await page
.locator('meta[http-equiv="refresh"]')
.count();
expect(metaRefreshCount).toBe(0);
} finally {
await page.close();
}
await waitForRun(runId);
});
});
describe("purge cache", () => {
test("Purge caches button is visible and submits successfully", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
const btn = page.locator('button:text("Purge caches")');
expect(await btn.isVisible()).toBe(true);
await btn.click();
// Should redirect back to CI history
await page.waitForURL(/\/ci-repo\/ci/);
// History page loads without error
expect(await page.locator("h2").textContent()).toContain("Pipelines");
// And reports the outcome to the user
expect(
await page.locator(".form-success, .form-error").textContent(),
).toMatch(/purge/i);
} finally {
await page.close();
}
});
});
describe("repo upload", () => {
const CLONE_TOML = `
image = "debian:latest"
work_dir = "/ci/build"
clone_project_to = "/ci/build/project"
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`;
let cloneSha: string;
beforeAll(() => {
cloneSha = seedCiToml("ci-repo", CLONE_TOML);
});
function trigger(): Promise<number> {
// Sibling tests in this block reseed the config, and the trigger
// route always builds HEAD. Put CLONE_TOML back first.
cloneSha = seedCiToml("ci-repo", CLONE_TOML);
return triggerRun(cloneSha);
}
test("the checkout is uploaded, not bind-mounted", async () => {
const runId = await trigger();
expect(await waitForRun(runId)).toBe("success");
expect(uploads.map((u) => u.path)).toContain("/ci/build/project");
expect(uploads[0]!.bytes).toBeGreaterThan(0);
const binds = JSON.stringify(lastCreateBody?.HostConfig?.Binds ?? []);
expect(binds).not.toContain(DATA_DIR);
});
test("the container never runs git", async () => {
const runId = await trigger();
expect(await waitForRun(runId)).toBe("success");
const ran = execCmds.flat().join(" ");
expect(ran).not.toContain("git");
});
test("a failing checkout fails the run before any step runs", async () => {
queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
queueExec({ output: "mkdir: read-only\n", exitCode: 1 }); // mkdir dest
const runId = await trigger();
expect(await waitForRun(runId)).toBe("failure");
const step = await db
.selectFrom("ci_steps")
.select("status")
.where("run_id", "=", runId)
.where("name", "=", "hello")
.executeTakeFirst();
expect(step?.status).toBe("skipped");
const setup = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "pipeline setup")
.executeTakeFirst();
expect(setup?.status).toBe("failure");
expect(setup?.log).toContain("mkdir: read-only");
});
test("a cache path inside the clone directory is rejected", async () => {
const badSha = seedCiToml(
"ci-repo",
`
image = "debian:latest"
clone_project_to = "/ci/build/project"
cache = ["/ci/build/project/target"]
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`,
);
const runId = await triggerRun(badSha);
expect(await waitForRun(runId)).toBe("failure");
expect(uploads).toHaveLength(0);
const setup = await db
.selectFrom("ci_steps")
.select("log")
.where("run_id", "=", runId)
.where("name", "=", "pipeline setup")
.executeTakeFirst();
expect(setup?.log).toContain("overlaps clone_project_to");
});
test("a cache path above the clone directory is rejected", async () => {
const badSha = seedCiToml(
"ci-repo",
`
image = "debian:latest"
clone_project_to = "/ci/build/project"
cache = ["/ci/build"]
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`,
);
const runId = await triggerRun(badSha);
expect(await waitForRun(runId)).toBe("failure");
expect(uploads).toHaveLength(0);
});
test("a relative clone_project_to is rejected", async () => {
const badSha = seedCiToml(
"ci-repo",
`
image = "debian:latest"
work_dir = "/ci/build"
clone_project_to = "project"
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`,
);
const runId = await triggerRun(badSha);
expect(await waitForRun(runId)).toBe("failure");
const setup = await db
.selectFrom("ci_steps")
.select("log")
.where("run_id", "=", runId)
.where("name", "=", "pipeline setup")
.executeTakeFirst();
expect(setup?.log).toContain("must be an absolute path");
});
test("the upload carries the requested commit", async () => {
const runId = await trigger();
expect(await waitForRun(runId)).toBe("success");
const upload = uploads.find((u) => u.path === "/ci/build/project");
expect(upload).toBeDefined();
// The archive must hold the CI config at the triggered commit, and no
// .git. A dropped commit argument would still produce a valid tar.
const names = tarEntryNames(upload!.body);
expect(names).toContain(".hearthforge-ci.toml");
expect(names.some((n) => n.startsWith(".git/"))).toBe(false);
// git archive writes uid 0 and no entry for the archive root, so the
// destination keeps the mode the container gave it.
for (const h of tarHeaders(upload!.body)) {
expect(h.uid).toBe(0);
expect(h.name).not.toBe("./");
}
});
});
describe("copy from another image", () => {
const COPY_TOML = `
image = "debian:latest"
[on]
manual = true
[[copy]]
image = "docker.io/oven/bun:1.4.0-alpine"
from = "/usr/local/bin/bun"
to = "/usr/local/bin"
[[steps]]
name = "hello"
run_sh = "bun --version"
`;
test("pulls the source image and uploads its files", async () => {
const sha = seedCiToml("ci-repo", COPY_TOML);
queueExec({ output: "", exitCode: 0 }); // mkdir of the copy target
queueExec({ output: "1.4.0\n", exitCode: 0 }); // the step
const runId = await triggerRun(sha);
expect(await waitForRun(runId)).toBe("success");
expect(pulls).toContain("docker.io/oven/bun");
expect(uploads.map((u) => u.path)).toContain("/usr/local/bin");
});
});
describe("always and warn_on_fail", () => {
const FLAGS_TOML = `
image = "debian:latest"
[on]
manual = true
[[steps]]
name = "lint"
run_sh = "make lint"
warn_on_fail = true
[[steps]]
name = "build"
run_sh = "make"
[[steps]]
name = "cleanup"
run_sh = "rm -rf /scratch"
always = true
`;
function status(runId: number, name: string) {
return db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", name)
.executeTakeFirst();
}
async function run(sha: string): Promise<number> {
const runId = await triggerRun(sha);
await waitForRun(runId);
return runId;
}
test("warn_on_fail marks the step and lets the run continue", async () => {
const sha = seedCiToml("ci-repo", FLAGS_TOML);
queueExec({ output: "style nit\n", exitCode: 1 }); // lint
queueExec({ output: "built\n", exitCode: 0 }); // build
queueExec({ output: "", exitCode: 0 }); // cleanup
const runId = await run(sha);
expect((await status(runId, "lint"))?.status).toBe("warning");
expect((await status(runId, "lint"))?.log).toContain("style nit");
expect((await status(runId, "build"))?.status).toBe("success");
const runRow = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
expect(runRow?.status).toBe("warning");
});
test("always runs after a failure, other steps stay skipped", async () => {
const sha = seedCiToml("ci-repo", FLAGS_TOML);
queueExec({ output: "ok\n", exitCode: 0 }); // lint
queueExec({ output: "boom\n", exitCode: 1 }); // build fails
queueExec({ output: "cleaned\n", exitCode: 0 }); // cleanup, always
const runId = await run(sha);
expect((await status(runId, "build"))?.status).toBe("failure");
expect((await status(runId, "cleanup"))?.status).toBe("success");
expect((await status(runId, "cleanup"))?.log).toContain("cleaned");
const runRow = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
expect(runRow?.status).toBe("failure");
});
test("a failing always step keeps the run failed", async () => {
const sha = seedCiToml("ci-repo", FLAGS_TOML);
queueExec({ output: "ok\n", exitCode: 0 }); // lint
queueExec({ output: "boom\n", exitCode: 1 }); // build fails
queueExec({ output: "no\n", exitCode: 1 }); // cleanup also fails
const runId = await run(sha);
expect((await status(runId, "cleanup"))?.status).toBe("failure");
const runRow = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
expect(runRow?.status).toBe("failure");
});
});
describe("duplicate step names", () => {
const DUPES_TOML = `
image = "debian:latest"
[on]
manual = true
[[steps]]
name = "check"
run_sh = "echo one"
[[steps]]
name = "check"
run_sh = "echo two"
`;
test("each occurrence gets its own row, in file order", async () => {
const sha = seedCiToml("ci-repo", DUPES_TOML);
queueExec({ output: "one\n", exitCode: 0 });
queueExec({ output: "two\n", exitCode: 0 });
const runId = await triggerRun(sha);
expect(await waitForRun(runId)).toBe("success");
const rows = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "check")
.orderBy("id", "asc")
.execute();
expect(rows).toHaveLength(2);
expect(rows[0]!.log).toContain("one");
expect(rows[1]!.log).toContain("two");
expect(rows.every((r) => r.status === "success")).toBe(true);
});
test("the second occurrence can fail on its own", async () => {
const sha = seedCiToml("ci-repo", DUPES_TOML);
queueExec({ output: "one\n", exitCode: 0 });
queueExec({ output: "boom\n", exitCode: 1 });
const runId = await triggerRun(sha);
expect(await waitForRun(runId)).toBe("failure");
const rows = await db
.selectFrom("ci_steps")
.select("status")
.where("run_id", "=", runId)
.where("name", "=", "check")
.orderBy("id", "asc")
.execute();
expect(rows.map((r) => r.status)).toEqual(["success", "failure"]);
});
});
describe("timeouts override warn_on_fail", () => {
const TIMEOUT_TOML = `
image = "debian:latest"
[on]
manual = true
[[steps]]
name = "lint"
run_sh = "make lint"
warn_on_fail = true
timeout = 1
[[steps]]
name = "build"
run_sh = "make"
`;
test("a timed-out warn_on_fail step fails the run", async () => {
const sha = seedCiToml("ci-repo", TIMEOUT_TOML);
queueExec({ output: "", exitCode: 0, delayMs: 3000 });
const runId = await triggerRun(sha);
expect(await waitForRun(runId, 20_000)).toBe("failure");
const lint = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "lint")
.executeTakeFirst();
// A timeout destroys the container, so nothing after it can run.
// Reporting that as a warning would hide a dead pipeline.
expect(lint?.status).toBe("failure");
expect(lint?.log).toContain("timed out");
}, 30_000);
});
describe("clear failures are recorded", () => {
const CLEAR_TOML = `
image = "debian:latest"
work_dir = "/ci/build"
clone_project_to = "/ci/build/project"
[on]
manual = true
[[steps]]
name = "first"
run_sh = "false"
[[steps]]
name = "second"
always = true
clear = true
run_sh = "echo hi"
`;
test("a clear failure lands on the step, not the console", async () => {
const sha = seedCiToml("ci-repo", CLEAR_TOML);
queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
queueExec({ output: "", exitCode: 0 }); // mkdir clone_project_to
queueExec({ output: "boom\n", exitCode: 1 }); // first, fails
queueExec({ output: "rm: device busy\n", exitCode: 1 }); // clear
const runId = await triggerRun(sha);
expect(await waitForRun(runId)).toBe("failure");
const second = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "second")
.executeTakeFirst();
expect(second?.status).toBe("failure");
expect(second?.log).toContain("Failed to reset");
expect(second?.log).toContain("device busy");
});
test("a clear step re-extracts the checkout", async () => {
const sha = seedCiToml("ci-repo", CLEAR_TOML);
queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
queueExec({ output: "", exitCode: 0 }); // mkdir clone_project_to
queueExec({ output: "ok\n", exitCode: 0 }); // first
queueExec({ output: "", exitCode: 0 }); // clear: rm -rf
queueExec({ output: "", exitCode: 0 }); // mkdir clone_project_to again
queueExec({ output: "hi\n", exitCode: 0 }); // second
const runId = await triggerRun(sha);
expect(await waitForRun(runId)).toBe("success");
const toProject = uploads.filter((u) => u.path === "/ci/build/project");
expect(toProject.length).toBe(2);
expect(execCmds.flat().join(" ")).not.toContain("git");
});
test("clear removes and recreates the directory in one exec", async () => {
// `rm -rf` can delete the container's WorkingDir. A second exec would
// then fail to chdir before its command starts, with exit 127 and an
// opaque OCI message. Splitting these is the regression.
const sha = seedCiToml(
"ci-repo",
`
image = "debian:latest"
work_dir = "/ci/build"
clone_project_to = "/ci/build"
[on]
manual = true
[[steps]]
name = "first"
run_sh = "true"
[[steps]]
name = "second"
clear = true
run_sh = "echo hi"
`,
);
const runId = await triggerRun(sha);
expect(await waitForRun(runId)).toBe("success");
const removals = execCmds.filter((c) => c.join(" ").includes("rm -rf"));
expect(removals).toHaveLength(1);
expect(removals[0]!.join(" ")).toContain("mkdir -p");
});
});
describe("cache volumes", () => {
const CACHE_TOML = `
image = "debian:latest"
cache = ["/ci/cache/target", "/ci/cache/registry"]
[on]
manual = true
push = ["main", "some-feature"]
[[steps]]
name = "hello"
run_sh = "echo hi"
`;
async function run(sha: string, branch: string): Promise<number> {
const runId =
branch === "main"
? await triggerRun(sha)
: await pushTriggeredRun(sha, branch);
await waitForRun(runId);
return runId;
}
test("two cache paths sharing a prefix get distinct volumes", async () => {
const sha = seedCiToml("ci-repo", CACHE_TOML);
await run(sha, "main");
const names = volumesCreated.map((v) => v.name);
expect(names).toHaveLength(2);
expect(new Set(names).size).toBe(2);
// The path is otherwise unrecoverable from a digest.
expect(volumesCreated.map((v) => v.labels["com.hearthforge.cache-path"]))
.toEqual(["/ci/cache/target", "/ci/cache/registry"]);
});
test("a volume the config no longer names is pruned", async () => {
const sha = seedCiToml("ci-repo", CACHE_TOML);
resetMock();
volumesOnHost = ["hearthforge-ci-cache-leftover-from-an-old-config"];
await run(sha, "main");
expect(volumesDeleted).toEqual([
"hearthforge-ci-cache-leftover-from-an-old-config",
]);
});
test("volumes still in the config survive", async () => {
const sha = seedCiToml("ci-repo", CACHE_TOML);
resetMock();
// Prime the host list with the names this config will create.
await run(sha, "main");
const inUse = volumesCreated.map((v) => v.name);
resetMock();
volumesOnHost = inUse;
await run(sha, "main");
expect(volumesDeleted).toEqual([]);
});
test("a run off the default branch prunes nothing", async () => {
const sha = seedCiToml("ci-repo", CACHE_TOML);
resetMock();
volumesOnHost = ["hearthforge-ci-cache-belongs-to-the-default-branch"];
// The config is read per commit, so pruning from a feature branch
// would delete the default branch's caches.
await run(sha, "some-feature");
expect(volumesDeleted).toEqual([]);
});
});
describe("cache size caps", () => {
const CAPPED_TOML = `
image = "debian:latest"
cache = [{ path = "/ci/cache/target", max_size = "1g" }, "/ci/cache/registry"]
[on]
manual = true
[[steps]]
name = "hello"
run_sh = "echo hi"
`;
async function run(sha: string): Promise<number> {
const runId = await triggerRun(sha);
await waitForRun(runId);
return runId;
}
/** Volume names the config produces, in declaration order. */
async function names(sha: string): Promise<string[]> {
resetMock();
await run(sha);
return volumesCreated.map((v) => v.name);
}
test("an oversized cache is dropped and reported on the run", async () => {
const sha = seedCiToml("ci-repo", CAPPED_TOML);
const [target, registry] = await names(sha);
resetMock();
volumesOnHost = [target!, registry!];
volumeUsage = {
[target!]: { Size: 2 * 1024 ** 3, RefCount: 0 },
[registry!]: { Size: 9 * 1024 ** 3, RefCount: 0 },
};
const runId = await run(sha);
// Only the capped one goes, however large the uncapped one grows.
expect(volumesDeleted).toEqual([target!]);
const step = await db
.selectFrom("ci_steps")
.select("log")
.where("run_id", "=", runId)
.where("name", "=", "cache")
.executeTakeFirst();
expect(step?.log).toContain("/ci/cache/target");
expect(step?.log).toContain("2.0G");
});
test("a cache under its cap survives", async () => {
const sha = seedCiToml("ci-repo", CAPPED_TOML);
const [target, registry] = await names(sha);
resetMock();
volumesOnHost = [target!, registry!];
volumeUsage = { [target!]: { Size: 100, RefCount: 0 } };
await run(sha);
expect(volumesDeleted).toEqual([]);
});
test("a cache a concurrent run holds is left alone", async () => {
const sha = seedCiToml("ci-repo", CAPPED_TOML);
const [target, registry] = await names(sha);
resetMock();
volumesOnHost = [target!, registry!];
volumeUsage = { [target!]: { Size: 9 * 1024 ** 3, RefCount: 1 } };
await run(sha);
expect(volumesDeleted).toEqual([]);
});
test("an unmeasured cache is never dropped", async () => {
const sha = seedCiToml("ci-repo", CAPPED_TOML);
const [target, registry] = await names(sha);
resetMock();
volumesOnHost = [target!, registry!];
// Docker reports -1 for a size it has not computed.
volumeUsage = { [target!]: { Size: -1, RefCount: 0 } };
const runId = await run(sha);
expect(volumesDeleted).toEqual([]);
const step = await db
.selectFrom("ci_steps")
.select("id")
.where("run_id", "=", runId)
.where("name", "=", "cache")
.executeTakeFirst();
expect(step).toBeUndefined();
});
});
Dtests/e2e.csrf.test.ts-173
@@ -1,173 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
} from './helpers.ts';
let server: Awaited<ReturnType<typeof spawnServer>>;
// The Go server derives PUBLIC_HTTPS / PUBLIC_ORIGIN from BASE_URL at
// startup, so the HTTPS-mode block restarts the server with a different
// BASE_URL rather than mutating config in process.
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
});
afterAll(async () => {
await killServer(server);
});
// `bun:test` runs describe blocks in source order, so the dev-mode block runs
// first against the default BASE_URL, then we restart in HTTPS mode.
describe('CSRF / Secure cookie — dev mode (http BASE_URL)', () => {
test('starts in dev mode (no HSTS header)', async () => {
// PUBLIC_HTTPS is not readable out of process. The HSTS header is the
// observable signal that the server is in plain-http mode.
const r = await fetch(`${BASE}/health`);
expect(r.headers.get('strict-transport-security')).toBeNull();
});
test('POST with no Origin is allowed (non-browser path)', async () => {
const r = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=admin&password=wrong',
redirect: 'manual',
});
expect(r.status).not.toBe(403);
});
test('POST with same-origin Origin is allowed', async () => {
const r = await fetch(`${BASE}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Origin: BASE,
},
body: 'username=admin&password=wrong',
redirect: 'manual',
});
expect(r.status).not.toBe(403);
});
test('POST with mismatched Origin is rejected', async () => {
const r = await fetch(`${BASE}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Origin: 'http://attacker.example',
},
body: 'username=admin&password=wrong',
redirect: 'manual',
});
expect(r.status).toBe(403);
});
test('successful login Set-Cookie omits Secure', async () => {
const r = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `username=admin&password=${encodeURIComponent(ADMIN_PASS)}`,
redirect: 'manual',
});
expect(r.status).toBe(302);
const cookie = r.headers.get('set-cookie') ?? '';
expect(cookie).toContain('session=');
expect(cookie).not.toContain('Secure');
});
test('responses do not include Strict-Transport-Security', async () => {
const r = await fetch(`${BASE}/health`);
expect(r.headers.get('strict-transport-security')).toBeNull();
});
});
describe('CSRF / Secure cookie — HTTPS mode (https BASE_URL)', () => {
beforeAll(async () => {
// Restart with `BASE_URL=https://forge.test`. Note that the test client
// still talks to the server over plain HTTP on localhost — that's the
// whole point of the reverse-proxy story: the app trusts BASE_URL, not
// the transport it sees on the proxy↔app hop.
await killServer(server);
server = await spawnServer({ BASE_URL: 'https://forge.test' });
});
test('POST with no Origin is allowed (non-browser path)', async () => {
const r = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=admin&password=wrong',
redirect: 'manual',
});
expect(r.status).not.toBe(403);
});
test('POST with matching public Origin is allowed', async () => {
const r = await fetch(`${BASE}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Origin: 'https://forge.test',
},
body: 'username=admin&password=wrong',
redirect: 'manual',
});
expect(r.status).not.toBe(403);
});
test('POST whose Origin only matches Host (not BASE_URL) is rejected', async () => {
// Stricter than dev mode: `Origin: ${BASE}` (http://localhost:PORT) would
// pass the Host-match check but must fail the BASE_URL check.
const r = await fetch(`${BASE}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Origin: BASE,
},
body: 'username=admin&password=wrong',
redirect: 'manual',
});
expect(r.status).toBe(403);
});
test('POST with attacker Origin is rejected', async () => {
const r = await fetch(`${BASE}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Origin: 'https://attacker.example',
},
body: 'username=admin&password=wrong',
redirect: 'manual',
});
expect(r.status).toBe(403);
});
test('successful login Set-Cookie includes Secure', async () => {
const r = await fetch(`${BASE}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Origin: 'https://forge.test',
},
body: `username=admin&password=${encodeURIComponent(ADMIN_PASS)}`,
redirect: 'manual',
});
expect(r.status).toBe(302);
const cookie = r.headers.get('set-cookie') ?? '';
expect(cookie).toContain('session=');
expect(cookie).toContain('Secure');
});
test('responses include Strict-Transport-Security', async () => {
const r = await fetch(`${BASE}/health`);
expect(r.headers.get('strict-transport-security')).toBe(
'max-age=31536000; includeSubDomains',
);
});
});
Dtests/e2e.file-editing.test.ts-157
@@ -1,157 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
DATA_DIR,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
getHeadCommit,
gitOutput,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── File editing ─────────────────────────────────────────────────────────────
describe('file editing', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a dedicated repo so edits don't interfere with other tests
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'edit-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/edit-repo`);
} finally { await page.close(); }
await seedRepo('edit-repo');
});
afterAll(async () => { await adminCtx.close(); });
test('Edit button appears on text file blob when viewing a branch as admin', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
const editBtn = page.locator('a[href*="/edit/main/index.js"]');
expect(await editBtn.isVisible()).toBe(true);
expect(await editBtn.textContent()).toBe('Edit');
} finally { await page.close(); }
});
test('Edit button does not appear when viewing a commit SHA', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`);
expect(await page.locator('a[href*="/edit/"]').count()).toBe(0);
} finally { await page.close(); }
});
test('Edit button does not appear for unauthenticated visitors', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0);
} finally {
await page.close();
await ctx.close();
}
});
test('edit page loads with file content pre-filled', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
const content = await page.locator('textarea[name=content]').inputValue();
expect(content).toContain('hello');
const msg = await page.locator('textarea[name=message]').inputValue();
expect(msg).toBe('Edited index.js');
} finally { await page.close(); }
});
test('edit page shows which branch will be committed to', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
expect(await page.content()).toContain('main');
} finally { await page.close(); }
});
test('edit page returns 404 for non-branch ref', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('submitting edit creates a new commit and redirects to blob view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
await page.fill('textarea[name=content]', 'console.log("edited");\n');
await page.fill('textarea[name=message]', 'Update index.js via web editor');
await page.locator('.form-actions button[type=submit]').click();
await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/);
// The commit detail view should show the commit message
expect(await page.content()).toContain('Update index.js via web editor');
} finally { await page.close(); }
});
test('edit commit has a gpgsig header (is signed)', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`;
const hash = gitOutput(['log', '--format=%H', '--grep=Update index.js via web editor', '-1'], repoDir);
expect(hash).toBeTruthy();
const obj = gitOutput(['cat-file', '-p', hash], repoDir);
expect(obj).toContain('gpgsig');
});
test('edit commit shows verified badge in commit log', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/commits/main`);
const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' });
expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('GET edit page returns 404 for non-branch ref', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
});
Dtests/e2e.git-ops.test.ts-481
@@ -1,481 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
ADMIN_PASS,
BASE,
DATA_DIR,
getHeadCommit,
gitOutput,
killServer,
login,
seedBranch,
seedRepo,
setupTestEnv,
spawnServer,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
let adminCtx: BrowserContext;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Single admin context shared across all describes
adminCtx = await browser.newContext();
const page = await adminCtx.newPage();
await login(page, 'admin', ADMIN_PASS);
await page.close();
// Create all repos upfront
for (const name of ['branch-repo', 'tag-repo', 'selector-repo', 'newfile-repo', 'delfile-repo', 'movefile-repo']) {
const p = await adminCtx.newPage();
try {
await p.goto(`${BASE}/new`);
await p.fill('[name=name]', name);
await p.click('form[action="/new"] button[type=submit]');
await p.waitForURL(`${BASE}/${name}`);
} finally { await p.close(); }
}
// Seed repos
await seedRepo('branch-repo');
seedBranch('branch-repo', 'feature-a');
await seedRepo('tag-repo');
gitOutput(['-C', repoDir('tag-repo'), 'tag', 'v0.1.0', 'HEAD']);
await seedRepo('selector-repo');
gitOutput(['-C', repoDir('selector-repo'), 'tag', 'stable', 'HEAD']);
await seedRepo('newfile-repo');
await seedRepo('delfile-repo');
await seedRepo('movefile-repo');
});
afterAll(async () => {
await adminCtx.close();
await browser.close();
await killServer(server);
});
function repoDir(name: string) {
return `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
}
// ─── Branch management ────────────────────────────────────────────────────────
describe('branches', () => {
test('Branches tab appears in repo nav', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo`);
const tab = page.locator('a.repo-tab', { hasText: 'Branches' });
expect(await tab.isVisible()).toBe(true);
} finally { await page.close(); }
});
test('branches page lists all branches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const content = await page.content();
expect(content).toContain('main');
expect(content).toContain('feature-a');
} finally { await page.close(); }
});
test('default branch has a "default" badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
expect(await page.locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('branch list shows last commit hash and subject', async () => {
const sha = getHeadCommit('branch-repo').slice(0, 7);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const content = await page.content();
expect(content).toContain(sha);
expect(content).toContain('Initial commit');
} finally { await page.close(); }
});
test('branches page returns 404 for non-existent repo', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
const resp = await page.request.get(`${BASE}/does-not-exist/branches`);
expect(resp.status()).toBe(404);
} finally {
await page.close();
await ctx.close();
}
});
test('create a new branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
await page.click('details:has(summary:text("New branch")) summary');
await page.fill('[name=name]', 'new-feature');
await page.fill('[name=source_ref]', 'main');
await page.click('form[action*="branches/create"] button[type=submit]');
await page.waitForURL(/branches/);
expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('creating branch with invalid name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, {
form: { name: '--invalid', source_ref: 'main' },
});
expect(resp.url()).toContain('error');
});
test('creating branch from non-existent ref shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, {
form: { name: 'bad-branch', source_ref: 'does-not-exist' },
});
expect(resp.url()).toContain('error');
});
test('rename a branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const row = page.locator('.ref-item').filter({ hasText: 'feature-a' });
await row.locator('details:has(summary:text("Rename")) summary').click();
await row.locator('[name=new_name]').fill('feature-renamed');
await row.locator('form[action*="branches/rename"] button[type=submit]').click();
await page.waitForURL(/branches/);
expect(await page.locator('.ref-item').filter({ hasText: 'feature-renamed' }).count()).toBeGreaterThan(0);
expect(await page.locator('.ref-item').filter({ hasText: 'feature-a' }).count()).toBe(0);
} finally { await page.close(); }
});
test('delete a non-default branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const row = page.locator('.ref-item').filter({ hasText: 'new-feature' });
await row.locator('details:has(summary:text("Delete")) summary').click();
await row.locator('form[action*="branches/delete"] button[type=submit]').click();
await page.waitForURL(/branches/);
expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBe(0);
} finally { await page.close(); }
});
test('cannot delete the default branch (no Delete button on main row)', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const mainRow = page.locator('.ref-item').filter({ hasText: 'main' });
expect(await mainRow.locator('summary:text("Delete")').count()).toBe(0);
} finally { await page.close(); }
});
test('renaming default branch updates it in repo', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const row = page.locator('.ref-item').filter({ hasText: 'main' });
await row.locator('details:has(summary:text("Rename")) summary').click();
await row.locator('[name=new_name]').fill('trunk');
await row.locator('form[action*="branches/rename"] button[type=submit]').click();
await page.waitForURL(/branches/);
expect(await page.locator('.ref-item').filter({ hasText: 'trunk' }).locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0);
// Rename back
await page.goto(`${BASE}/branch-repo/branches`);
const trunkRow = page.locator('.ref-item').filter({ hasText: 'trunk' });
await trunkRow.locator('details:has(summary:text("Rename")) summary').click();
await trunkRow.locator('[name=new_name]').fill('main');
await trunkRow.locator('form[action*="branches/rename"] button[type=submit]').click();
await page.waitForURL(/branches/);
} finally { await page.close(); }
});
});
// ─── Tag management ───────────────────────────────────────────────────────────
describe('tags', () => {
test('Tags tab appears in repo nav', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo`);
const tab = page.locator('a.repo-tab', { hasText: 'Tags' });
expect(await tab.isVisible()).toBe(true);
} finally { await page.close(); }
});
test('tags page lists existing tags', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
expect(await page.content()).toContain('v0.1.0');
} finally { await page.close(); }
});
test('tag links to correct tree view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
const link = page.locator(`a[href="/${['tag-repo', 'tree', 'v0.1.0'].join('/')}"]`);
expect(await link.count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('create a new tag', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
await page.click('details:has(summary:text("New tag")) summary');
await page.fill('[name=name]', 'v1.0.0');
await page.click('form[action*="tags/create"] button[type=submit]');
await page.waitForURL(/tags/);
expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('create annotated tag with message', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
await page.click('details:has(summary:text("New tag")) summary');
await page.fill('[name=name]', 'v1.1.0-annotated');
await page.fill('[name=message]', 'Annotated release tag');
await page.click('form[action*="tags/create"] button[type=submit]');
await page.waitForURL(/tags/);
expect(await page.locator('.ref-item').filter({ hasText: 'v1.1.0-annotated' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('delete a tag', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
const row = page.locator('.ref-item').filter({ hasText: 'v1.0.0' });
await row.locator('details:has(summary:text("Delete")) summary').click();
await row.locator('form[action*="tags/delete"] button[type=submit]').click();
await page.waitForURL(/tags/);
expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBe(0);
expect(await page.locator('.ref-item').filter({ hasText: 'v0.1.0' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('tag linked to release shows release badge and warning on delete', async () => {
const resp = await adminCtx.request.post(`${BASE}/tag-repo/releases`, {
multipart: { name: 'Linked Release', create_tag: 'on', tag_name: 'v-linked', revision: 'main' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
const row = page.locator('.ref-item').filter({ hasText: 'v-linked' });
expect(await row.locator('.badge-release').count()).toBeGreaterThan(0);
await row.locator('details:has(summary:text("Delete")) summary').click();
expect(await row.locator('.confirm-warning').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
});
// ─── BranchSelector with tags ─────────────────────────────────────────────────
describe('BranchSelector tags integration', () => {
test('branch selector shows Tags optgroup when tags exist', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/selector-repo`);
expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1);
expect(await page.locator('option', { hasText: 'stable' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('branch selector shows Branches optgroup when tags exist', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/selector-repo`);
expect(await page.locator('optgroup[label="Branches"]').count()).toBe(1);
} finally { await page.close(); }
});
test('branch selector on blob view includes tag optgroup', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/selector-repo/blob/main/README.md`);
expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1);
} finally { await page.close(); }
});
});
// ─── File creation ────────────────────────────────────────────────────────────
describe('file creation', () => {
test('New file button appears in tree toolbar for admin on a branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/tree/main`);
expect(await page.locator('a[href*="/new-file/main"]').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('New file button not visible on commit SHA view', async () => {
const sha = getHeadCommit('newfile-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/tree/${sha}`);
expect(await page.locator('a[href*="/new-file/"]').count()).toBe(0);
} finally { await page.close(); }
});
test('New file button not visible to unauthenticated user', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/tree/main`);
expect(await page.locator('a[href*="/new-file/main"]').count()).toBe(0);
} finally {
await page.close();
await ctx.close();
}
});
test('new file form pre-fills dir when ?dir= query param is provided', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/new-file/main?dir=src`);
const pathInput = await page.locator('[name=path]').inputValue();
expect(pathInput).toBe('src/');
} finally { await page.close(); }
});
test('creating a new file creates a commit and redirects to commit view', async () => {
const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, {
form: { path: 'hello.txt', content: 'Hello, world!\n', message: 'Add hello.txt' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toMatch(/\/newfile-repo\/commit\/[0-9a-f]{40}/);
});
test('new file appears in tree after creation', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/tree/main`);
expect(await page.content()).toContain('hello.txt');
} finally { await page.close(); }
});
test('new file commit is signed', async () => {
const hash = gitOutput(['-C', repoDir('newfile-repo'), 'log', '--format=%H', '--grep=Add hello.txt', '-1']);
expect(hash).toBeTruthy();
const obj = gitOutput(['-C', repoDir('newfile-repo'), 'cat-file', '-p', hash]);
expect(obj).toContain('gpgsig');
});
test('creating file with invalid path shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, {
form: { path: '../escape', content: '', message: 'bad' },
});
expect(resp.url()).toContain('error');
});
});
// ─── File deletion ────────────────────────────────────────────────────────────
describe('file deletion', () => {
test('Delete button appears in file blob for admin on a branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/delfile-repo/blob/main/index.js`);
expect(await page.locator('details:has(summary:text("Delete"))').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('Delete button not visible to unauthenticated user', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/delfile-repo/blob/main/index.js`);
expect(await page.locator('details:has(summary:text("Delete"))').count()).toBe(0);
} finally {
await page.close();
await ctx.close();
}
});
test('deleting a file creates a commit and removes it from the tree', async () => {
const resp = await adminCtx.request.post(`${BASE}/delfile-repo/delete-file/main/index.js`, {
form: { message: 'Remove index.js' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toMatch(/\/delfile-repo\/commit\/[0-9a-f]{40}/);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/delfile-repo/tree/main`);
expect(await page.content()).not.toContain('index.js');
} finally { await page.close(); }
});
test('delete commit is signed', async () => {
const hash = gitOutput(['-C', repoDir('delfile-repo'), 'log', '--format=%H', '--grep=Remove index.js', '-1']);
expect(hash).toBeTruthy();
const obj = gitOutput(['-C', repoDir('delfile-repo'), 'cat-file', '-p', hash]);
expect(obj).toContain('gpgsig');
});
});
// ─── File rename/move ─────────────────────────────────────────────────────────
describe('file rename/move', () => {
test('edit form has new_path input pre-filled with current path', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/movefile-repo/edit/main/index.js`);
const newPathInput = await page.locator('[name=new_path]').inputValue();
expect(newPathInput).toBe('index.js');
} finally { await page.close(); }
});
test('renaming a file via edit creates a commit and old path is gone', async () => {
const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/index.js`, {
form: { content: 'console.log("hello");\n', new_path: 'app.js', message: 'Rename index.js to app.js' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toMatch(/\/movefile-repo\/commit\/[0-9a-f]{40}/);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/movefile-repo/tree/main`);
expect(await page.content()).toContain('app.js');
expect(await page.content()).not.toContain('index.js');
} finally { await page.close(); }
});
test('rename commit is signed', async () => {
const hash = gitOutput(['-C', repoDir('movefile-repo'), 'log', '--format=%H', '--grep=Rename index.js', '-1']);
expect(hash).toBeTruthy();
const obj = gitOutput(['-C', repoDir('movefile-repo'), 'cat-file', '-p', hash]);
expect(obj).toContain('gpgsig');
});
test('renaming to invalid path shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/README.md`, {
form: { content: '# movefile-repo\n', new_path: '../escape.md', message: 'bad' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('error');
});
});
Dtests/e2e.issues.test.ts-482
@@ -1,482 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
// Create my-repo
const adminCtx = await browser.newContext();
const adminPage = await adminCtx.newPage();
try {
await login(adminPage);
await adminPage.goto(`${BASE}/new`);
await adminPage.fill('[name=name]', 'my-repo');
await adminPage.click('form[action="/new"] button[type=submit]');
await adminPage.waitForURL(`${BASE}/my-repo`);
} finally { await adminCtx.close(); }
await seedRepo('my-repo');
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Issues ───────────────────────────────────────────────────────────────────
describe('issues', () => {
let adminCtx: BrowserContext;
let issueUrl: string;
let completedIssueUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
});
afterAll(async () => { await adminCtx.close(); });
test('create issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'First issue');
await page.fill('[name=body]', 'Body with **markdown**.');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
issueUrl = page.url();
expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue');
} finally { await page.close(); }
});
test('issue body renders markdown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>');
} finally { await page.close(); }
});
test('issue appears in open list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('First issue'))).toBe(true);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from new issue form', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('add comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
const beforeCount = await page.locator('.timeline-item').count();
await page.fill('textarea[name=body]', 'A follow-up comment.');
await page.click('form[action*="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount);
} finally { await page.close(); }
});
test('react to issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.locator('.reaction-picker').first().click();
await page.locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('close issue changes status badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('closed');
} finally { await page.close(); }
});
test('closed issue appears in closed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues?status=closed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('First issue'))).toBe(true);
} finally { await page.close(); }
});
test('reopen issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('.issue-detail-meta-actions button');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('open');
} finally { await page.close(); }
});
test('completed button marks issue as completed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'To be completed');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
completedIssueUrl = page.url();
await page.click('form[action*="/complete"] button');
await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('completed');
} finally { await page.close(); }
});
test('completed issue appears in completed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues?status=completed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('To be completed'))).toBe(true);
} finally { await page.close(); }
});
test('non-admin cannot complete or close issue', async () => {
const issueNum = issueUrl.split('/issues/')[1];
const ctx = await browser.newContext();
try {
const completeResp = await ctx.request.post(
`${BASE}/my-repo/issues/${issueNum}/complete`,
{ maxRedirects: 0 },
);
expect(completeResp.status()).toBe(302);
expect(completeResp.headers()['location']).toContain('/login');
} finally { await ctx.close(); }
});
test('reacting with same emoji toggles it off', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Reaction was added by the earlier 'react to issue' test
expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
await page.locator('.reaction-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').count()).toBe(0);
} finally { await page.close(); }
});
test('react to issue comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)')
.filter({ hasText: 'A follow-up comment.' });
await commentItem.locator('.reaction-add-btn').click();
await commentItem.locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
});
// ─── Issue editing and deletion ───────────────────────────────────────────────
describe('issue editing', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
let issueUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create an issue to edit
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'Issue to edit');
await page.fill('[name=body]', 'Original body.');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
issueUrl = page.url();
} finally { await page.close(); }
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('author can edit issue title and body', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Edit title via title form
await page.click('.title-edit-open');
await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
await page.click('.title-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
// Edit body via inline form
await page.click('.timeline-author .inline-edit-details summary');
await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
await page.click('.inline-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
} finally { await page.close(); }
});
test('edited marker appears after editing', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
expect(await page.locator('.edited-indicator').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('non-author non-admin cannot edit issue', async () => {
const page = await aliceCtx.newPage();
try {
const issueNum = issueUrl.split('/issues/')[1];
const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
form: { title: 'Hacked title', edit_body: '' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('author can edit issue comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Add a comment first
await page.fill('textarea[name=body]', 'Comment to edit.');
await page.click('form[action*="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
// Edit the comment
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
await commentItem.locator('.inline-edit-details summary').click();
await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
await commentItem.locator('.inline-edit-form-area [type=submit]').click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
} finally { await page.close(); }
});
test('non-admin user can create an issue', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', "Alice's issue");
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
} finally { await page.close(); }
});
test('non-admin cannot comment on a closed issue', async () => {
// Close the issue as admin first
const issueNum = issueUrl.split('/issues/')[1];
await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
form: { body: 'comment on closed issue' },
maxRedirects: 0,
});
// Non-admin gets redirected (silently ignored), not an error
expect(resp.status()).toBe(302);
// The comment should NOT appear
await page.goto(issueUrl);
const bodies = await page.locator('.timeline-body').allTextContents();
expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
} finally { await page.close(); }
});
test('cannot edit comment via wrong repo url (cross-repo bypass)', async () => {
// Create a second repo
const setupPage = await adminCtx.newPage();
try {
await setupPage.goto(`${BASE}/new`);
await setupPage.fill('[name=name]', 'other-repo');
await setupPage.click('form[action="/new"] button[type=submit]');
await setupPage.waitForURL(`${BASE}/other-repo`);
} finally { await setupPage.close(); }
// Pull a comment id from the existing my-repo issue
const issueNum = issueUrl.split('/issues/')[1];
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
const formAction = await page
.locator(`form[action*="/my-repo/issues/${issueNum}/comments/"][action$="/edit"]`)
.first()
.getAttribute('action');
expect(formAction).toBeTruthy();
const commentId = formAction!.split('/comments/')[1]!.split('/')[0];
// Edit the same comment via /other-repo/... — must 404, not 200/302
const resp = await page.request.post(
`${BASE}/other-repo/issues/${issueNum}/comments/${commentId}/edit`,
{ form: { edit_body: 'cross-repo bypass attempt' }, maxRedirects: 0 },
);
expect(resp.status()).toBe(404);
// And the original comment must be unchanged
await page.goto(issueUrl);
const bodies = await page.locator('.timeline-body').allTextContents();
expect(bodies.every(b => !b.includes('cross-repo bypass attempt'))).toBe(true);
} finally { await page.close(); }
});
test('admin can delete issue', async () => {
const issueNum = issueUrl.split('/issues/')[1];
const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
// Issue should be gone
const page = await adminCtx.newPage();
try {
const checkResp = await page.request.get(issueUrl);
expect(checkResp.status()).toBe(404);
} finally { await page.close(); }
});
});
// ─── Repository description update ───────────────────────────────────────────
describe('repo description', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('updating repo description is reflected on list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=description]', 'A freshly updated description');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const desc = await page.locator('.repo-description').allTextContents();
expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
} finally { await page.close(); }
});
});
// ─── Issue and patch templates ────────────────────────────────────────────────
describe('issue and patch templates', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('issue template can be saved and is prefilled on new issue form', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/issues/new`);
const body = await page.locator('[name=body]').inputValue();
expect(body).toContain('## Steps to reproduce');
expect(body).toContain('## Expected behavior');
} finally { await page.close(); }
});
test('patch template can be saved and is prefilled on new patch form', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=patch_template]', '## Summary\n\n## Testing');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/patches/new`);
const desc = await page.locator('[name=description]').inputValue();
expect(desc).toContain('## Summary');
expect(desc).toContain('## Testing');
} finally { await page.close(); }
});
test('clearing the issue template removes prefill', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=issue_template]', '');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/issues/new`);
const body = await page.locator('[name=body]').inputValue();
expect(body).toBe('');
} finally { await page.close(); }
});
test('clearing the patch template removes prefill', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=patch_template]', '');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/patches/new`);
const desc = await page.locator('[name=description]').inputValue();
expect(desc).toBe('');
} finally { await page.close(); }
});
});
Dtests/e2e.labels.test.ts-614
@@ -1,614 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
writeTempFile,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice (needed for user label management)
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Labels ───────────────────────────────────────────────────────────────────
describe('labels', () => {
let adminCtx: BrowserContext;
let issueUrl: string;
let patchUrl: string;
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add label-test.txt',
'',
'---',
'diff --git a/label-test.txt b/label-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/label-test.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a dedicated repo for label tests
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'label-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/label-repo`);
} finally { await page.close(); }
// Create an issue
const issuePage = await adminCtx.newPage();
try {
await issuePage.goto(`${BASE}/label-repo/issues/new`);
await issuePage.fill('[name=title]', 'Labelled issue');
await issuePage.click('form[action$="/issues"] button[type=submit]');
await issuePage.waitForURL(/\/label-repo\/issues\/\d+/);
issueUrl = issuePage.url();
} finally { await issuePage.close(); }
// Create a patch
writeTempFile('/tmp/label-test.patch', VALID_PATCH);
const patchPage = await adminCtx.newPage();
try {
await patchPage.goto(`${BASE}/label-repo/patches/new`);
await patchPage.fill('[name=title]', 'Labelled patch');
await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch');
await patchPage.click('form[action$="/patches"] button[type=submit]');
await patchPage.waitForURL(/\/label-repo\/patches\/\d+/);
patchUrl = patchPage.url();
} finally { await patchPage.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('create label in repo settings', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'bug');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#ff0000'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('create a second label', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'enhancement');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#00aa00'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
const badges = await page.locator('.label-settings-name').allTextContents();
expect(badges).toContain('bug');
expect(badges).toContain('enhancement');
} finally { await page.close(); }
});
test('duplicate label name is rejected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'bug');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#0000ff'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
expect(await page.locator('.form-error').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('non-admin cannot create labels', async () => {
const ctx = await browser.newContext();
try {
const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, {
form: { name: 'nope', color: '#123456' },
maxRedirects: 0,
});
// Unauthenticated → redirected to /login
expect(r.status()).toBe(302);
expect(r.headers()['location']).toContain('/login');
} finally { await ctx.close(); }
});
test('assign label to issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.selectOption('select[name=label_id]', { label: 'bug' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('label appears on issue list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' });
expect(await item.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('filter issues by label shows only matching issues', async () => {
// Create a second issue without the label
const createPage = await adminCtx.newPage();
try {
await createPage.goto(`${BASE}/label-repo/issues/new`);
await createPage.fill('[name=title]', 'Unlabelled issue');
await createPage.click('form[action$="/issues"] button[type=submit]');
await createPage.waitForURL(/\/label-repo\/issues\/\d+/);
} finally { await createPage.close(); }
// Open filter popup and apply label filter
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
// Get the label id from the checkbox
const checkbox = page.locator('.label-filter-item input[name=labels]').first();
const labelId = await checkbox.getAttribute('value');
expect(labelId).toBeTruthy();
// Navigate with the filter applied via URL
await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Labelled issue'))).toBe(true);
expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false);
} finally { await page.close(); }
});
test('filter popup is visible without JS', async () => {
// details/summary is a native HTML element — verify it renders
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
expect(await page.locator('details.label-filter').isVisible()).toBe(true);
expect(await page.locator('details.label-filter summary').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('remove label from issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
// Label badge should no longer appear in the labels row
const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(labelBadges).not.toContain('bug');
} finally { await page.close(); }
});
test('assign label to patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(patchUrl);
await page.selectOption('select[name=label_id]', { label: 'enhancement' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement');
} finally { await page.close(); }
});
test('label appears on patch list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/patches`);
const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' });
expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement');
} finally { await page.close(); }
});
test('filter patches by label', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/patches`);
// Find the checkbox for the 'enhancement' label specifically
const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' })
.locator('input[name=labels]');
const labelId = await checkbox.getAttribute('value');
expect(labelId).toBeTruthy();
await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Labelled patch'))).toBe(true);
} finally { await page.close(); }
});
test('remove label from patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(patchUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(labelBadges).not.toContain('enhancement');
} finally { await page.close(); }
});
test('delete label removes it from settings list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' });
await bugItem.locator('form[action$="/labels/delete"] button').click();
await page.waitForURL(/\/label-repo\/settings/);
const badges = await page.locator('.label-settings-name').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
});
test('deleted label no longer appears in filter popup', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
const filterLabels = await page.locator('.label-filter-item').allTextContents();
expect(filterLabels.every(t => !t.includes('bug'))).toBe(true);
} finally { await page.close(); }
});
});
// ─── User label management ────────────────────────────────────────────────────
describe('user label management', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
// URL of an issue owned by alice
let aliceIssueUrl: string;
// URL of an issue owned by admin
let adminIssueUrl: string;
// label IDs, fetched from the filter inputs
let bugLabelId: string;
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add ulm-test.txt',
'',
'---',
'diff --git a/ulm-test.txt b/ulm-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/ulm-test.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create dedicated repo
const repoPage = await adminCtx.newPage();
try {
await repoPage.goto(`${BASE}/new`);
await repoPage.fill('[name=name]', 'ulm-repo');
await repoPage.click('form[action="/new"] button[type=submit]');
await repoPage.waitForURL(`${BASE}/ulm-repo`);
} finally { await repoPage.close(); }
// Create labels 'bug' and 'feature'
for (const name of ['bug', 'feature']) {
const p = await adminCtx.newPage();
try {
await p.goto(`${BASE}/ulm-repo/settings`);
await p.fill('input[name=name]', name);
await p.click('form[action$="/settings/labels"] button[type=submit]');
await p.waitForURL(/\/ulm-repo\/settings/);
} finally { await p.close(); }
}
// Create an issue owned by admin
const adminIssuePage = await adminCtx.newPage();
try {
await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
await adminIssuePage.fill('[name=title]', "Admin's issue");
await adminIssuePage.click('form[action$="/issues"] button[type=submit]');
await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
adminIssueUrl = adminIssuePage.url();
} finally { await adminIssuePage.close(); }
// Create an issue owned by alice
const aliceIssuePage = await aliceCtx.newPage();
try {
await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
await aliceIssuePage.fill('[name=title]', "Alice's issue");
await aliceIssuePage.click('form[action$="/issues"] button[type=submit]');
await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
aliceIssueUrl = aliceIssuePage.url();
} finally { await aliceIssuePage.close(); }
// Grab the bug label id from the filter popup
const filterPage = await adminCtx.newPage();
try {
await filterPage.goto(`${BASE}/ulm-repo/issues`);
const checkbox = filterPage.locator('.label-filter-item')
.filter({ hasText: 'bug' })
.locator('input[name=labels]');
bugLabelId = (await checkbox.getAttribute('value')) ?? '';
} finally { await filterPage.close(); }
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
// ── Settings ──
test('allow_user_labels checkbox is present in repo settings', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1);
} finally { await page.close(); }
});
test('allow_user_labels is off by default', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
} finally { await page.close(); }
});
// ── Label checkboxes hidden when setting is off ──
test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
test('label checkboxes shown to admin on new issue form regardless of setting', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Enable the setting ──
test('admin can enable allow_user_labels', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
await page.check('input[name=allow_user_labels]');
await page.click('form[action$="/settings"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/settings/);
// Verify it persisted
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true);
} finally { await page.close(); }
});
// ── Label checkboxes visible when setting is on ──
test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents();
expect(labels).toContain('bug');
expect(labels).toContain('feature');
} finally { await page.close(); }
});
test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Creating with labels selected ──
test('non-admin can create issue with label selected', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
await page.fill('[name=title]', 'Issue with label');
// Check the 'bug' label checkbox
await page.locator('.label-checkbox-item').filter({ hasText: 'bug' })
.locator('input[type=checkbox]').check();
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/issues\/\d+/);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).toContain('bug');
} finally { await page.close(); }
});
test('non-admin can create patch with label selected', async () => {
writeTempFile('/tmp/ulm-test.patch', VALID_PATCH);
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
await page.fill('[name=title]', 'Patch with label');
await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch');
await page.locator('.label-checkbox-item').filter({ hasText: 'feature' })
.locator('input[type=checkbox]').check();
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/patches\/\d+/);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).toContain('feature');
} finally { await page.close(); }
});
test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => {
// Temporarily disable the setting, post with label_ids, re-enable
await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
form: { description: '', default_branch: 'main' }, // no allow_user_labels
maxRedirects: 0,
}).catch(() => {});
const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, {
form: { title: 'Issue sneaking labels', label_ids: bugLabelId },
maxRedirects: 0,
}).catch(() => null);
// Should redirect to the new issue
const location = resp?.headers()['location'] ?? '';
const issueNum = location.split('/issues/')[1];
if (issueNum) {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
}
// Re-enable for subsequent tests
await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
form: { description: '', default_branch: 'main', allow_user_labels: '1' },
maxRedirects: 0,
}).catch(() => {});
});
// ── Add/remove labels on existing items ──
test('non-admin can add label to their own issue', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const page = await aliceCtx.newPage();
try {
await page.goto(aliceIssueUrl);
await page.selectOption('select[name=label_id]', { label: 'bug' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('non-admin can remove label from their own issue', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const page = await aliceCtx.newPage();
try {
await page.goto(aliceIssueUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
const badges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
});
test('non-admin cannot add label to another user\'s issue', async () => {
const issueNum = adminIssueUrl.split('/issues/')[1];
const resp = await aliceCtx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(403);
});
test('unauthenticated user gets 401 adding a label', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const ctx = await browser.newContext();
try {
const resp = await ctx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(401);
} finally { await ctx.close(); }
});
// ── Disable setting and verify enforcement ──
test('admin can disable allow_user_labels', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
await page.uncheck('input[name=allow_user_labels]');
await page.click('form[action$="/settings"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/settings/);
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
} finally { await page.close(); }
});
test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const resp = await aliceCtx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(403);
});
test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
});
Dtests/e2e.pagination.test.ts-200
@@ -1,200 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// Helper: create N issues in a repo using the server API (no browser rendering)
async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) {
for (let i = 1; i <= count; i++) {
await ctx.request.fetch(`${BASE}/${repo}/issues`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: `title=Issue+number+${i}&body=`,
maxRedirects: 0,
}).catch(() => {}); // 302 redirect throws; that's fine
}
}
// Helper: create N patches in a repo using the server API
async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
for (let i = 1; i <= count; i++) {
await ctx.request.fetch(`${BASE}/${repo}/patches`, {
method: 'POST',
multipart: {
title: `Patch number ${i}`,
patch_file: { name: 'bulk.patch', mimeType: 'text/plain', buffer: Buffer.from(VALID_PATCH) },
},
maxRedirects: 0,
}).catch(() => {});
}
}
// ─── Pagination ───────────────────────────────────────────────────────────────
describe('pagination', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a repo dedicated to pagination testing
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'paged-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/paged-repo`);
} finally { await page.close(); }
// Create 21 issues via the API (triggers page 2 at 20 per page)
await bulkCreateIssues(adminCtx, 'paged-repo', 21);
// Create 21 patches via browser (patch upload requires multipart)
await bulkCreatePatches(adminCtx, 'paged-repo', 21);
});
afterAll(async () => { await adminCtx.close(); });
// ── Repo list pagination ──────────────────────────────────────────────────
test('repo list page 1 shows repos and no pagination when few repos', async () => {
// With only a handful of test repos (< 20), there should be no pagination nav
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
// Repos are shown
expect(await page.locator('.repo-name').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
// ── Issue pagination ──────────────────────────────────────────────────────
test('issue list page 1 shows at most 20 items', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('issue list pagination nav appears when more than 20 issues', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('issue list page 2 shows remaining issues', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('issue list page 2 prev link goes to page 1', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues?page=2`);
const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href');
expect(prevHref).toContain('page=1');
} finally { await page.close(); }
});
test('issue list page 1 next link goes to page 2', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href');
expect(nextHref).toContain('page=2');
} finally { await page.close(); }
});
// ── Patch pagination ──────────────────────────────────────────────────────
test('patch list page 1 shows at most 20 items', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('patch list pagination nav appears when more than 20 patches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch list page 2 shows remaining patches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
} finally { await page.close(); }
});
// ── Commit log pagination ─────────────────────────────────────────────────
test('commit log with few commits shows no cursor nav', async () => {
// paged-repo has no commits (empty repo) — there's no commit log to paginate
// We verify the page loads without pagination controls
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo`);
expect(await page.locator('.commit-cursor-nav').count()).toBe(0);
} finally { await page.close(); }
});
});
Dtests/e2e.patches.test.ts-718
@@ -1,718 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import { existsSync, readFileSync } from 'node:fs';
import {
BASE,
DATA_DIR,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
writeTempFile,
gitOutput,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
// Create my-repo
const adminCtx = await browser.newContext();
const adminPage = await adminCtx.newPage();
try {
await login(adminPage);
await adminPage.goto(`${BASE}/new`);
await adminPage.fill('[name=name]', 'my-repo');
await adminPage.click('form[action="/new"] button[type=submit]');
await adminPage.waitForURL(`${BASE}/my-repo`);
} finally { await adminCtx.close(); }
await seedRepo('my-repo');
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Patches ──────────────────────────────────────────────────────────────────
describe('patches', () => {
let adminCtx: BrowserContext;
let cleanPatchUrl: string;
let conflictPatchUrl: string;
let closePatchUrl: string;
// Adds a new file — applies cleanly to my-repo
const CLEAN_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add patch-test.txt',
'',
'---',
'diff --git a/patch-test.txt b/patch-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/patch-test.txt',
'@@ -0,0 +1 @@',
'+patch test content',
'',
].join('\n');
// References non-existent lines in README.md — always conflicts
const CONFLICT_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Modify README',
'',
'---',
'diff --git a/README.md b/README.md',
'index abc1234..def5678 100644',
'--- a/README.md',
'+++ b/README.md',
'@@ -50,3 +50,3 @@',
' nonexistent context line',
'-nonexistent old line',
'+nonexistent new line',
'',
].join('\n');
// Adds another new file — for testing close flow
const CLOSE_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add patch-close.txt',
'',
'---',
'diff --git a/patch-close.txt b/patch-close.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/patch-close.txt',
'@@ -0,0 +1 @@',
'+close test',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
});
afterAll(async () => { await adminCtx.close(); });
test('reject file without patch markers', async () => {
writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Bad patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('valid patch');
} finally { await page.close(); }
});
test('reject patch missing Subject header', async () => {
writeTempFile('/tmp/no-subject.patch', [
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No subject');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Subject');
} finally { await page.close(); }
});
test('reject patch missing From header', async () => {
writeTempFile('/tmp/no-from.patch', [
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No from');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('From');
} finally { await page.close(); }
});
test('reject patch missing Date header', async () => {
writeTempFile('/tmp/no-date.patch', [
'From: Test User <test@example.com>',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No date');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Date');
} finally { await page.close(); }
});
test('upload clean patch', async () => {
writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Add patch-test.txt');
await page.fill('[name=description]', 'Adds a file with **markdown** desc.');
await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
cleanPatchUrl = page.url();
expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt');
} finally { await page.close(); }
});
test('changes tab shows commit metadata card', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl + '?tab=changes');
expect(await page.locator('.commit-card').isVisible()).toBe(true);
expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt');
expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User');
expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com');
expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch description renders markdown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
} finally { await page.close(); }
});
test('clean patch shows apply-clean status immediately', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('.apply-result').isVisible()).toBe(true);
expect(await page.locator('.apply-clean').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('merge button appears for clean patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch diff is displayed with highlighted table', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl + '?tab=changes');
expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('patch appears in open list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from patch upload', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('upload conflict patch', async () => {
writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Conflict patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
conflictPatchUrl = page.url();
} finally { await page.close(); }
});
test('conflict patch shows apply-conflict status', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('merge button absent for conflict patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
} finally { await page.close(); }
});
test('merge clean patch changes status to merged', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
await page.click('form[action*="/merge"] button');
await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('merged');
} finally { await page.close(); }
});
test('merge uses patch From header as git author', async () => {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath);
const authorEmail = gitOutput(['log', '-1', '--format=%aE'], repoPath);
const subject = gitOutput(['log', '-1', '--format=%s'], repoPath);
expect(authorName).toBe('Test User');
expect(authorEmail).toBe('test@example.com');
expect(subject).toBe('Add patch-test.txt');
});
test('merged patch appears in merged list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches?status=merged`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
} finally { await page.close(); }
});
test('upload and close a patch', async () => {
writeTempFile('/tmp/close.patch', CLOSE_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Close me');
await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
closePatchUrl = page.url();
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('closed');
} finally { await page.close(); }
});
test('closed patch appears in closed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches?status=closed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Close me'))).toBe(true);
} finally { await page.close(); }
});
test('closed patch can be reopened', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(closePatchUrl);
expect(await page.locator('.patch-badge').textContent()).toBe('closed');
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('open');
} finally { await page.close(); }
});
test('patch title and description can be edited', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
// Edit title via title form
await page.click('.title-edit-open');
await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
await page.click('.title-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
// Edit description via inline form
await page.click('.timeline-author .inline-edit-details summary');
await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
await page.click('.inline-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
} finally { await page.close(); }
});
test('patch comment: add and edit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
await page.fill('[name=body]', 'My patch comment');
await page.click('form[action$="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
// Edit the comment — scope to the timeline-item containing the comment text
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
await commentItem.locator('.inline-edit-details summary').click();
await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
await commentItem.locator('.inline-edit-form-area [type=submit]').click();
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
} finally { await page.close(); }
});
test('patch reaction on description', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
// Open reaction picker on the first timeline-item (description)
await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
} finally { await page.close(); }
});
test('admin can delete a patch', async () => {
const page = await adminCtx.newPage();
try {
const patchNum = conflictPatchUrl.split('/patches/')[1];
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('/patches');
// Patch should no longer be accessible
const checkResp = await page.request.get(conflictPatchUrl);
expect(checkResp.status()).toBe(404);
} finally { await page.close(); }
});
// ── Patch file re-upload & version protection ──────────────────────────────
let uploadTestPatchUrl: string;
// Adds upload-test.txt — applies cleanly to my-repo
const UPLOAD_TEST_PATCH = [
'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001',
'From: Original Author <original@example.com>',
'Date: Wed, 03 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add upload-test.txt',
'',
'---',
'diff --git a/upload-test.txt b/upload-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/upload-test.txt',
'@@ -0,0 +1 @@',
'+upload test',
'',
].join('\n');
// Replacement: different author, same diff target
const REPLACEMENT_PATCH = [
'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001',
'From: Replaced Author <replaced@example.com>',
'Date: Thu, 04 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add upload-test.txt (v2)',
'',
'---',
'diff --git a/upload-test.txt b/upload-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/upload-test.txt',
'@@ -0,0 +1 @@',
'+upload test v2',
'',
].join('\n');
test('create patch for re-upload tests', async () => {
writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Upload test patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
uploadTestPatchUrl = page.url();
} finally { await page.close(); }
});
test('upload patch file button is visible for admin on open patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(1);
} finally { await page.close(); }
});
test('non-author non-admin cannot upload patch file', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
const patchNum = uploadTestPatchUrl.split('/patches/')[1];
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await aliceCtx.close(); }
});
test('upload button hidden for non-author non-admin', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
} finally { await aliceCtx.close(); }
});
test('admin can upload replacement patch file', async () => {
writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
await page.locator('details:has([name=patch_file]) summary').click();
await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch');
await page.locator('details:has([name=patch_file]) button[type=submit]').click();
await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
} finally { await page.close(); }
});
test('merge fails when version token is stale', async () => {
// Patch that adds stale-version.txt — applies cleanly
const STALE_PATCH = [
'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Fri, 05 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add stale-version.txt',
'',
'---',
'diff --git a/stale-version.txt b/stale-version.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/stale-version.txt',
'@@ -0,0 +1 @@',
'+stale',
'',
].join('\n');
const STALE_PATCH_V2 = STALE_PATCH
.replace('Add stale-version.txt', 'Add stale-version.txt (v2)')
.replace('+stale', '+stale v2');
writeTempFile('/tmp/stale.patch', STALE_PATCH);
const page = await adminCtx.newPage();
try {
// Create the patch
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Stale version test');
await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
const stalePatchUrl = page.url();
const patchNum = stalePatchUrl.split('/patches/')[1];
// Capture the version the admin sees on the page
const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue();
// Author uploads a new patch file (simulated by admin here), bumping the version
writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2);
await page.locator('details:has([name=patch_file]) summary').click();
await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch');
await page.locator('details:has([name=patch_file]) button[type=submit]').click();
await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, '')));
// Admin tries to merge with the stale version — should be rejected
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, {
form: { version: staleVersion },
maxRedirects: 0,
});
expect(resp.status()).toBe(409);
expect(await resp.text()).toContain('updated');
// Patch status must still be open
const checkResp = await page.request.get(stalePatchUrl);
expect(checkResp.status()).toBe(200);
expect(await checkResp.text()).toContain('open');
} finally { await page.close(); }
});
test('merge succeeds with current version token after replacement upload', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
await page.click('form[action*="/merge"] button');
await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('merged');
// Merged commit should carry the replacement patch's author
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath);
expect(authorName).toBe('Replaced Author');
} finally { await page.close(); }
});
test('upload patch file button hidden on merged patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
} finally { await page.close(); }
});
test('POST to upload on merged patch returns 400', async () => {
const patchNum = uploadTestPatchUrl.split('/patches/')[1];
const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
maxRedirects: 0,
});
expect(resp.status()).toBe(400);
});
});
// ─── Commit signing ───────────────────────────────────────────────────────────
// Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo.
describe('commit signing', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('allowed_signers file is generated at startup', () => {
const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`;
expect(existsSync(allowedSignersPath)).toBe(true);
const content = readFileSync(allowedSignersPath, 'utf8');
expect(content).toContain('namespaces="git"');
expect(content).toContain('ssh-ed25519');
});
test('merged commit has a gpgsig header', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
// Find the patch commit specifically by subject
const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir);
expect(hash).toBeTruthy();
const obj = gitOutput(['cat-file', '-p', hash], repoDir);
expect(obj).toContain('gpgsig');
});
test('unsigned commits have no gpgsig header', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
// Initial commit was created by seedRepo (plain git commit, not hearthforge)
const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir);
expect(hash).toBeTruthy();
const obj = gitOutput(['cat-file', '-p', hash], repoDir);
expect(obj).not.toContain('gpgsig');
});
test('commit log shows verified badge on signed commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
// Find the commit-item for the merged patch by subject text
const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' });
expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('commit log shows no sig badge on unsigned commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
// Initial commit was not signed via hearthforge
const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' });
expect(await initialItem.locator('.sig-badge').count()).toBe(0);
} finally { await page.close(); }
});
test('commit detail shows verified signature row for signed commit', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commit/${hash}`);
const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
expect(await sigRow.isVisible()).toBe(true);
expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('commit detail shows no signature row for unsigned commit', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commit/${hash}`);
const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
expect(await sigRow.count()).toBe(0);
} finally { await page.close(); }
});
});
Dtests/e2e.releases.test.ts-367
@@ -1,367 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Releases ─────────────────────────────────────────────────────────────────
describe('releases', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
let releaseUrl: string;
let srcReleaseUrl: string;
let releaseWithAssetsUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create a dedicated repo with at least one commit
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'releases-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/releases-repo`);
} finally { await page.close(); }
await seedRepo('releases-repo');
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
// ── Navigation ──────────────────────────────────────────────────────────────
test('releases tab visible in repo nav', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo`);
expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
} finally { await page.close(); }
});
test('releases list shows empty state when no releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Access control ──────────────────────────────────────────────────────────
test('non-admin cannot access /releases/new', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('non-admin POST to /releases returns 403', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Test', tag_name: 'v0.1.0' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from /releases/new', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
// ── Validation ──────────────────────────────────────────────────────────────
test('missing release title shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {},
});
expect(await resp.text()).toContain('Release title is required');
});
test('create_tag checked but no tag name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Test', create_tag: 'on' },
});
expect(await resp.text()).toContain('Tag name is required');
});
// ── Create ──────────────────────────────────────────────────────────────────
test('create a basic release', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
create_tag: 'on',
tag_name: 'v1.0.0',
revision: 'main',
name: 'First release',
notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
},
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
releaseUrl = `${BASE}${location}`;
});
test('duplicate tag name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' },
});
expect(await resp.text()).toContain('already exists');
});
// ── List ────────────────────────────────────────────────────────────────────
test('release appears in list with tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.release-item-title').textContent()).toContain('First release');
expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('release list shows tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('new release button hidden for non-admin', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
} finally { await page.close(); }
});
// ── Detail ──────────────────────────────────────────────────────────────────
test('release detail shows title and tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseUrl);
expect(await page.locator('h2.page-title').textContent()).toBe('First release');
expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('release notes rendered in detail view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseUrl);
expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
} finally { await page.close(); }
});
// ── Source archives ─────────────────────────────────────────────────────────
test('create release with source code archives', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
srcReleaseUrl = `${BASE}${location}`;
});
test('zip and tar.gz archives appear in downloads', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const assetNames = await page.locator('.asset-name').allTextContents();
expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
} finally { await page.close(); }
});
test('source archive download responds with 200', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
const resp = await page.request.get(`${BASE}${zipLink}`);
expect(resp.status()).toBe(200);
} finally { await page.close(); }
});
// ── File upload ─────────────────────────────────────────────────────────────
test('create release with attached file', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
name: 'Asset release',
create_tag: 'on',
tag_name: 'v1.2.0',
revision: 'main',
files: {
name: 'release-asset.txt',
mimeType: 'text/plain',
buffer: Buffer.from('binary-like content for testing\n'),
},
},
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
releaseWithAssetsUrl = `${BASE}${location}`;
});
test('uploaded asset appears in downloads with filename and size', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseWithAssetsUrl);
const names = await page.locator('.asset-name').allTextContents();
expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
expect(await page.locator('.asset-size').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('asset download responds with 200', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseWithAssetsUrl);
const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
const resp = await page.request.get(`${BASE}${link}`);
expect(resp.status()).toBe(200);
} finally { await page.close(); }
});
// ── Delete ──────────────────────────────────────────────────────────────────
test('non-admin cannot delete a release', async () => {
const page = await aliceCtx.newPage();
try {
const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('admin can delete a release', async () => {
const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
const resp = await adminCtx.request.post(
`${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
{ maxRedirects: 0 },
);
expect(resp.status()).toBe(302);
// Verify it's gone from the list
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
const titles = await page.locator('.release-item-title').allTextContents();
expect(titles.some(t => t.includes('First release'))).toBe(false);
} finally { await page.close(); }
});
// ── Pagination ──────────────────────────────────────────────────────────────
test('release list shows at most 20 per page', async () => {
// Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
// regardless of which browser-based tests above succeeded
for (let i = 1; i <= 25; i++) {
await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' },
maxRedirects: 0,
}).catch(() => {});
}
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('pagination nav appears with more than 20 releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('release list page 2 shows remaining releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('source archive tar.zst appears in downloads', async () => {
const zstd = Bun.spawnSync({ cmd: ['which', 'zstd'] });
if (zstd.exitCode !== 0) {
console.log('zstd not available, skipping tar.zst test');
return;
}
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const assetNames = await page.locator('.asset-name').allTextContents();
expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
} finally { await page.close(); }
});
});
Dtests/e2e.repos.test.ts-414
@@ -1,414 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
DATA_DIR,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
db,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
await page.waitForURL(BASE + '/');
} finally { await ctx.close(); }
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Repos ────────────────────────────────────────────────────────────────────
describe('repos', () => {
// Shared admin context — cookies persist across tests in this block.
let adminCtx: BrowserContext;
// Alice's context, created after alice is registered in auth tests.
let aliceCtx: BrowserContext;
// Set after 'commit log' test; used by all commit-detail tests below.
let commitUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('non-admin gets 403 on /new', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/new`);
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('create repository', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'my-repo');
await page.fill('[name=description]', 'A test repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/my-repo`);
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('repository appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
} finally { await page.close(); }
});
test('search finds matching repo', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await page.fill('[name=q]', 'my-repo');
await page.click('.search-form button[type=submit]');
expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
} finally { await page.close(); }
});
test('search returns empty for unknown term', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await page.fill('[name=q]', 'zzz-nothing-here');
await page.click('.search-form button[type=submit]');
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('browse file tree after seeding content', async () => {
await seedRepo('my-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
const files = await page.locator('.file-name a').allTextContents();
expect(files).toContain('README.md');
expect(files).toContain('index.js');
} finally { await page.close(); }
});
test('view file blob with syntax highlighting', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/blob/main/index.js`);
expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
expect(await page.locator('.file-blob-body').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('raw file download responds 200', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
expect(resp.status()).toBe(200);
expect(resp.headers()['content-disposition']).toContain('README.md');
} finally { await page.close(); }
});
test('raw svg is served as an image under a sandbox policy', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/raw/main/logo.svg`);
expect(resp.status()).toBe(200);
expect(resp.headers()['content-type']).toContain('image/svg+xml');
expect(resp.headers()['content-security-policy']).toContain('sandbox;');
const txt = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
expect(txt.headers()['content-security-policy']).not.toContain('sandbox');
} finally { await page.close(); }
});
test('commit log shows initial commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
const subjects = await page.locator('.commit-subject').allTextContents();
expect(subjects.some(s => s.includes('Initial commit'))).toBe(true);
// Navigate to the commit page and capture the URL for subsequent tests
await page.locator('.commit-hash').first().click();
await page.waitForURL(/\/my-repo\/commit\//);
commitUrl = page.url();
} finally { await page.close(); }
});
test('commit detail shows metadata card', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
expect(await page.locator('.commit-card').isVisible()).toBe(true);
expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit');
// Author, date and SHA rows are all present
const metaText = await page.locator('.commit-card-meta').textContent();
expect(metaText).toContain('Test'); // author name set by seedRepo
expect(metaText).toContain('Author'); // label (CSS uppercases visually)
expect(metaText).toContain('Date');
expect(metaText).toContain('Commit');
} finally { await page.close(); }
});
test('commit detail full SHA is shown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const sha = commitUrl.split('/commit/')[1] ?? null;
expect(await page.locator('.commit-sha-full').textContent()).toBe(sha);
} finally { await page.close(); }
});
test('commit detail shows file nav sidebar', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
expect(await page.locator('.file-nav-details').isVisible()).toBe(true);
const navItems = await page.locator('.file-nav-item').allTextContents();
// seedRepo adds README.md and index.js
expect(navItems.some(t => t.includes('README.md'))).toBe(true);
expect(navItems.some(t => t.includes('index.js'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail file nav items are anchor links to diff sections', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const hrefs = await page.locator('.file-nav-item').evaluateAll(
els => els.map(el => el.getAttribute('href') ?? ''),
);
expect(hrefs.every(h => h.startsWith('#'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail shows diff table with added lines', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// Initial commit only adds lines
expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
expect(await page.locator('.diff-row-del').count()).toBe(0);
} finally { await page.close(); }
});
test('commit detail diff table has line numbers', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// New-side line numbers (column 2) on add rows start at 1
const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent();
expect(firstNewLn?.trim()).toBe('1');
} finally { await page.close(); }
});
test('commit detail shows added stats on file header', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const addStats = await page.locator('.diff-stat-add').allTextContents();
expect(addStats.length).toBeGreaterThan(0);
expect(addStats.every(s => s.startsWith('+'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail view-at-sha button links to blob at that commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const sha = commitUrl.split('/commit/')[1];
const btn = page.locator('.btn-xs').first();
const href = await btn.getAttribute('href');
expect(href).toContain(`/blob/${sha}/`);
} finally { await page.close(); }
});
test('commit detail view-at-branch button links to blob at default branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const btns = await page.locator('.btn-xs').allTextContents();
expect(btns.some(t => t.includes('@ main'))).toBe(true);
const branchBtns = await page.locator('.btn-xs').evaluateAll(
els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''),
);
expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail file diff can be collapsed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// File body is visible when details is open
const diffFile = page.locator('.diff-file').first();
expect(await diffFile.getAttribute('open')).not.toBeNull();
// Click the summary to collapse
await diffFile.locator('.diff-file-header').click();
expect(await diffFile.getAttribute('open')).toBeNull();
} finally { await page.close(); }
});
test('commit detail file nav sidebar can be collapsed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const nav = page.locator('.file-nav-details');
expect(await nav.getAttribute('open')).not.toBeNull();
await nav.locator('.file-nav-toggle').click();
expect(await nav.getAttribute('open')).toBeNull();
} finally { await page.close(); }
});
test('readme renders on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.readme-header').isVisible()).toBe(true);
expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo');
} finally { await page.close(); }
});
test('private repo hidden from other users', async () => {
// Make private
const adminPage = await adminCtx.newPage();
try {
await adminPage.goto(`${BASE}/my-repo/settings`);
await adminPage.check('[name=is_private]');
await adminPage.click('form[action$="/settings"] button[type=submit]');
expect(await adminPage.locator('.form-success').isVisible()).toBe(true);
} finally { await adminPage.close(); }
// Alice should get 404
const alicePage = await aliceCtx.newPage();
try {
const resp = await alicePage.request.get(`${BASE}/my-repo`);
expect(resp.status()).toBe(404);
await alicePage.goto(BASE);
expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo');
} finally { await alicePage.close(); }
// Restore to public
const adminPage2 = await adminCtx.newPage();
try {
await adminPage2.goto(`${BASE}/my-repo/settings`);
await adminPage2.uncheck('[name=is_private]');
await adminPage2.click('form[action$="/settings"] button[type=submit]');
} finally { await adminPage2.close(); }
});
test('settings tab visible for admin, hidden for others', async () => {
const adminPage = await adminCtx.newPage();
try {
await adminPage.goto(`${BASE}/my-repo`);
expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true);
} finally { await adminPage.close(); }
const alicePage = await aliceCtx.newPage();
try {
await alicePage.goto(`${BASE}/my-repo`);
expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
} finally { await alicePage.close(); }
});
test('create repository with invalid name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/new`, {
form: { name: 'has spaces!', description: '', default_branch: 'main' },
maxRedirects: 0,
});
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain('Invalid repository name');
});
test('auto-scanned repo is private by default', async () => {
const name = 'auto-private-repo';
const dir = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
rmSync(dir, { recursive: true, force: true });
const tmp = `/tmp/hf-scan-${Date.now()}`;
try {
spawnSync('git', ['init', '--bare', dir], { stdio: 'ignore' });
spawnSync('git', ['clone', dir, tmp], { stdio: 'ignore' });
spawnSync('git', ['-C', tmp, 'commit', '--allow-empty', '-m', 'init'], { stdio: 'ignore' });
spawnSync('git', ['-C', tmp, 'push', 'origin', 'HEAD:main'], { stdio: 'ignore' });
} finally {
rmSync(tmp, { recursive: true, force: true });
}
// The Go server adopts repos found on disk at startup, not lazily per
// request, so restart it and read the row it created.
await killServer(server);
server = await spawnServer();
const repo = await db
.selectFrom('repositories')
.select(['name', 'is_private'])
.where('name', '=', name)
.executeTakeFirst();
expect(repo!.is_private).toBe(1);
await db.deleteFrom('repositories').where('name', '=', name).execute();
rmSync(dir, { recursive: true, force: true });
});
test('repo is registered even with a stale config.lock', async () => {
const name = 'stale-lock-repo';
const dir = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
rmSync(dir, { recursive: true, force: true });
spawnSync('git', ['init', '--bare', dir], { stdio: 'ignore' });
// Drop core.bare so ensureBare has to attempt a write, then block it.
spawnSync('git', ['config', '--file', `${dir}/config`, '--unset', 'core.bare'], {
stdio: 'ignore',
});
writeFileSync(`${dir}/config.lock`, '');
await killServer(server);
server = await spawnServer();
const repo = await db
.selectFrom('repositories')
.select('name')
.where('name', '=', name)
.executeTakeFirst();
expect(repo!.name).toBe(name);
await db.deleteFrom('repositories').where('name', '=', name).execute();
rmSync(dir, { recursive: true, force: true });
});
});
Dtests/e2e.settings.test.ts-477
@@ -1,477 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import { rmSync, readFileSync } from 'node:fs';
import { spawnSync as nodeSpawnSync } from 'node:child_process';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
// Create my-repo (needed for repo deletion non-admin test)
const adminCtx = await browser.newContext();
const adminPage = await adminCtx.newPage();
try {
await login(adminPage);
await adminPage.goto(`${BASE}/new`);
await adminPage.fill('[name=name]', 'my-repo');
await adminPage.click('form[action="/new"] button[type=submit]');
await adminPage.waitForURL(`${BASE}/my-repo`);
} finally { await adminCtx.close(); }
await seedRepo('my-repo');
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Settings ─────────────────────────────────────────────────────────────────
describe('settings', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
// Public key generated once in beforeAll, reused across SSH key tests
let testPubKey: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Generate a throwaway ed25519 key for SSH key tests.
const keyPath = '/tmp/hf-e2e-sshkey';
rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true });
nodeSpawnSync(
'ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
{ stdio: 'ignore' },
);
testPubKey = readFileSync(`${keyPath}.pub`, 'utf-8').trim();
rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true });
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('settings page requires auth', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('settings page loads for logged-in user', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
} finally { await page.close(); }
});
// ── Password ──────────────────────────────────────────────────────────────
test('password change with mismatched passwords shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=new_password]', 'newpass123');
await page.fill('[name=confirm_password]', 'different456');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('password change with wrong current password shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=current_password]', 'wrongpassword');
await page.fill('[name=new_password]', 'newpass123');
await page.fill('[name=confirm_password]', 'newpass123');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('password change too short shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=current_password]', 'password123');
await page.fill('[name=new_password]', 'short');
await page.fill('[name=confirm_password]', 'short');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
// ── SSH keys ──────────────────────────────────────────────────────────────
test('add SSH key with unsupported key type shows error', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'Bad key');
await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('add valid SSH key shows success and key appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'My Laptop');
await page.fill('#ssh_public_key', testPubKey);
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=ssh_key_added');
await page.goto(`${BASE}/settings`);
expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
} finally { await page.close(); }
});
test('add duplicate SSH key shows error', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'Duplicate');
await page.fill('#ssh_public_key', testPubKey);
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('delete SSH key removes it from list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
// Click the Remove button for the key added above
await page.click('form[action="/settings/ssh-keys/delete"] button');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=ssh_key_deleted');
await page.goto(`${BASE}/settings`);
expect(await page.locator('.ssh-key-name').count()).toBe(0);
} finally { await page.close(); }
});
// ── Admin user management ────────────────────────────────────────────────
test('admin can create a new user account', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#new_username', 'charlie');
await page.fill('#new_user_password', 'charliepw1');
await page.click('form[action="/admin/users"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=user_created');
} finally { await page.close(); }
});
test('admin cannot create duplicate username', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#new_username', 'charlie');
await page.fill('#new_user_password', 'charliepw1');
await page.click('form[action="/admin/users"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('admin cannot create user with invalid username characters', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
form: { username: 'bad user!', password: 'password123' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location'] ?? '';
expect(location).toContain('error');
});
test('non-admin gets 403 when creating user', async () => {
const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
form: { username: 'hacker', password: 'password123' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
});
test('admin can delete user account', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
form: { username: 'charlie' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('success=user_deleted');
});
test('admin cannot delete the admin account', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
form: { username: 'admin' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('error');
});
test('settings page has no git identity section', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(await page.locator('text=Git Identity').count()).toBe(0);
expect(await page.locator('[name=git_name]').count()).toBe(0);
expect(await page.locator('[name=git_email]').count()).toBe(0);
} finally { await page.close(); }
});
test('git identity route no longer exists', async () => {
const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
form: { git_name: 'Test', git_email: 'test@example.com' },
maxRedirects: 0,
});
expect(resp.status()).toBe(404);
});
});
// ─── Repository deletion ──────────────────────────────────────────────────────
describe('repository deletion', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a repo to delete
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'deleteme-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/deleteme-repo`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('admin can delete repository', async () => {
const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toBe('/');
});
test('deleted repository returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/deleteme-repo`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('deleted repository no longer appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
} finally { await page.close(); }
});
test('non-admin cannot delete repository', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally {
await page.close();
await aliceCtx.close();
}
});
});
// ─── Repository rename ───────────────────────────────────────────────────────
describe('repository rename', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'renameme-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/renameme-repo`);
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'rename-other');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/rename-other`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('rejects invalid name', async () => {
const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'bad name' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const loc = resp.headers()['location']!;
expect(loc).toContain('/renameme-repo/settings?error=');
expect(decodeURIComponent(loc)).toContain('Invalid');
});
test('rejects no-op rename', async () => {
const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'renameme-repo' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(decodeURIComponent(resp.headers()['location']!)).toContain('same as the current name');
});
test('rejects duplicate name', async () => {
const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'rename-other' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(decodeURIComponent(resp.headers()['location']!)).toContain('already taken');
});
test('non-admin cannot rename', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
try {
const resp = await aliceCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'hijack' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await aliceCtx.close(); }
});
test('admin can rename repository', async () => {
const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'renamed-repo' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const loc = resp.headers()['location']!;
expect(loc).toContain('/renamed-repo/settings?success=');
const old = await adminCtx.request.get(`${BASE}/renameme-repo`);
expect(old.status()).toBe(404);
const next = await adminCtx.request.get(`${BASE}/renamed-repo`);
expect(next.status()).toBe(200);
});
});
// ─── 404 handling ─────────────────────────────────────────────────────────────
describe('404 handling', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('non-existent repository returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/no-such-repo`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent issue returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent commit returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent file blob returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent patch returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent release returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
});
Dtests/e2e.sorting.test.ts-185
@@ -1,185 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Repo sorting and pinning ─────────────────────────────────────────────────
describe('repo sorting and pinning', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create two repos with predictable names: sort-aaa (created first/older),
// sort-zzz (created second/newer). This lets us verify both name order and
// creation-time order independently.
// Also create my-repo which is navigated to in one test.
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'my-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/my-repo`);
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'sort-aaa');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/sort-aaa`);
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'sort-zzz');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/sort-zzz`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('sort dropdown is visible on repo list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
const options = await page.locator('.repo-sort-select option').allTextContents();
expect(options.some(t => t.includes('Newest'))).toBe(true);
expect(options.some(t => t.includes('Name'))).toBe(true);
} finally { await page.close(); }
});
test('newest option is selected by default', async () => {
// Use a fresh context to ensure no repo_sort cookie is set.
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page, 'admin', ADMIN_PASS);
await page.goto(BASE);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('created');
} finally { await ctx.close(); }
});
test('Go button is hidden with JS and works without JS', async () => {
const ctx = await browser.newContext({ javaScriptEnabled: false });
const page = await ctx.newPage();
try {
await login(page, 'admin', ADMIN_PASS);
await page.goto(BASE);
// Go button visible without JS
expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true);
// Select name sort and submit via Go button
await page.locator('.repo-sort-select').selectOption('name');
await page.locator('form[action="/sort"] button[type=submit]').click();
await page.waitForURL(BASE + '/');
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
} finally { await ctx.close(); }
});
test('selecting name sort sets cookie and persists on next visit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await Promise.all([
page.waitForURL(BASE + '/'),
page.locator('.repo-sort-select').selectOption('name'),
]);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
// Navigate away and back to confirm cookie persists
await page.goto(`${BASE}/my-repo`);
await page.goto(BASE);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
} finally {
// Reset cookie so subsequent tests start from the default sort.
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.close();
}
});
test('default sort shows newer repo before older repo', async () => {
const page = await adminCtx.newPage();
try {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa'));
} finally { await page.close(); }
});
test('name sort shows repos in alphabetical order', async () => {
const page = await adminCtx.newPage();
try {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]);
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
} finally {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.close();
}
});
test('pinning a repo shows pinned badge on list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/sort-aaa/settings`);
await page.check('[name=is_pinned]');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
expect(await card.locator('.badge-pinned').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('pinned repo appears before unpinned repos regardless of creation order', async () => {
const page = await adminCtx.newPage();
try {
// sort-aaa is pinned; default (newest) sort would normally show sort-zzz
// first since it's newer — but pinned repos float to the top.
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
} finally { await page.close(); }
});
test('unpinning a repo removes the pinned badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/sort-aaa/settings`);
await page.uncheck('[name=is_pinned]');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
expect(await card.locator('.badge-pinned').count()).toBe(0);
} finally { await page.close(); }
});
});
Dtests/e2e.validation.test.ts-276
@@ -1,276 +0,0 @@
/**
* Tests for input validation: body size limits, username/password limits,
* tag name validation, and LIKE search wildcard escaping.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
seedRepo,
} from './helpers.ts';
// The server runs out of process, so its limits cannot be imported. These
// mirror the defaults in internal/config/config.go; the tests never override
// the matching env vars.
const config = {
MAX_TITLE_BYTES: 500,
MAX_TEXT_BODY_BYTES: 100_000,
MAX_USERNAME_BYTES: 64,
MAX_PASSWORD_BYTES: 1024,
};
let server: Awaited<ReturnType<typeof spawnServer>>;
let sessionCookie = '';
let issueUrl = '';
async function adminLogin(): Promise<string> {
const res = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username: 'admin', password: ADMIN_PASS }),
redirect: 'manual',
});
const raw = res.headers.get('set-cookie') ?? '';
return raw.split(';')[0]!; // "session=<hex>"
}
async function post(path: string, body: Record<string, string>): Promise<Response> {
return fetch(`${BASE}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: sessionCookie,
},
body: new URLSearchParams(body),
redirect: 'manual',
});
}
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
sessionCookie = await adminLogin();
// Create a repo and seed it so issues/patches can be submitted
const res = await post('/new', { name: 'val-repo' });
expect(res.status).toBe(302);
await seedRepo('val-repo');
// Create a baseline issue so we have an issue URL for comment tests
const issueRes = await post('/val-repo/issues', { title: 'Baseline issue', body: 'ok' });
expect(issueRes.status).toBe(302);
issueUrl = issueRes.headers.get('location') ?? '/val-repo/issues/1';
});
afterAll(async () => {
await killServer(server);
});
// ─── Body size limits ─────────────────────────────────────────────────────────
describe('body size limits', () => {
test('issue body at limit is accepted', async () => {
const res = await post('/val-repo/issues', {
title: 'Body at limit',
body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES),
});
expect(res.status).toBe(302);
});
test('issue body over limit is rejected', async () => {
const res = await post('/val-repo/issues', {
title: 'Body over limit',
body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1),
});
expect(res.status).toBe(422);
});
test('issue title at limit is accepted', async () => {
const res = await post('/val-repo/issues', {
title: 'x'.repeat(config.MAX_TITLE_BYTES),
body: 'ok',
});
expect(res.status).toBe(302);
});
test('issue title over limit is rejected', async () => {
const res = await post('/val-repo/issues', {
title: 'x'.repeat(config.MAX_TITLE_BYTES + 1),
body: 'ok',
});
expect(res.status).toBe(422);
});
test('issue comment body at limit is accepted', async () => {
const res = await post(`${issueUrl}/comments`, {
body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES),
});
expect(res.status).toBe(302);
});
test('issue comment body over limit is rejected', async () => {
const res = await post(`${issueUrl}/comments`, {
body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1),
});
expect(res.status).toBe(422);
});
test('patch description at limit is accepted', async () => {
const res = await post('/val-repo/patches', {
title: 'Patch ok',
description: 'x'.repeat(config.MAX_TEXT_BODY_BYTES),
});
// No patch_file provided → will fail business logic, but schema passes → 302 or 200, not 422
expect(res.status).not.toBe(422);
});
test('patch description over limit is rejected', async () => {
const res = await post('/val-repo/patches', {
title: 'Patch bad',
description: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1),
});
expect(res.status).toBe(422);
});
test('patch title over limit is rejected', async () => {
const res = await post('/val-repo/patches', {
title: 'x'.repeat(config.MAX_TITLE_BYTES + 1),
});
expect(res.status).toBe(422);
});
});
// ─── Auth limits ──────────────────────────────────────────────────────────────
describe('auth limits', () => {
test('username over limit is rejected at registration', async () => {
const res = await fetch(`${BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
username: 'u'.repeat(config.MAX_USERNAME_BYTES + 1),
password: 'validpass1',
password2: 'validpass1',
}),
redirect: 'manual',
});
expect(res.status).toBe(422);
});
test('username at limit is not schema-rejected', async () => {
// A username at exactly the limit passes schema (may fail business logic due to uniqueness/format)
const res = await fetch(`${BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
username: 'a'.repeat(config.MAX_USERNAME_BYTES),
password: 'validpass1',
password2: 'validpass1',
}),
redirect: 'manual',
});
// 302 (registered) or 200 (form error like invalid chars), but not 422
expect(res.status).not.toBe(422);
});
test('password over limit is rejected at registration', async () => {
const res = await fetch(`${BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
username: 'newuser',
password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
password2: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
}),
redirect: 'manual',
});
expect(res.status).toBe(422);
});
test('new_password over limit is rejected at settings/password', async () => {
const res = await post('/settings/password', {
current_password: ADMIN_PASS,
new_password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
confirm_password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
});
expect(res.status).toBe(422);
});
});
// ─── Tag name validation ──────────────────────────────────────────────────────
describe('tag name validation', () => {
const validTags = ['v1.0.0', 'release-2', '1.0+build.1', 'v1_alpha'];
const invalidTags = ['v1.0~1', 'tag with space', 'v1:2', 'v1^2', 'ref/head', 'v1?', 'v1*'];
for (const tag of validTags) {
test(`valid tag "${tag}" is accepted`, async () => {
const res = await post('/val-repo/releases', {
create_tag: 'on',
tag_name: tag,
revision: 'main',
name: `Release ${tag}`,
});
// 302 = success redirect, or 200 = form with error (e.g. tag already exists) — either is fine
// What's NOT acceptable is a 422 schema error
expect(res.status).not.toBe(422);
});
}
for (const tag of invalidTags) {
test(`invalid tag "${tag}" is rejected`, async () => {
const res = await post('/val-repo/releases', {
create_tag: 'on',
tag_name: tag,
revision: 'main',
name: `Release ${tag}`,
});
// Should get a 200 with an inline form error (business-logic validation)
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain('may only contain');
});
}
});
// ─── LIKE wildcard escaping in repo search ────────────────────────────────────
describe('repo search LIKE escaping', () => {
beforeAll(async () => {
// Create repos with and without underscore/special chars to verify search behavior
await post('/new', { name: 'search-under_score' });
await post('/new', { name: 'search-nodash' });
});
test('search for "_" returns only repos with literal underscore', async () => {
const res = await fetch(`${BASE}/?q=${encodeURIComponent('_')}`, {
headers: { Cookie: sessionCookie },
});
const body = await res.text();
expect(body).toContain('search-under_score');
expect(body).not.toContain('search-nodash');
expect(body).not.toContain('val-repo');
});
test('search for "%" returns no repos (no repo has literal % in name)', async () => {
const res = await fetch(`${BASE}/?q=${encodeURIComponent('%')}`, {
headers: { Cookie: sessionCookie },
});
const body = await res.text();
expect(body).not.toContain('search-under_score');
expect(body).not.toContain('search-nodash');
expect(body).not.toContain('val-repo');
});
test('normal substring search still works', async () => {
const res = await fetch(`${BASE}/?q=search-under`, {
headers: { Cookie: sessionCookie },
});
const body = await res.text();
expect(body).toContain('search-under_score');
expect(body).not.toContain('search-nodash');
});
});
Dtests/helpers.ts-309
@@ -1,309 +0,0 @@
import { createServer } from 'net';
import { rmSync, mkdirSync, writeFileSync } from 'fs';
import { spawnSync as nodeSpawnSync } from 'child_process';
import path from 'path';
import { Database } from 'bun:sqlite';
import type { Page } from 'playwright';
// Set by tests/preload.ts before any module reads it — see the comment
// there for why it cannot be assigned from this file.
export const DATA_DIR = process.env.DATA_DIR ?? './data-test';
export const ADMIN_PASS = 'testpass123';
/** The Go binary under test. Built with `go build -o hearthforge ./cmd/hearthforge`. */
export const BIN = process.env.HEARTHFORGE_BIN ?? path.resolve('../hearthforge');
// If the preload did not run, DATA_DIR is still ./data and the tests would
// wipe and re-init the real database. Stop before that happens. Checked here
// rather than in the preload, which by definition cannot catch its own absence.
if (path.resolve(DATA_DIR) === path.resolve('./data')) {
throw new Error(
'Tests are pointed at the production DATA_DIR. tests/preload.ts did not run — ' +
'check that bunfig.toml is present and that bun was started from tests/.',
);
}
/** Bind to port 0 and return the OS-assigned free port number. */
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = createServer();
srv.listen(0, '127.0.0.1', () => {
const port = (srv.address() as { port: number }).port;
srv.close((err) => (err ? reject(err) : resolve(port)));
});
srv.on('error', reject);
});
}
// Resolved once in setupTestEnv() and re-exported for tests.
export let PORT = 0;
export let BASE = '';
/** Server handle — the spawned Go process. */
export type ServerHandle = ReturnType<typeof Bun.spawn>;
export async function setupTestEnv() {
PORT = await getFreePort();
BASE = `http://localhost:${PORT}`;
rmSync(DATA_DIR, { recursive: true, force: true });
mkdirSync(`${DATA_DIR}/repos`, { recursive: true });
const r = nodeSpawnSync(BIN, ['init'], {
env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS },
stdio: ['ignore', 'ignore', 'pipe'],
});
if (r.status !== 0) {
throw new Error(`hearthforge init failed: ${r.stderr?.toString() ?? r.error}`);
}
closeDb();
}
/**
* Start the server as a child process and wait until it answers /health.
*
* The Go server reads its whole configuration from the environment at
* startup, so a test that needs a different setting (CI_DOCKER_SOCKET,
* BASE_URL) restarts the server with `extraEnv` instead of mutating config.
*/
export async function spawnServer(
extraEnv: Record<string, string> = {},
): Promise<ServerHandle> {
const proc = Bun.spawn([BIN], {
env: {
...process.env,
PORT: String(PORT),
DATA_DIR,
SSH_DISABLED: '1',
RATE_LIMIT_DISABLED: '1',
BASE_URL: `http://localhost:${PORT}`,
...extraEnv,
},
stdout: 'ignore',
stderr: 'pipe',
});
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
if (proc.exitCode !== null) {
const err = await new Response(proc.stderr).text();
throw new Error(`server exited with ${proc.exitCode}: ${err}`);
}
try {
const r = await fetch(`http://localhost:${PORT}/health`);
if (r.status === 200) return proc;
} catch {
// not listening yet
}
await Bun.sleep(50);
}
proc.kill('SIGKILL');
throw new Error(`server did not become healthy on port ${PORT} within 15s`);
}
export async function killServer(proc: ServerHandle): Promise<void> {
closeDb();
proc.kill('SIGTERM');
await proc.exited;
}
// ── Database access ───────────────────────────────────────────────────────────
//
// The server owns the database file; tests read it (and occasionally write a
// fixture row) with a second connection. `db` mimics the sliver of the Kysely
// API the ported tests already used, so those assertions stay untouched.
let handle: Database | null = null;
function conn(): Database {
if (!handle) {
handle = new Database(`${DATA_DIR}/hearthforge.db`);
handle.exec('PRAGMA busy_timeout = 5000');
}
return handle;
}
function closeDb() {
handle?.close();
handle = null;
}
type Row = Record<string, unknown>;
class Select {
private cols = '*';
private conds: string[] = [];
private args: unknown[] = [];
private order = '';
constructor(private table: string) {}
select(c: string | string[]) {
this.cols = (Array.isArray(c) ? c : [c]).join(', ');
return this;
}
selectAll() {
this.cols = '*';
return this;
}
where(col: string, op: string, val: unknown) {
this.conds.push(`${col} ${op} ?`);
this.args.push(val);
return this;
}
orderBy(col: string, dir = 'asc') {
this.order = ` ORDER BY ${col} ${dir}`;
return this;
}
private sql() {
const where = this.conds.length ? ` WHERE ${this.conds.join(' AND ')}` : '';
return `SELECT ${this.cols} FROM ${this.table}${where}${this.order}`;
}
async execute(): Promise<Row[]> {
return conn().query(this.sql()).all(...(this.args as never[])) as Row[];
}
async executeTakeFirst(): Promise<Row | undefined> {
return (this.orderLimited() ?? undefined) as Row | undefined;
}
async executeTakeFirstOrThrow(): Promise<Row> {
const row = this.orderLimited();
if (!row) throw new Error(`no row in ${this.table}`);
return row as Row;
}
private orderLimited() {
return conn()
.query(`${this.sql()} LIMIT 1`)
.get(...(this.args as never[])) as Row | null;
}
}
class Insert {
constructor(private table: string) {}
private row: Row = {};
values(row: Row) {
this.row = row;
return this;
}
async execute() {
const keys = Object.keys(this.row);
conn()
.query(
`INSERT INTO ${this.table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
)
.run(...(keys.map((k) => this.row[k]) as never[]));
}
}
class Delete {
private conds: string[] = [];
private args: unknown[] = [];
constructor(private table: string) {}
where(col: string, op: string, val: unknown) {
this.conds.push(`${col} ${op} ?`);
this.args.push(val);
return this;
}
async execute() {
const where = this.conds.length ? ` WHERE ${this.conds.join(' AND ')}` : '';
conn()
.query(`DELETE FROM ${this.table}${where}`)
.run(...(this.args as never[]));
}
}
export const db = {
selectFrom: (t: string) => new Select(t),
insertInto: (t: string) => new Insert(t),
deleteFrom: (t: string) => new Delete(t),
};
// ── UI helpers ────────────────────────────────────────────────────────────────
export async function login(
page: Page,
username = 'admin',
password = ADMIN_PASS,
) {
await page.goto(`${BASE}/login`);
await page.fill('[name=username]', username);
await page.fill('[name=password]', password);
await page.click('button[type=submit]');
await page.waitForURL(BASE + '/');
}
export async function logout(page: Page) {
await page.click('form[action="/logout"] button');
await page.waitForURL(BASE + '/');
}
const git = (args: string[], cwd?: string) =>
nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: 'ignore' });
export function gitOutput(args: string[], cwd?: string): string {
const r = nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: ['ignore', 'pipe', 'ignore'] });
return r.stdout?.toString().trim() ?? '';
}
/** Push an initial commit into a bare repo that already exists on disk. */
export function seedRepo(name: string) {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
const tmp = `/tmp/hf-seed-${Date.now()}`;
try {
git(['clone', repoPath, tmp]);
git(['config', 'user.email', 'test@test.com'], tmp);
git(['config', 'user.name', 'Test'], tmp);
writeFileSync(`${tmp}/README.md`, `# ${name}\n`);
writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`);
writeFileSync(`${tmp}/logo.svg`, `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>\n`);
git(['add', '-A'], tmp);
git(['commit', '-m', 'Initial commit'], tmp);
git(['push', 'origin', 'HEAD:main'], tmp);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
export function writeTempFile(path: string, content: string) {
writeFileSync(path, content);
}
/** Commit a subdirectory with the given files into an existing repo on main. */
export function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
const tmp = `/tmp/hf-subdir-${Date.now()}`;
try {
git(['clone', repoDir, tmp]);
git(['config', 'user.email', 'test@test.com'], tmp);
git(['config', 'user.name', 'Test'], tmp);
mkdirSync(path.join(tmp, dirPath), { recursive: true });
for (const [fileName, content] of Object.entries(files)) {
writeFileSync(path.join(tmp, dirPath, fileName), content);
}
git(['add', '-A'], tmp);
git(['commit', '-m', `Add ${dirPath}`], tmp);
git(['push', 'origin', 'HEAD:main'], tmp);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
/** Return the HEAD commit hash of a repo. */
export function getHeadCommit(repoName: string): string {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
const result = nodeSpawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], {
stdio: ['ignore', 'pipe', 'ignore'],
});
return result.stdout?.toString().trim() ?? '';
}
/** Create a new branch in an existing repo (from current HEAD). */
export function seedBranch(name: string, branchName: string) {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
const tmp = `/tmp/hf-branch-${Date.now()}`;
try {
git(['clone', repoPath, tmp]);
git(['config', 'user.email', 'test@test.com'], tmp);
git(['config', 'user.name', 'Test'], tmp);
git(['checkout', '-b', branchName], tmp);
git(['push', 'origin', branchName], tmp);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
Dtests/package.json-12
@@ -1,12 +0,0 @@
{
"name": "hearthforge-tests",
"private": true,
"type": "module",
"scripts": {
"test": "bun test --parallel --isolate --timeout 60000 --preload ./preload.ts"
},
"devDependencies": {
"@types/bun": "^1.4.0",
"playwright": "^1.58.2"
}
}
Dtests/preload.ts-8
@@ -1,8 +0,0 @@
// Runs before any test module is evaluated (see --preload in the "test"
// script and bunfig.toml).
//
// Every parallel worker needs its own data directory and its own database
// file. helpers.ts reads DATA_DIR at module scope, so it has to be set here,
// before imports are hoisted.
const worker = process.env.BUN_TEST_WORKER_ID;
process.env.DATA_DIR = worker ? `./data-test-${worker}` : "./data-test";
Dtests/tsconfig.json-12
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["bun"]
}
}