package chrome
import (
"html/template"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vaughan0/go-ini"
)
// testConf synthesizes the shared instance config the way the services read
// it: one section per service plus the [sr.ht] block.
func testConf() ini.File {
return ini.File{
"sr.ht": {
"site-name": "srht.example",
"environment": "production",
},
"meta.sr.ht": {"origin": "https://meta.example"},
"git.sr.ht": {"origin": "https://git.example"},
"todo.sr.ht": {"origin": "https://todo.example"},
"builds.sr.ht": {"origin": "https://builds.example"},
"hub.sr.ht": {"origin": "https://hub.example"},
"paste.sr.ht": {"origin": "https://paste.example"},
"pages.sr.ht": {"origin": "https://pages.example"},
"compare.sr.ht": {"origin": "https://compare.example"},
"dolt.sr.ht": {"origin": "https://dolt.example"},
"ghost.sr.ht": {}, // no origin -> must not appear
"webhooks": {"private-key": "x"},
}
}
func TestBuildNavOrderExclusionsActive(t *testing.T) {
nav := BuildNav(testConf(), "compare.sr.ht")
var names []string
for _, it := range nav {
names = append(names, it.Name)
}
// Canonical services first in canonical order, customs alphabetical after;
// hub/paste/pages and the origin-less section excluded.
assert.Equal(t, []string{"git", "todo", "builds", "meta", "compare", "dolt"}, names)
for _, it := range nav {
assert.Equal(t, it.Name == "compare", it.Active, "active flag for %s", it.Name)
}
}
func TestPageURLsAndIdentity(t *testing.T) {
svc := NewService(testConf(), "compare.sr.ht")
r := httptest.NewRequest("GET", "/~alice/demo?a=1", nil)
p := svc.Page(r, "t", "alice")
assert.Equal(t, "srht.example", p.SiteName)
assert.Equal(t, "compare", p.SiteLabel)
assert.Equal(t, "https://meta.example/login?return_to="+
"https%3A%2F%2Fcompare.example%2F~alice%2Fdemo%3Fa%3D1", p.LoginURL)
assert.Equal(t, "https://meta.example/logout?return_to="+
"https%3A%2F%2Fcompare.example", p.LogoutURL)
// Hub is configured, so the profile link prefers hub's ~username page.
assert.Equal(t, "https://hub.example/~alice", p.ProfileURL)
assert.Equal(t, "container", p.ContainerClass)
assert.False(t, p.ShowBanner, "production must not show the env banner")
}
func TestPageProfileFallsBackToMeta(t *testing.T) {
conf := testConf()
delete(conf, "hub.sr.ht")
svc := NewService(conf, "compare.sr.ht")
r := httptest.NewRequest("GET", "/", nil)
assert.Equal(t, "https://meta.example/profile", svc.Page(r, "t", "alice").ProfileURL)
// Anonymous viewers get the meta profile link regardless of hub.
assert.Equal(t, "https://meta.example/profile",
NewService(testConf(), "compare.sr.ht").Page(r, "t", "").ProfileURL)
}
func TestPageEnvBanner(t *testing.T) {
conf := testConf()
conf["sr.ht"]["environment"] = "staging"
svc := NewService(conf, "compare.sr.ht")
p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "")
assert.True(t, p.ShowBanner)
assert.Equal(t, "STAGING", p.Environment)
}
// render executes a minimal layout that invokes both shared partials against v.
func render(t *testing.T, v any) string {
t.Helper()
tpl := template.New("layout")
tpl = MustAttach(tpl)
tpl, err := tpl.Parse(`{{template "srht-env-banner" .}}<nav>{{template "srht-nav" .}}</nav>`)
require.NoError(t, err)
var b strings.Builder
require.NoError(t, tpl.Execute(&b, v))
return b.String()
}
func TestNavTemplateLoggedIn(t *testing.T) {
svc := NewService(testConf(), "compare.sr.ht")
svc.ExtraNav = []NavItem{{Name: "tokens", Origin: "/tokens"}}
p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice")
out := render(t, p)
assert.Contains(t, out, "icon icon-circle")
// The brand is two links: the site name to hub, the red label to us.
assert.Contains(t, out, `<a href="https://hub.example">srht.example</a>`)
assert.Contains(t, out, `<a href="/"><span class="text-danger">compare</span></a>`)
assert.Contains(t, out, `href="https://git.example"`)
assert.Contains(t, out, `href="/tokens"`, "extra nav entries must render")
assert.Contains(t, out, "Logged in as")
assert.NotContains(t, out, "ENVIRONMENT")
}
func TestNavTemplateAnonymous(t *testing.T) {
svc := NewService(testConf(), "compare.sr.ht")
p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "")
out := render(t, p)
// The switcher renders only for authenticated viewers.
assert.NotContains(t, out, `href="https://git.example"`)
assert.Contains(t, out, "Log in")
assert.Contains(t, out, "Register")
}
// TestNavBrandWithoutHub covers the instance that runs no hub: the site name
// has nowhere else to point, so it falls back to the service root and the
// brand becomes two links to the same place rather than a dead one.
func TestNavBrandWithoutHub(t *testing.T) {
conf := testConf()
delete(conf, "hub.sr.ht")
svc := NewService(conf, "compare.sr.ht")
out := render(t, svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice"))
assert.Contains(t, out, `<a href="/">srht.example</a>`)
assert.Contains(t, out, `<a href="/"><span class="text-danger">compare</span></a>`)
}
// TestNavTemplateEmbeddedPage guards the documented consumption pattern: a
// service view struct embedding Page resolves the promoted fields.
func TestNavTemplateEmbeddedPage(t *testing.T) {
type viewData struct {
Page
Data any
}
svc := NewService(testConf(), "dolt.sr.ht")
v := viewData{Page: svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice")}
out := render(t, v)
assert.Contains(t, out, `<span class="text-danger">dolt</span>`)
assert.Contains(t, out, "Logged in as")
}
// renderList executes the srht-repo-list partial alone against v.
func renderList(t *testing.T, v RepoList) string {
t.Helper()
tpl := MustAttach(template.New("t"))
tpl, err := tpl.Parse(`{{template "srht-repo-list" .}}`)
require.NoError(t, err)
var b strings.Builder
require.NoError(t, tpl.Execute(&b, v))
return b.String()
}
func TestRepoListCards(t *testing.T) {
out := renderList(t, RepoList{Items: []ListItem{
{Href: "/~a/pub", Title: "~a/pub", Visibility: "PUBLIC", Description: "described"},
{Href: "/~a/unl", Title: "~a/unl", Visibility: "UNLISTED"},
{Href: "/~a/prv", Title: "~a/prv", Visibility: "PRIVATE"},
{Href: "/~a/none", Title: "~a/none"}, // service without visibility (spec spaces)
}})
assert.Equal(t, 4, strings.Count(out, `class="event"`))
assert.Contains(t, out, "<p>described</p>")
// PUBLIC and empty visibility render no label; the others render lowercase.
assert.Equal(t, 1, strings.Count(out, ">unlisted</small>"))
assert.Equal(t, 1, strings.Count(out, ">private</small>"))
assert.Equal(t, 2, strings.Count(out, "pull-right"))
}
// TestOptionalColumnsRenderOnlyWhenCarried is the rule that keeps all four
// listing services consumers: dolt has no timestamp in its schema, bench and
// spec do, and neither may force a column of blanks on the other.
func TestOptionalColumnsRenderOnlyWhenCarried(t *testing.T) {
at := time.Now().Add(-3 * time.Hour)
bare := renderList(t, RepoList{Items: []ListItem{{Href: "/a", Title: "~a/db"}}})
assert.NotContains(t, bare, "text-muted\">\n", "no empty metadata line")
assert.NotContains(t, bare, "ago")
rich := renderList(t, RepoList{Items: []ListItem{
{Href: "/a", Title: "~a/repo", Updated: at, Meta: []string{"12 runs"}},
}})
assert.Contains(t, rich, "3 hours ago")
assert.Contains(t, rich, AbsTime(at), "the exact stamp rides in the title")
assert.Contains(t, rich, "12 runs")
}
// renderTable executes the srht-repo-table partial alone against v.
func renderTable(t *testing.T, v RepoList) string {
t.Helper()
tpl := MustAttach(template.New("t"))
tpl, err := tpl.Parse(`{{template "srht-repo-table" .}}`)
require.NoError(t, err)
var b strings.Builder
require.NoError(t, tpl.Execute(&b, v))
return b.String()
}
func TestRepoTableIsTheSameDotAsTheCards(t *testing.T) {
at := time.Now().Add(-2 * 24 * time.Hour)
list := RepoList{
Items: []ListItem{
{Href: "/a", Title: "~a/one", Visibility: "PRIVATE", Updated: at},
{Href: "/b", Title: "~a/two", Description: "described"},
},
Empty: "No repositories yet.",
}
out := renderTable(t, list)
assert.Equal(t, 2, strings.Count(out, "<tr>"))
assert.Contains(t, out, `<a href="/a">~a/one</a>`)
assert.Contains(t, out, ">private</small>")
assert.Contains(t, out, "2 days ago")
assert.Contains(t, out, "described")
// The second row carries no time, so it grows no cell for one.
assert.Equal(t, 1, strings.Count(out, "text-right"))
assert.Contains(t, renderTable(t, RepoList{Empty: "No repositories yet."}),
"No repositories yet.")
}
func TestRepoListEmptyState(t *testing.T) {
out := renderList(t, RepoList{Empty: "No databases yet."})
assert.NotContains(t, out, "event-list")
assert.Contains(t, out, "No databases yet.")
}
// TestLoginURLForMatchesTheNav guards the whole reason the accessor exists: a
// handler redirecting to login must land the viewer exactly where the nav's
// "Log in" would have.
func TestLoginURLForMatchesTheNav(t *testing.T) {
svc := NewService(testConf(), "compare.sr.ht")
r := httptest.NewRequest("GET", "/~alice/demo?a=1", nil)
assert.Equal(t, svc.Page(r, "t", "").LoginURL, svc.LoginURLFor(r))
assert.Equal(t, "https://meta.example/login?return_to="+
"https%3A%2F%2Fcompare.example%2F~alice%2Fdemo%3Fa%3D1", svc.LoginURLFor(r))
}
// TestHeadLinksAreGuardedAndTheFaviconSurvivesEscaping covers the two ways the
// head links go wrong: an empty href that re-requests the page, and a data:
// URI that html/template rewrites to #ZgotmplZ unless it is a template.URL.
func TestHeadLinksAreGuardedAndTheFaviconSurvivesEscaping(t *testing.T) {
renderHead := func(t *testing.T, p Page) string {
t.Helper()
tpl := MustAttach(template.New("h"))
tpl, err := tpl.Parse(`{{template "srht-head-links" .}}`)
require.NoError(t, err)
var b strings.Builder
require.NoError(t, tpl.Execute(&b, p))
return b.String()
}
svc := NewService(testConf(), "compare.sr.ht")
svc.StyleHref = "/static/main.min.0badc0de.css"
out := renderHead(t, svc.Page(httptest.NewRequest("GET", "/", nil), "t", ""))
assert.Contains(t, out, `<link rel="stylesheet" href="/static/main.min.0badc0de.css">`)
// The "+" of image/svg+xml renders as the HTML entity +, which the
// browser decodes back before the href is ever parsed as a URL.
assert.Contains(t, out, `<link rel="icon" href="data:image/svg+xml,`,
"the default icon must survive the URL filter")
assert.Contains(t, out, "%3Csvg", "and carry its markup")
assert.NotContains(t, out, "ZgotmplZ", "a filtered href would render as this")
// A binary built without a stylesheet, and a service that switched the
// icon off, render neither link rather than an empty one.
bare := NewService(testConf(), "compare.sr.ht")
bare.FaviconHref = ""
out = renderHead(t, bare.Page(httptest.NewRequest("GET", "/", nil), "t", ""))
assert.NotContains(t, out, "<link")
assert.NotContains(t, out, `href=""`)
}
// TestPageCarriesTheServiceAssets pins the slot three services grew their own
// copy of: the layout reads an extra hashed artefact off the chrome, not off
// the page's payload.
func TestPageCarriesTheServiceAssets(t *testing.T) {
svc := NewService(testConf(), "bench.sr.ht")
svc.Assets = map[string]string{"uplot.js": "/static/uplot.0badc0de.js"}
p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice")
assert.Equal(t, "/static/uplot.0badc0de.js", p.Assets["uplot.js"])
// A name the service never registered reads as empty, which is what the
// layout's {{if}} guard is written against.
assert.Empty(t, p.Assets["missing.js"])
assert.Empty(t, NewService(testConf(), "bench.sr.ht").
Page(httptest.NewRequest("GET", "/", nil), "t", "alice").Assets)
}
func TestServiceAccessors(t *testing.T) {
svc := NewService(testConf(), "compare.sr.ht")
assert.Equal(t, "https://compare.example", svc.SelfOrigin())
assert.Equal(t, "https://meta.example", svc.MetaOrigin())
assert.Equal(t, "https://hub.example", svc.HubOrigin())
assert.Equal(t, "srht.example", svc.SiteName())
assert.Equal(t, "production", svc.Environment())
conf := testConf()
delete(conf, "hub.sr.ht")
assert.Empty(t, NewService(conf, "compare.sr.ht").HubOrigin())
}
func TestRelTimeFacesBothDirections(t *testing.T) {
now := time.Now()
assert.Equal(t, "just now", RelTime(now))
assert.Equal(t, "just now", RelTime(now.Add(30*time.Second)))
assert.Equal(t, "3 hours ago", RelTime(now.Add(-3*time.Hour)))
assert.Equal(t, "in 3 hours", RelTime(now.Add(3*time.Hour+time.Minute)))
assert.Equal(t, "1 minute ago", RelTime(now.Add(-time.Minute-time.Second)))
assert.Equal(t, "2 years ago", RelTime(now.Add(-2*365*24*time.Hour)))
assert.Equal(t, "2026-08-08 12:34:56 UTC",
AbsTime(time.Date(2026, 8, 8, 12, 34, 56, 0, time.UTC)))
}
func TestFuncs(t *testing.T) {
assert.Equal(t, "12345678", ShortSHA("1234567890abcdef"))
assert.Equal(t, "abc", ShortSHA("abc"))
for _, name := range []string{"dict", "shortsha", "reltime", "abstime"} {
assert.Contains(t, Funcs(), name)
}
m, err := Dict("a", 1, "b", "x")
require.NoError(t, err)
assert.Equal(t, map[string]any{"a": 1, "b": "x"}, m)
_, err = Dict("a")
require.Error(t, err)
_, err = Dict(1, "v")
require.Error(t, err)
}