package web
import (
"fmt"
"net/http/httptest"
"regexp"
"slices"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/idn"
"craftdesign.group/quarry/internal/index"
)
// The layouts are what real corpora actually contain — fossil writes
// millisecond UTC with no zone, and an imported store carries the
// second-precision form too. A layout this misses renders the raw stamp,
// which is ugly but honest; a layout it MISPARSES would silently date
// every page wrong, so the parse is worth pinning.
func TestParseArtifactTimeCoversRealCorpusShapes(t *testing.T) {
want := time.Date(2025, 8, 25, 21, 25, 59, 0, time.UTC)
for _, s := range []string{
"2025-08-25T21:25:59.000", // fossil forum post
"2025-08-25T21:25:59", // second precision
"2025-08-25T21:25:59Z", // RFC3339, quarry's own stamps
"2025-08-25 21:25:59", // space-separated
} {
got, ok := parseArtifactTime(s)
if !ok {
t.Errorf("%q did not parse", s)
continue
}
if !got.Equal(want) {
t.Errorf("%q parsed to %v, want %v", s, got, want)
}
}
if _, ok := parseArtifactTime("last tuesday"); ok {
t.Error("nonsense parsed as a time")
}
}
// An unparseable stamp must be shown, not swallowed. A blank cell where
// a date belongs reads as "no date recorded", which is a different and
// wrong claim about the artifact.
func TestAgoShowsWhatItCannotParse(t *testing.T) {
if got := string(ago("not a date")); got != "not a date" {
t.Errorf("ago(unparseable) = %q, want it passed through", got)
}
if got := string(ago("")); got != "" {
t.Errorf("ago(empty) = %q, want empty", got)
}
// A parseable one keeps the exact value reachable, because a
// relative time is not something you can quote in a bug report.
got := string(ago("2025-08-25T21:25:59.000"))
if !strings.Contains(got, `datetime="2025-08-25T21:25:59Z"`) {
t.Errorf("ago dropped the machine-readable value: %s", got)
}
if !strings.Contains(got, `title="2025-08-25 21:25:59 UTC"`) {
t.Errorf("ago dropped the absolute value: %s", got)
}
}
func TestRelativeWording(t *testing.T) {
now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
at time.Time
want string
}{
{now.Add(-30 * time.Second), "just now"},
{now.Add(-time.Minute), "1 minute ago"},
{now.Add(-5 * time.Minute), "5 minutes ago"},
{now.Add(-time.Hour), "1 hour ago"},
{now.Add(-3 * time.Hour), "3 hours ago"},
// Once the day is no longer today it gets NAMED — the zit#111
// weekday convention, forge-side (UTC, same as the CLI).
{now.Add(-24 * time.Hour), "Sun · 1 day ago"},
{now.Add(-13 * 24 * time.Hour), "Tue · 13 days ago"},
// Calendar days, not hours/24: 2.7 elapsed days spanning three
// calendar days must read as three, or the named weekday and
// the count contradict each other on the page.
{now.Add(-64 * time.Hour), "Fri · 3 days ago"},
// Past a month a date beats a day count; past a year the year is
// the part that carries the information.
{now.Add(-60 * 24 * time.Hour), "Thu · 18 Jun"},
{now.Add(-400 * 24 * time.Hour), "Sun · 13 Jul 2025"},
// Clock skew: a future stamp must not read as "in 3 minutes".
{now.Add(3 * time.Minute), "2026-08-17 12:03"},
} {
if got := relative(tc.at, now); got != tc.want {
t.Errorf("relative(%v) = %q, want %q", tc.at, got, tc.want)
}
}
}
// The nav's job is orientation, so the two states that carry it — where
// you are, and where there is nothing to find — must both survive into
// the markup, and must not collide into one attribute.
func TestNavlinkStates(t *testing.T) {
current := string(navlink("/r/alpha", "forum", "forum", "forum", true, 12))
if !strings.Contains(current, `class="here"`) || !strings.Contains(current, `aria-current="page"`) {
t.Errorf("current section unmarked: %s", current)
}
if !strings.Contains(current, `>12</span>`) {
t.Errorf("count missing: %s", current)
}
empty := string(navlink("/r/alpha", "wiki", "wiki", "forum", true, 0))
if !strings.Contains(empty, `class="empty"`) {
t.Errorf("empty section unmarked: %s", empty)
}
// No digit at zero, on any tab in the repository bar. The tab is
// dimmed instead, which keeps the bar's shape and still says the
// section is empty — eight tabs of noughts is a row a reader has to
// filter before finding the numbers they came for.
if strings.Contains(empty, `<span class="n"`) {
t.Errorf("FAIL: a counted section at zero drew a badge: %s", empty)
}
if !strings.Contains(empty, `href="/r/alpha/wiki"`) {
t.Errorf("empty section stopped being a link: %s", empty)
}
// Current AND empty is one class attribute, not two — a browser
// honours only the first, so the second state would vanish.
both := string(navlink("/r/alpha", "wiki", "wiki", "wiki", true, 0))
// Count within the ANCHOR tag only; the count badge carries its own
// class and would otherwise be mistaken for a duplicate.
anchor, _, _ := strings.Cut(both, ">")
if strings.Count(anchor, "class=") != 1 {
t.Errorf("expected exactly one class attribute on the anchor: %s", both)
}
if !strings.Contains(anchor, "here") || !strings.Contains(anchor, "empty") {
t.Errorf("both states not preserved: %s", both)
}
// A section with nothing countable shows no badge rather than "0".
// The ink span is the active tab's underline, not a badge.
files := string(navlink("/r/alpha", "tree", "files", "tree", false, false))
if strings.Contains(strings.ReplaceAll(files, `<span class="ink" aria-hidden="true"></span>`, ""), "<span") {
t.Errorf("uncountable section rendered a badge: %s", files)
}
}
// The marker has to follow a reader INTO a section, not just light up on
// its index page — otherwise it goes dark exactly when orientation
// matters most.
func TestSectionOfFollowsDeepPages(t *testing.T) {
for path, want := range map[string]string{
"/r/alpha": "tree",
"/r/alpha/tree": "tree",
"/r/alpha/file/trunk/README.md": "tree",
"/r/alpha/history/trunk/README.md": "tree",
"/r/alpha/timeline": "timeline",
"/r/alpha/checkin/abc123": "timeline",
"/r/alpha/diff": "timeline",
"/r/alpha/wiki": "wiki",
"/r/alpha/wiki/Home/history": "wiki",
"/r/alpha/ticket/abc/edit": "ticket",
"/r/alpha/forum/abc": "forum",
"/fleet": "",
} {
if got := sectionOf(path); got != want {
t.Errorf("sectionOf(%q) = %q, want %q", path, got, want)
}
}
}
// Badges exist so the numbers line up down the column, and a compact
// duration is what makes that possible — "34 minutes ago" is a different
// width in every row.
func TestAgoShortIsCompact(t *testing.T) {
for _, tc := range []struct{ d, want string }{
{"30s", "now"}, {"34m", "34m"}, {"12h", "12h"},
{"72h", "3d"}, {"9000h", "1y"},
} {
dur, err := time.ParseDuration(tc.d)
if err != nil {
t.Fatal(err)
}
stamp := time.Now().UTC().Add(-dur).Format("2006-01-02T15:04:05")
got := string(agoShort(stamp))
if !strings.Contains(got, ">"+tc.want+"<") {
t.Errorf("agoShort(-%s) = %q, want %q", tc.d, got, tc.want)
}
// The exact instant stays reachable; a badge is a summary, not a
// replacement for the value.
if !strings.Contains(got, "datetime=") || !strings.Contains(got, "title=") {
t.Errorf("agoShort dropped the precise value: %q", got)
}
}
if agoShort("not a date") != "" {
t.Error("agoShort invented a duration for an unparseable stamp")
}
}
// The nav's width was a function of its numbers: a repository with
// 25,557 check-ins rendered a visibly different nav from one with none,
// and on a phone it wrapped to three lines. Four characters is the
// budget; the exact figure lives in the badge's title.
func TestCompactCountIsBounded(t *testing.T) {
for _, tc := range []struct {
n int
want string
}{
{0, "0"}, {19, "19"}, {503, "503"}, {999, "999"},
{1000, "1K"}, {1100, "1.1K"}, {5809, "5.8K"}, {9999, "10K"},
{25557, "25K"}, {42118, "42K"}, {999999, "999K"},
{1000000, "1M"}, {1234567, "1.2M"}, {25000000, "25M"},
{-5, "0"},
} {
if got := compactCount(tc.n); got != tc.want {
t.Errorf("compactCount(%d) = %q, want %q", tc.n, got, tc.want)
}
if len(compactCount(tc.n)) > 4 {
t.Errorf("compactCount(%d) = %q, over the four-character budget", tc.n, compactCount(tc.n))
}
}
}
// Fossil technote titles carry HTML — "<b>Design Note:</b> About the
// Irreversible Schema Change" is a real one from fossil's own repo.
// Escaping it is safe but reads as literal markup in a list.
func TestPlainTitleStripsMarkup(t *testing.T) {
got := plainTitle(`<b>Design Note:</b> About the "Irreversible Schema Change"`)
want := `Design Note: About the "Irreversible Schema Change"`
if got != want {
t.Errorf("plainTitle = %q, want %q", got, want)
}
// The caller still escapes, so this is a readability pass and not a
// security boundary — but it must not leave a usable tag behind.
if strings.Contains(plainTitle(`<script>alert(1)</script>hi`), "<") {
t.Error("a tag survived stripping")
}
if plainTitle("no markup here") != "no markup here" {
t.Error("plain text was altered")
}
}
// Fossil's ticket status vocabulary is per-repository, so this cannot be
// exhaustive — it must degrade to "open" for words it doesn't know
// rather than mislabel them, and it must not care about case or spacing.
func TestStatusClass(t *testing.T) {
for status, want := range map[string]string{
"Open": "st-open", "Review": "st-open", "Tested": "st-open",
"Closed": "st-done", "Fixed": "st-done", "Verified": "st-done",
" DEFERRED ": "st-done", "duplicate": "st-done",
"Needs Triage": "st-open", "": "st-open",
} {
if got := statusClass(status); got != want {
t.Errorf("statusClass(%q) = %q, want %q", status, got, want)
}
}
}
// A Referer is attacker-controllable, so the theme switcher must never
// bounce a reader off this origin. Only a same-site path survives.
func TestLocalRefRefusesOffsiteReferers(t *testing.T) {
// Only the PATH survives, so an off-origin Referer is reduced to a
// local path rather than refused — what must never happen is a
// return value a browser would read as another origin.
for ref, want := range map[string]string{
"": "/",
"http://evil.example/steal": "/steal",
"//evil.example/steal": "/steal",
"https://quarry.test/r/x/tree?v=ab": "/r/x/tree?v=ab",
"/r/x/wiki": "/r/x/wiki",
"javascript:alert(1)": "/",
"https://evil.example": "/",
} {
req := httptest.NewRequest("POST", "/theme", nil)
if ref != "" {
req.Header.Set("Referer", ref)
}
got := localRef(req)
if got != want {
t.Errorf("localRef(%q) = %q, want %q", ref, got, want)
}
if !strings.HasPrefix(got, "/") || strings.HasPrefix(got, "//") {
t.Errorf("localRef(%q) = %q, which a browser may read as another origin", ref, got)
}
}
}
// Three states in a fixed cycle: a reader who picks light must be able
// to get back to "follow my phone", which a two-way toggle cannot offer.
func TestThemeCycleReturnsToSystem(t *testing.T) {
theme := ""
var seen []string
for range 3 {
next, _, name := themeState(theme)
seen = append(seen, name)
theme = next
}
if want := []string{"system", "light", "dark"}; !slices.Equal(seen, want) {
t.Errorf("cycle = %v, want %v", seen, want)
}
if theme != "" {
t.Errorf("cycle ended at %q, want back at system", theme)
}
// An unrecognized stored value must not strand the control.
if next, _, _ := themeState("chartreuse"); next != "light" {
t.Errorf("unknown theme advanced to %q, want light", next)
}
}
// The search box means "in this section". A section with nothing indexed
// must fall through to everything rather than to an empty scope, which
// would silently find nothing and look like a broken search.
func TestSearchContextPerSection(t *testing.T) {
for section, wantKind := range map[string]string{
"timeline": "checkin", "wiki": "wiki", "ticket": "ticket",
"forum": "forum", "technote": "technote",
// The tree is indexed now — searching from the files tab
// searches files. ci and chat still hold nothing, and fall
// through to everything rather than to an empty scope.
"tree": "file", "docs": "doc",
"ci": "", "chat": "", "": "",
} {
kind, scope := searchContext(section)
if kind != wantKind {
t.Errorf("searchContext(%q) kind = %q, want %q", section, kind, wantKind)
}
if scope == "" {
t.Errorf("searchContext(%q) has no scope word", section)
}
if kind == "" && scope != "everything" {
t.Errorf("searchContext(%q) unindexed section scoped to %q", section, scope)
}
}
// Every scope the results page offers must be a kind the index
// actually knows, or the pill leads to a 404.
for _, sc := range searchScopes {
if sc.Kind != "" && !slices.Contains(index.SearchKinds, sc.Kind) {
t.Errorf("scope %q is not an index kind", sc.Kind)
}
}
}
// The avatar is initials because quarry keeps no avatar images and
// fetching a gravatar would send every reader's identity to a third
// party on every page. It must never render empty or panic on the
// shapes a login name actually takes.
func TestInitials(t *testing.T) {
for name, want := range map[string]string{
"dmestas": "DM",
"dan": "DA",
"d": "D",
"Dan Mestas": "DM",
"dan.mestas": "DM",
"dan-mestas": "DM",
"dan_mestas": "DM",
"[email protected]": "DE",
"": "?",
" ": "?",
"drh": "DR",
"Ægir": "ÆG",
} {
got := initials(name)
if got != want {
t.Errorf("initials(%q) = %q, want %q", name, got, want)
}
if got == "" {
t.Errorf("initials(%q) is empty; the button would render blank", name)
}
}
}
// Templates wrap their tables in .tw, which scrolls. Tables that arrive
// from RENDERED CONTENT — the API doc, a wiki page, a ticket body, a
// forum post — have no wrapper to be put in, and a wide one pushed the
// whole page sideways: /docs overflowed a 320px phone by 208px. Such a
// table has to be its own scroll container, and the ones already in a
// wrapper must stay real tables or their columns stop lining up.
func TestContentTablesScrollThemselves(t *testing.T) {
if !strings.Contains(styleSource, "main :not(.tw) > table { display: block; overflow-x: auto;") {
t.Error("an unwrapped table would push the page sideways")
}
if !strings.Contains(styleSource, ".tw > table { display: table; }") {
t.Error("wrapped tables lost table layout, so their columns will not align")
}
}
// Fossil's own new-ticket template labels private_contact "Not publicly
// visible" and gates it behind capability "e" — view private e-mail
// addresses. quarry rendered it to everyone, then dropped it outright
// because there was no capability to gate on. There is now, so this is
// a GATE: withheld from a reader who does not hold e, shown to one who
// does. Faithfully reproducing a tracker must not mean publishing what
// it withheld — nor permanently hiding it from the people it was for.
func TestPrivateTicketFieldsAreGatedOnCapabilityE(t *testing.T) {
fields := []ticketField{
{Name: "title", Value: "a bug"},
{Name: "status", Value: "Open"},
{Name: "private_contact", Value: "[email protected]"},
{Name: "contact", Value: "[email protected]"},
{Name: "foundin", Value: "2.7"},
{Name: "subsystem", Value: "build"},
}
// Without e: withheld, and the address reaches no rendered value.
td := ticketDetail{Fields: fields}
for _, f := range append(td.Extra(), td.Header()...) {
if privateFields[f.Name] {
t.Errorf("%s is rendered to a reader without e", f.Name)
}
if strings.Contains(f.Value, "@example.com") {
t.Errorf("an e-mail address reached the page through %s", f.Name)
}
}
// The public fields must still be there — the rule is not "hide
// everything unknown".
var seen []string
for _, f := range append(td.Extra(), td.Header()...) {
seen = append(seen, f.Name)
}
for _, want := range []string{"status", "subsystem", "foundin"} {
if !slices.Contains(seen, want) {
t.Errorf("%s went missing; only the private fields drop", want)
}
}
// With e: shown. A capability nobody can exercise is not a
// capability, and dropping the field forever was never the goal.
held := ticketDetail{Fields: fields, ShowPrivate: true}
var got []string
for _, f := range append(held.Extra(), held.Header()...) {
got = append(got, f.Name)
}
for _, want := range []string{"private_contact", "contact"} {
if !slices.Contains(got, want) {
t.Errorf("%s withheld from a reader who holds e", want)
}
}
}
// The change history is a second path to the same values: a J card
// setting private_contact would read as "private_contact — → an e-mail
// address" in the timeline. It follows the same gate.
func TestPrivateFieldChangesFollowTheSameGate(t *testing.T) {
changes := []ticketChange{
{Date: "2026-08-01T09:00:00", Fields: []ticketField{{Name: "status", Value: "Open"}}},
{Date: "2026-08-02T09:00:00", Fields: []ticketField{
{Name: "status", Value: "Review"},
{Name: "private_contact", Value: "[email protected]"}}},
}
hidden := ticketTimeline(ticketDetail{Changes: changes}, nil)
for _, e := range hidden {
for _, d := range e.Deltas {
if privateFields[d.Name] || strings.Contains(d.To, "@example.com") {
t.Errorf("the change history leaked %s to a reader without e", d.Name)
}
}
}
shown := ticketTimeline(ticketDetail{Changes: changes, ShowPrivate: true}, nil)
var found bool
for _, e := range shown {
for _, d := range e.Deltas {
if d.Name == "private_contact" {
found = true
}
}
}
if !found {
t.Error("a reader holding e cannot see a private field change")
}
}
// The mark is embedded, not fetched. The privacy page states that these
// pages load nothing from another host; an icon served from a CDN would
// make that statement false, and a favicon is the asset most likely to
// be added that way without thinking.
func TestFaviconIsEmbeddedAndLinked(t *testing.T) {
if len(faviconICO) == 0 || len(appleTouchIcon) == 0 {
t.Fatal("the icons are not embedded in the binary")
}
// An ICO begins with a 6-byte header: reserved 0, type 1, then the
// image count. Asserting the count catches a single-size file,
// which is what a browser asking for 16px gets served badly.
if len(faviconICO) < 6 || faviconICO[0] != 0 || faviconICO[1] != 0 || faviconICO[2] != 1 {
t.Error("favicon.ico is not an ICO")
}
if n := int(faviconICO[4]) | int(faviconICO[5])<<8; n < 3 {
t.Errorf("favicon.ico holds %d sizes, want at least 16/32/48", n)
}
for _, want := range []string{`href="/favicon.ico"`, `href="/apple-touch-icon.png"`} {
if !strings.Contains(styleSource, want) {
t.Errorf("the layout does not link %s", want)
}
}
// No SUBRESOURCE may come from another host. A plain <a href> is
// navigation and a placeholder is text; what matters is anything
// the page FETCHES — src=, <link href=, and url() in CSS. That is
// the claim the privacy page makes, and it is the one a stray CDN
// icon would break.
sub := regexp.MustCompile(`(?:src|<link[^>]*href)\s*=\s*"([^"]+)"|url\(\s*['"]?([^)'"]+)`)
for _, m := range sub.FindAllStringSubmatch(styleSource, -1) {
ref := m[1] + m[2]
if strings.HasPrefix(ref, "//") || strings.Contains(ref, "://") {
t.Errorf("the layout fetches %q from another host", ref)
}
}
}
// A popover the UA has closed must not still be laid out. The UA
// stylesheet closes one with [popover]:not(:popover-open){display:none},
// and ANY author display declaration on the base beats it — which left
// every menu on every page as an invisible, hit-testable rectangle at
// opacity 0, swallowing clicks over the content beneath it. The rule
// is therefore: display belongs only in the open state.
func TestPopoverDisplayOnlyInTheOpenState(t *testing.T) {
// Comments are stripped first: the explanation next to this rule
// quotes the UA declaration verbatim, so a naive substring search
// finds "display:" in prose and fails on the fix rather than the bug.
css := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(styleSource, "")
for _, base := range []string{".menu {", ".tip {"} {
i := strings.Index(css, base)
if i < 0 {
continue
}
body := css[i : i+strings.Index(css[i:], "}")]
if strings.Contains(body, "display:") {
t.Errorf("%s declares display on the closed state; it will override the UA's display:none and stay clickable:\n%s",
base, body)
}
}
if !strings.Contains(css, ".menu:popover-open { display: flex;") {
t.Error("the open state does not restore display, so the menu would never show")
}
}
// The tooltip follows Basecoat's shape — a ::after carrying
// content: attr(data-tip), no script — and is HOVER-ONLY, clearing the
// moment hover ends. It once also showed on :focus-visible, but a
// click leaves its trigger focused, so the tip stuck open after every
// interaction. Keyboard and screen-reader users get the name from the
// aria-label/title every trigger must also carry.
func TestTooltipClearsAfterHover(t *testing.T) {
if !strings.Contains(styleSource, "[data-tip]:hover::after {") {
t.Error("the tooltip is not hover-scoped")
}
if strings.Contains(styleSource, "[data-tip]:focus-visible::after") {
t.Error("the tooltip shows on focus, so it sticks open after a click")
}
if !strings.Contains(styleSource, "content: attr(data-tip)") {
t.Error("the tooltip text does not come from the attribute")
}
// The anchored variant must stay NAMELESS: a pseudo-element's
// implicit anchor is its own trigger, and a shared anchor-name made
// every tooltip on the page resolve to the last trigger in tree
// order — four column headers, one drifting tip.
// No anchor positioning for tooltips, on evidence: a shared
// anchor-name resolved every tip to the LAST trigger, and the
// implicit-anchor form resolved a column off inside table layout.
// Absolute against the trigger cannot point at the wrong thing.
tip := styleSource[strings.Index(styleSource, "[data-tip] {"):]
tip = tip[:strings.Index(tip, ".colhead {")]
for _, bad := range []string{"anchor-name:", "position-anchor:", "anchor(", "position: fixed"} {
if strings.Contains(tip, bad) {
t.Errorf("tooltip CSS uses %q; both anchor forms mispointed in practice", bad)
}
}
// Column tips open DOWNWARD and grow leftward from the icon: above
// is the scroll container's clipped top edge, and rightward is
// outside the table for the last column.
if !strings.Contains(styleSource, ".colhead[data-tip]::after { bottom: auto; top: calc(100% + 0.35rem);") {
t.Error("column tooltips open upward into the scroll container's clipped edge")
}
// It must never eat clicks — the failure the popover menus already had.
if !strings.Contains(styleSource, "pointer-events: none") {
t.Error("the tooltip is hit-testable and will swallow clicks over the trigger")
}
// The avatar keeps the BUILT-IN tooltip: it opens a popover, and any
// custom tip over a click target lingers over the thing it opened.
if !strings.Contains(styleSource, `<button class="avatar" popovertarget="acct" title="{{if .SignedIn}}{{.SignedIn}}{{else}}account{{end}}"`) {
t.Error("the avatar lost its native tooltip")
}
if strings.Contains(styleSource, `class="avatar" popovertarget="acct" data-tip=`) {
t.Error("the avatar has the custom tooltip again; it sticks after opening the menu")
}
// Every remaining custom-tip trigger still carries a real accessible
// name: hover is unavailable on touch and to a screen reader.
for _, frag := range []string{`data-tip="select the clone command"`} {
i := strings.Index(styleSource, frag)
if i < 0 {
continue
}
start := strings.LastIndex(styleSource[:i], "<button")
end := i + strings.Index(styleSource[i:], ">")
if !strings.Contains(styleSource[start:end], "aria-label=") {
t.Errorf("a data-tip trigger has no aria-label: %s", styleSource[start:end])
}
}
// The fleet's icon column headers carry the custom tip, and their
// hidden-at-narrow label must stay in the accessibility tree:
// display:none there would leave the column unnamed for a reader.
if col := string(tipcol("people", "contributors")); !strings.Contains(col, `data-tip="contributors"`) {
t.Errorf("column headers lost their tooltip: %s", col)
}
if strings.Contains(styleSource, ".collabel { display: none;") {
t.Error("the column label is display:none at narrow widths, silencing its accessible name")
}
}
// Status vocabularies are per repository and drift within one: quarry's
// own store holds "Open" and "open" both. Ungrouped that draws two
// columns where there is one, and the label shown must be the tracker's
// own spelling rather than one this code picked.
func TestBoardGroupsStatusesCaseInsensitively(t *testing.T) {
cols := columnLabels([]ticketSummary{
{ID: "a", Status: "Open"}, {ID: "b", Status: "open"}, {ID: "c", Status: "Open"},
{ID: "d", Status: "Closed"}, {ID: "e", Status: "Fixed"}, {ID: "f", Status: "Fixed"},
}, "status", api.DefaultTicketVocabulary)
seen := map[string]bool{}
for _, c := range cols {
if seen[strings.ToLower(c)] {
t.Errorf("two columns for one status: %v", cols)
}
seen[strings.ToLower(c)] = true
}
if !seen["open"] || !seen["closed"] || !seen["fixed"] {
t.Fatalf("missing a status the tracker uses: %v", cols)
}
// The majority spelling wins, so the board reads the way the tracker
// writes.
if !slices.Contains(cols, "Open") {
t.Errorf("board spelled the status its own way, not the tracker's: %v", cols)
}
// Fossil's workflow order, not size order: a board is read left to
// right the way work moves, so Open precedes Fixed precedes Closed.
if i, j := slices.Index(cols, "Open"), slices.Index(cols, "Fixed"); i > j {
t.Errorf("Fixed sorted before Open: %v", cols)
}
if i, j := slices.Index(cols, "Fixed"), slices.Index(cols, "Closed"); i > j {
t.Errorf("Closed sorted before Fixed: %v", cols)
}
}
// The board was useless because its columns were only the statuses
// already in use: quarry's tracker has only ever written "Open", so it
// drew one column and the move control could only move a ticket to where
// it already was. Lanes must come from the vocabulary, not the data.
func TestBoardDrawsEmptyLanesFromTheVocabulary(t *testing.T) {
cols := columnLabels([]ticketSummary{{ID: "a", Status: "Open"}}, "status", api.DefaultTicketVocabulary)
if len(cols) < 5 {
t.Fatalf("one status drew %d columns; a board needs somewhere to move to: %v",
len(cols), cols)
}
for _, want := range []string{"Open", "Review", "Fixed", "Closed"} {
if !slices.Contains(cols, want) {
t.Errorf("no %q column to move a card into: %v", want, cols)
}
}
}
// Lanes are the reader's choice. The board used to pick a lane field on
// its own when the data varied in one, which put four priority swimlanes
// on a page nobody asked for — logic a reader cannot see. Now "" means
// none, and an unknown lane falls back to none as well.
func TestBoardLanesOnlyWhenAsked(t *testing.T) {
if strings.Contains(boardSource, "autoLane") {
t.Error("the board template references the removed auto lane")
}
mixed := []ticketSummary{
{ID: "a", Status: "Open", Priority: "High"},
{ID: "b", Status: "Open", Priority: "Low"},
}
lanes := buildBoard(mixed, "status", "", api.DefaultTicketVocabulary, cardView{})
if len(lanes) != 1 || lanes[0].Label != "" {
t.Errorf("no lane asked for, got %d lanes (first %q)", len(lanes), lanes[0].Label)
}
// Crossing a field with itself yields a diagonal, not a board.
if got := buildBoard(mixed, "priority", "priority", api.DefaultTicketVocabulary, cardView{}); len(got) != 1 || got[0].Label != "" {
t.Error("the grouping field was crossed with itself")
}
}
// Every lane must carry the SAME columns, including ones it has no
// tickets in — a grid whose rows have different columns is not a grid,
// and a card cannot be dropped into a column that was not drawn.
func TestLanesShareOneColumnSet(t *testing.T) {
lanes := buildBoard([]ticketSummary{
{ID: "a", Status: "Open", Priority: "High"},
{ID: "b", Status: "Fixed", Priority: "Low"},
}, "status", "priority", api.DefaultTicketVocabulary, cardView{})
if len(lanes) != 2 {
t.Fatalf("got %d lanes, want 2", len(lanes))
}
if len(lanes[0].Columns) != len(lanes[1].Columns) {
t.Fatalf("lanes have different column counts: %d vs %d",
len(lanes[0].Columns), len(lanes[1].Columns))
}
for i := range lanes[0].Columns {
if lanes[0].Columns[i].Status != lanes[1].Columns[i].Status {
t.Errorf("column %d differs between lanes: %q vs %q",
i, lanes[0].Columns[i].Status, lanes[1].Columns[i].Status)
}
}
// High before Low: the vocabulary's order, not the data's.
if lanes[0].Label != "High" {
t.Errorf("lanes[0] = %q, want High first", lanes[0].Label)
}
}
// The board's move control is the mechanism, not a fallback: it must be
// real submitting markup, and it must be gated on write CAPABILITY. A
// reader with Basic auth can write and is not "signed in", and gating
// on session state hid the only way such a reader could move a card.
func TestBoardMoveControlIsPlainMarkup(t *testing.T) {
i := strings.Index(boardSource, `popovertarget="mv-{{$id}}"`)
if i < 0 {
t.Fatal("the board has no Move menu")
}
// Gated on write CAPABILITY, not session state: a reader with Basic
// auth can write and is not "signed in", and gating on the session
// hid the only way such a reader could move a card.
//
// Asserted against the CARD's own context, not the page's. The card
// is a shared component now (three surfaces render it), so it reads
// .CanWrite off the cardView it was handed rather than $.CanWrite
// off whatever page it sits on — which is the whole reason a second
// board could be built at all.
if !strings.Contains(boardSource[:i], "{{if .CanWrite}}") {
t.Error("the move control is not gated on write capability")
}
// Moveable is CanWrite AND somewhere to move to, so a surface with
// no columns draws no menu rather than an empty one.
if !strings.Contains(boardSource, "{{if .Moveable}}") {
t.Error("the Move menu is not gated on there being anywhere to move")
}
// The menu must offer every column the board draws (less the one
// the card is in). Offering only the occupied ones is what made the
// board useless: quarry's tracker has one status in use, so the only
// move was to where it already was.
if !strings.Contains(boardSource, `{{range .ColumnLabels}}{{if ne . (fieldOf $c.T $c.GroupBy)}}<form method="post"`) {
t.Error("the Move menu does not offer the full column set")
}
// Drag is an ADDITION. The menu's forms are plain posts — a native
// popover of submit buttons — so a reader without script can move a
// card; nothing in it is hidden behind a signal.
menu := boardSource[strings.Index(boardSource, `<div id="mv-{{$id}}" popover`):]
menu = menu[:strings.Index(menu, "</div>")]
if strings.Contains(menu, "data-attr") || strings.Contains(menu, "data-show") {
t.Error("the plain move control is hidden behind a signal")
}
if strings.Contains(boardSource, "pick up") || strings.Contains(boardSource, "data-attr-hidden") {
t.Error("the pick-up mode is back; it never rendered (data-attr-hidden) and a mode is not a move control")
}
}
// The mark replaced the "quarry" wordmark, so the link must keep the
// name the word carried — otherwise the forge's home link is unnamed
// for a screen reader and for anyone with images off.
//
// It is inline SVG in currentColor rather than an image: one shape for
// both themes and every size, which retired two themed PNGs and a
// palette entry that had to be declared three times to stay honest.
func TestHeaderMarkKeepsItsAccessibleName(t *testing.T) {
if !strings.Contains(styleSource, `<a class="site" href="/" aria-label="quarry">`) {
t.Error("the home link has no accessible name")
}
if !strings.Contains(styleSource, `role="img" aria-label="quarry"`) {
t.Error("the mark is not named for assistive technology")
}
// currentColor, or it stops following the theme and needs assets back.
i := strings.Index(styleSource, `<svg class="mark"`)
if i < 0 {
t.Fatal("the mark is not inline SVG")
}
mark := styleSource[i : i+strings.Index(styleSource[i:], "</svg>")]
if !strings.Contains(mark, "currentColor") {
t.Error("the mark does not use currentColor, so it cannot follow the theme")
}
if strings.Contains(mark, "#") || strings.Contains(mark, "var(--") {
t.Error("the mark hard-codes a colour; it must inherit")
}
// The tail must stay ATTACHED to the bowl — its round cap overlaps
// the ring stroke — or the mark reads as a magnifying glass, in a
// header that has a search box beside it. Current geometry: ring
// centre (11,12), outer radius 8.6; the tail starts on that edge at
// 45° and the 1.4-unit cap reaches back into the stroke.
if !strings.Contains(mark, `d="M17.1 18.1 L19.2 20.2"`) {
t.Error("the tail geometry changed; check it still reads as a Q and not a magnifier")
}
}
// og:image is fetched by a scraper on its own machine, so it must be
// absolute. A site-relative path here is the classic failure: it renders
// fine, validates fine by eye, and yields a blank card everywhere.
//
// The origin has to come from the request rather than a configured base,
// because quarry is reached by tailnet IP, by localhost and by a public
// hostname in the same deployment, and a card should point back at the
// address the reader actually followed.
func TestSocialCardImageIsAbsolute(t *testing.T) {
for _, tag := range []string{
`<meta property="og:image" content="{{.Origin}}/assets/quarry.png"`,
`<meta property="og:url" content="{{.Origin}}{{.Path}}"`,
`<meta name="twitter:card" content="summary">`,
} {
if !strings.Contains(styleSource, tag) {
t.Errorf("missing social tag: %s", tag)
}
}
// "timeline" alone names no repository, and a link to a repo page is
// the link people actually share.
if !strings.Contains(styleSource, `content="{{if and .Repo (ne .Repo .Title)}}{{.Repo}} — {{end}}{{.Title}}"`) {
t.Error("og:title does not carry the repository")
}
// A wide card letterboxes a square logo into a thin band.
if strings.Contains(styleSource, `content="summary_large_image"`) {
t.Error("the mark is square; the wide card crops it to a band")
}
}
// A proxy terminates TLS, so the scheme quarry sees on the wire is not
// the scheme the reader used. Getting this wrong emits http:// image
// URLs on an https:// page, which scrapers and browsers both drop.
func TestOriginFollowsTheProxy(t *testing.T) {
for _, tc := range []struct {
name string
fwd string
want string
}{
{"plain", "", "http://example.test"},
{"behind a TLS proxy", "https", "https://example.test"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.test/r/quarry/timeline", nil)
if tc.fwd != "" {
req.Header.Set("X-Forwarded-Proto", tc.fwd)
}
if got := origin(req); got != tc.want {
t.Errorf("origin = %q, want %q", got, tc.want)
}
// The clone address must not have drifted from the origin
// it is now built out of.
if got, want := cloneURL(req, "quarry"), tc.want+"/r/quarry"; got != want {
t.Errorf("cloneURL = %q, want %q", got, want)
}
})
}
}
// Priority order must put the vocabulary's own order first and sort the
// untriaged last — a ticket nobody has ranked is not more urgent than
// one somebody ranked Low. Severity breaks ties; equal triage is a
// queue by age. Recency must NOT be in the key: position in this view
// changes only when a human changes the triage, never because a
// comment landed somewhere in the tier.
func TestByTriageSortsVocabularyFirstUnsetLast(t *testing.T) {
ts := []ticketSummary{
{ID: "unset", Created: "1"},
{ID: "low", Priority: "Low", Created: "9"},
{ID: "odd", Priority: "Someday", Created: "2"},
{ID: "imm", Priority: "immediate", Created: "5"}, // case-folded
{ID: "hi-sev", Priority: "High", Severity: "Critical", Created: "9"},
{ID: "hi", Priority: "High", Severity: "Minor", Created: "1"},
}
byTriage(ts, api.DefaultTicketVocabulary)
var got []string
for _, x := range ts {
got = append(got, x.ID)
}
want := []string{"imm", "hi-sev", "hi", "low", "odd", "unset"}
if !slices.Equal(got, want) {
t.Fatalf("order = %v, want %v", got, want)
}
// Two tickets with identical triage are a queue: the one that has
// waited longest leads, and touching the newer one must not reorder.
tie := []ticketSummary{
{ID: "younger", Priority: "High", Created: "2", Updated: "9"},
{ID: "older", Priority: "High", Created: "1", Updated: "1"},
}
byTriage(tie, api.DefaultTicketVocabulary)
if tie[0].ID != "older" {
t.Fatalf("equal triage ordered by recency, not age: %v then %v", tie[0].ID, tie[1].ID)
}
}
// The whole point of .quarry/tickets.json: list order is sort order,
// in the PROJECT's words. A repo that adopts P0/P1/P2 must get P0
// first — under the default vocabulary those words are all unknown and
// the sort collapses to age, which is what this feature removes.
func TestCustomVocabularyDrivesOrderAndColumns(t *testing.T) {
vocab := map[string][]string{
"priority": {"P0", "P1", "P2"},
"status": {"Todo", "Doing", "Done"},
}
ts := []ticketSummary{
{ID: "b", Priority: "P2", Created: "1"},
{ID: "c", Priority: "Immediate", Created: "1"}, // not in THIS repo's words
{ID: "a", Priority: "p0", Created: "9"}, // case-folded
}
byTriage(ts, vocab)
if ts[0].ID != "a" || ts[1].ID != "b" {
t.Fatalf("order = %s,%s,%s; want a,b,c — the file's order did not drive the sort",
ts[0].ID, ts[1].ID, ts[2].ID)
}
cols := columnLabels([]ticketSummary{{ID: "x", Status: "Doing"}}, "status", vocab)
if len(cols) < 3 || cols[0] != "Todo" || cols[1] != "Doing" || cols[2] != "Done" {
t.Fatalf("columns = %v; want the file's words in the file's order", cols)
}
for _, c := range cols {
if c == "Open" || c == "Verified" {
t.Fatalf("fossil's defaults leaked into a repo that replaced them: %v", cols)
}
}
}
// The account menu anchors to --avatar. That name must be claimed by
// exactly one selector — the account BUTTON — never by the .avatar
// class: identicons reuse the class by the hundreds on /people, and a
// class-wide anchor-name silently re-anchors the popover to the last
// avatar on the page (off-screen, "the menu stopped opening").
func TestMenuAnchorIsScopedToTheAccountButton(t *testing.T) {
if n := strings.Count(styleSource, "anchor-name: --avatar"); n != 1 {
t.Fatalf("anchor-name: --avatar declared %d times, want exactly 1", n)
}
if !strings.Contains(styleSource, `button.avatar[popovertarget="acct"] { anchor-name: --avatar;`) {
t.Fatal("the --avatar anchor is not scoped to the account button")
}
}
// The spectrum is a thread's fingerprint, so two voices must never paint one
// block. Hues come from names and near-collisions are a birthday bound — ten
// names in this corpus put their closest pair three degrees apart — so the
// bar resolves what is in the thread rather than pretending it will not
// happen.
//
// The rule it must not break while doing so: a person's HUE never moves. It
// is the identity, and a face that changes colour by context is not a face.
func TestSpectrumSeparatesVoicesWithoutMovingTheirHue(t *testing.T) {
// dmestas and ai-local are seven degrees apart, which at six
// pixels is one colour.
posts := []forumPostRow{
{Hash: strings.Repeat("a", 40), User: "dmestas"},
{Hash: strings.Repeat("b", 40), User: "ai-local"},
{Hash: strings.Repeat("c", 40), User: "dmestas"},
}
shade := spectrumShades(posts)
if shade["dmestas"] == shade["ai-local"] {
t.Errorf("two near-identical hues took the same lightness: %v", shade)
}
// One voice, one tone — posting twice must not read as two people.
if len(shade) != 2 {
t.Errorf("a voice was assigned more than one shade: %v", shade)
}
svg := string(spectrumSVG(posts))
// Every segment still carries its author's own hue.
for _, who := range []string{"dmestas", "ai-local"} {
if want := fmt.Sprintf(" %d)", idn.Hue(who)); !strings.Contains(svg, want) {
t.Errorf("a voice lost its hue: %q missing from\n%s", want, svg)
}
}
// And the bar stays continuous: no gaps between segments.
if strings.Contains(svg, "opacity=\"0\"") {
t.Error("the bar was broken up rather than shaded")
}
// Voices that are already far apart keep the default tone, so a
// thread that reads well today is not repainted.
far := []forumPostRow{
{Hash: strings.Repeat("d", 40), User: "dmestas"},
{Hash: strings.Repeat("e", 40), User: "stephan"},
}
fs := spectrumShades(far)
if fs["dmestas"] != 56 || fs["stephan"] != 56 {
t.Errorf("distinguishable hues were shaded anyway: %v", fs)
}
}
// Every repository page follows the repository's revision stream to
// keep its tabs current — the Files page as much as the timeline — and
// a page whose body already subscribes does not subscribe twice.
func TestRepoChromeFollowsTheRevisionStream(t *testing.T) {
var d pageData
d.Repo, d.Section, d.TabsLive = "alpha", "tree", true
html := renderBase(t, d)
for _, want := range []string{
`data-init="@get('/r/alpha/timeline.rev')"`,
`@get('/r/alpha/nav.frag?s=tree')`,
} {
if !strings.Contains(html, want) {
t.Errorf("FAIL: the chrome does not follow the stream: missing %s", want)
}
}
d.TabsLive = false
if html := renderBase(t, d); strings.Contains(html, "timeline.rev") {
t.Error("FAIL: a page whose body is live subscribed a second time from the chrome")
}
t.Log("ok: the chrome subscribes once for pages whose body does not")
}
// Your tickets is a control in the bar, not a row in the account menu:
// behind a popover, the only door to a person's cross-repository queue
// was a ticket that had already reached them.
func TestYourTicketsIsAControlInTheBar(t *testing.T) {
render := func(d pageData) string {
t.Helper()
var buf strings.Builder
if err := explorePage.Execute(&buf, d); err != nil {
t.Fatal(err)
}
return buf.String()
}
body := render(pageData{Title: "Home", SignedIn: "dmestas", HasInbox: true})
if !strings.Contains(body, `<a class="bar-btn" href="/tickets"`) {
t.Fatalf("FAIL: the bar has no tickets control")
}
if !strings.Contains(body, `#i-tkt-open`) {
t.Errorf("FAIL: the tickets control has no icon")
}
// Counted, not matched loosely: one door, and it is the bar's.
if n := strings.Count(body, `href="/tickets"`); n != 1 {
t.Errorf("FAIL: %d links to /tickets, want 1 (the bar's)", n)
}
if strings.Contains(body, `>Your tickets</a>`) {
t.Errorf("FAIL: the account menu still carries the tickets row")
}
// Signed out there is no queue to show, as there was no row before.
if out := render(pageData{Title: "Home"}); strings.Contains(out, `href="/tickets"`) {
t.Errorf("FAIL: a signed-out visitor is offered your tickets")
}
}
// The account menu does not repeat the inbox. The bar beside it
// carries the inbox with its unread badge — the one thing a menu row
// could not show without being opened — so a row there was a second
// door to the same page, one click further away, saying less.
func TestTheAccountMenuDoesNotRepeatTheInbox(t *testing.T) {
var buf strings.Builder
if err := explorePage.Execute(&buf, pageData{Title: "Home",
SignedIn: "dmestas", HasInbox: true}); err != nil {
t.Fatal(err)
}
body := buf.String()
// The bar's control is still there, badge and all.
if !strings.Contains(body, `class="bar-btn inboxbtn"`) {
t.Fatalf("FAIL: the bar lost the inbox")
}
if !strings.Contains(body, `id="inbox-dot"`) {
t.Errorf("FAIL: the bar lost the unread badge")
}
// And the menu row is gone. Counted rather than matched loosely:
// the bar's own link is also href="/inbox".
if n := strings.Count(body, `href="/inbox"`); n != 1 {
t.Errorf("FAIL: %d links to /inbox, want 1 (the bar's)", n)
}
if strings.Contains(body, `<a href="/inbox">Inbox</a>`) {
t.Errorf("FAIL: the account menu still repeats the inbox")
}
}