Artifact b58de1203c6d69060513264c589788bcad06ba128a1522eca3556637a4a4787c:
- File
internal/web/chrome_test.go
— part of check-in
[8df971b482]
at
2026-08-24 21:19:47
on branch tree-search
— search: the tree is searchable — documentation and source
quarry rendered both as first-class surfaces (the docs tab, the file browser) and could find neither. Fossil searches documentation but not source; this does both, from one pass.
One tarball, not a read per file. cat per path costs a subprocess and a store open each: 2,199 files on sqlite is ~31s, against 2.2s for one archive — the tarball is the whole tree in one pass, which is the shape of the question.
Tip-only. Indexing every revision of every file would dwarf the rest of the index many times over to answer a question nobody asks; what a file used to say is a question its own history answers.
ref carries the version, not just the path: a link has to name the check-in the row was indexed at, and resolving a branch name at click time would be a second source of truth about which tree the row describes.
Binaries and files over 1MB are skipped and counted — a search that silently covers less than the tree beside it is the failure this change is about. (user: dmestas, size: 39922) [annotate] [blame] [check-ins using] [more...]
package web import ( "craftdesign.group/quarry/internal/api" "net/http/httptest" "regexp" "slices" "strings" "testing" "time" "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("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("alpha", "wiki", "wiki", "forum", true, 0)) if !strings.Contains(empty, `class="empty"`) { t.Errorf("empty section unmarked: %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("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("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(baseHTML, "main :not(.tw) > table { display: block; overflow-x: auto;") { t.Error("an unwrapped table would push the page sideways") } if !strings.Contains(baseHTML, ".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(baseHTML, 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(baseHTML, -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(baseHTML, "") 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(baseHTML, "[data-tip]:hover::after {") { t.Error("the tooltip is not hover-scoped") } if strings.Contains(baseHTML, "[data-tip]:focus-visible::after") { t.Error("the tooltip shows on focus, so it sticks open after a click") } if !strings.Contains(baseHTML, "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 := baseHTML[strings.Index(baseHTML, "[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(baseHTML, ".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(baseHTML, "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(baseHTML, `<button class="avatar" popovertarget="acct" title="{{if .SignedIn}}{{.SignedIn}}{{else}}account{{end}}"`) { t.Error("the avatar lost its native tooltip") } if strings.Contains(baseHTML, `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(baseHTML, frag) if i < 0 { continue } start := strings.LastIndex(baseHTML[:i], "<button") end := i + strings.Index(baseHTML[i:], ">") if !strings.Contains(baseHTML[start:end], "aria-label=") { t.Errorf("a data-tip trigger has no aria-label: %s", baseHTML[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(baseHTML, ".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) 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); 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) 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. if !strings.Contains(boardSource[:i], "{{if $.CanWrite}}") { t.Error("the move control is not gated on write capability") } // 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 $here $.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(baseHTML, `<a class="site" href="/" aria-label="quarry">`) { t.Error("the home link has no accessible name") } if !strings.Contains(baseHTML, `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(baseHTML, `<svg class="mark"`) if i < 0 { t.Fatal("the mark is not inline SVG") } mark := baseHTML[i : i+strings.Index(baseHTML[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(baseHTML, 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(baseHTML, `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(baseHTML, `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(baseHTML, "anchor-name: --avatar"); n != 1 { t.Fatalf("anchor-name: --avatar declared %d times, want exactly 1", n) } if !strings.Contains(baseHTML, `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 is 270 and ai-local is 277: 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 _, want := range []string{"hsl(270,", "hsl(277,"} { if !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"] != 40 || fs["stephan"] != 40 { t.Errorf("distinguishable hues were shaded anyway: %v", fs) } }