package web
import (
"context"
"fmt"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
const (
sgOpen = "aaaa000000000000000000000000000000000000000000000000000000000001"
sgClosed = "bbbb000000000000000000000000000000000000000000000000000000000002"
)
// suggestIndex is a repository with two stylesheet tickets, #1 open and
// #2 closed, and three check-ins, two of which mention the stylesheet.
func suggestIndex(t *testing.T) *index.Index {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
ticket := func(id, date, title, status string) index.Parsed {
return index.Parsed{Hash: id, Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: id, Date: date, User: "dan",
Fields: []artifact.Field{{Name: "title", Value: title}, {Name: "status", Value: status}}}}
}
batch := []index.Parsed{
ticket(sgOpen, "2026-09-01T10:00:00", "the stylesheet breaks on a phone", "Open"),
ticket(sgClosed, "2026-09-02T10:00:00", "stylesheet colours fail contrast", "Closed"),
}
var prev string
for i, c := range []string{"plain work", "the stylesheet gets its own file", "stylesheet ships lean"} {
art := &artifact.Artifact{Type: artifact.Manifest,
Date: fmt.Sprintf("2026-09-17T10:%02d:00", i), User: "dan", Comment: c}
if prev != "" {
art.Parents = []string{prev}
}
prev = fmt.Sprintf("c%d", i) + strings.Repeat("0", 39)
batch = append(batch, index.Parsed{Hash: prev, Art: art})
}
if err := ix.FoldNow(batch); err != nil {
t.Fatal(err)
}
if err := ix.ReplaceTree(prev, []index.TreeText{{Path: "STYLEGUIDE.md", Body: "how the stylesheet is laid out", Doc: true}}); err != nil {
t.Fatal(err)
}
return ix
}
// suggestServer serves "alpha" and a private "secret" holding the same
// words, which no signed-out reader may see.
func suggestServer(t *testing.T) *httptest.Server {
t.Helper()
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: suggestIndex(t)}, "secret": {Index: suggestIndex(t)}}),
Local: db, Idem: db, Numbers: db,
CanRead: func(ctx context.Context, repo string) bool { return repo != "secret" }}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithPrivateRepos(func(repo string) bool { return repo == "secret" }),
}))
t.Cleanup(srv.Close)
return srv
}
var sgHref = regexp.MustCompile(`<a class="sg-it" href="([^"]+)" data-hook="suggest-item"`)
func suggestHrefs(body string) []string {
var out []string
for _, m := range sgHref.FindAllStringSubmatch(body, -1) {
out = append(out, strings.ReplaceAll(m[1], "&", "&"))
}
return out
}
// The repository box offers what its section's search would find, under
// that section's scope, then the full results and everything; an empty
// box offers nothing.
func TestRepoBoxSuggestsWithinItsScope(t *testing.T) {
srv := suggestServer(t)
code, body := getPage(t, srv, "/r/alpha/search.suggest?q=stylesheet&kind=ticket")
if code != 200 || !strings.Contains(body, `data-hook="search-suggest"`) {
t.Fatalf("FAIL: %d, no list", code)
}
hrefs := suggestHrefs(body)
if len(hrefs) != 2 {
t.Fatalf("FAIL: %d items, want the 2 tickets: %v", len(hrefs), hrefs)
}
for _, h := range hrefs {
if !strings.HasPrefix(h, "/r/alpha/ticket/") {
t.Errorf("FAIL: a ticket-scoped list offers %s", h)
}
}
if n := strings.Count(body, `data-hook="suggest-more"`); n != 2 {
t.Errorf("FAIL: %d ways on, want the scoped results and everything", n)
}
if !strings.Contains(body, `href="/r/alpha/search?q=stylesheet&kind=ticket"`) ||
!strings.Contains(body, `href="/r/alpha/search?q=stylesheet"`) {
t.Error("FAIL: the ways on do not open the full results")
}
if !strings.Contains(body, `data-count="2"`) || !strings.Contains(body, `id="find-suggest-n"`) {
t.Error("FAIL: the list does not carry its count")
}
_, body = getPage(t, srv, "/r/alpha/search.suggest?q=stylesheet")
var tickets, checkins int
for _, h := range suggestHrefs(body) {
switch {
case strings.Contains(h, "/ticket/"):
tickets++
case strings.Contains(h, "/checkin/"):
checkins++
}
}
if tickets != 2 || checkins != 2 {
t.Errorf("FAIL: everything offers %d tickets and %d check-ins, want 2 and 2", tickets, checkins)
}
if n := strings.Count(body, `data-hook="suggest-more"`); n != 1 {
t.Errorf("FAIL: %d ways on when already searching everything, want 1", n)
}
_, empty := getPage(t, srv, "/r/alpha/search.suggest?q=")
if len(suggestHrefs(empty)) != 0 || strings.Contains(empty, "suggest-more") || strings.Contains(empty, "suggest-none") {
t.Error("FAIL: an empty box suggests things")
}
_, none := getPage(t, srv, "/r/alpha/search.suggest?q=zebra")
if len(suggestHrefs(none)) != 0 || !strings.Contains(none, `data-hook="suggest-none"`) {
t.Error("FAIL: words matching nothing do not say so")
}
if code, _ := getPage(t, srv, "/r/alpha/search.suggest?q=x&kind=bogus"); code != 404 {
t.Errorf("FAIL: an unknown scope answered %d", code)
}
}
// A ticket's icon says whether it is open, as on every ticket link.
func TestSuggestedTicketsWearTheirState(t *testing.T) {
srv := suggestServer(t)
_, body := getPage(t, srv, "/r/alpha/search.suggest?q=stylesheet&kind=ticket")
for _, c := range []struct{ id, icon, num string }{{sgOpen, "i-tkt-open", "#1"}, {sgClosed, "i-tkt-done", "#2"}} {
i := strings.Index(body, "/ticket/"+c.id)
if i < 0 {
t.Fatalf("FAIL: ticket %.4s not offered", c.id)
}
end := strings.Index(body[i:], "</a>")
if !strings.Contains(body[i:i+end], `href="#`+c.icon+`"`) {
t.Errorf("FAIL: ticket %.4s does not wear %s", c.id, c.icon)
}
if !strings.Contains(body[i:i+end], "<code>"+c.num+"</code>") {
t.Errorf("FAIL: ticket %.4s is not offered by its number %s", c.id, c.num)
}
}
}
// A ticket number or a hash prefix goes straight to what it names,
// listed first.
func TestBoxJumpsToWhatANumberOrHashNames(t *testing.T) {
srv := suggestServer(t)
for _, q := range []string{"2", "%232"} {
_, body := getPage(t, srv, "/r/alpha/search.suggest?q="+q)
hrefs := suggestHrefs(body)
if len(hrefs) == 0 || hrefs[0] != "/r/alpha/ticket/"+sgClosed {
t.Errorf("FAIL: q=%s first offers %v, want ticket #2", q, hrefs)
}
}
_, body := getPage(t, srv, "/r/alpha/search.suggest?q=c100")
if hrefs := suggestHrefs(body); len(hrefs) == 0 || hrefs[0] != "/r/alpha/info/c100" {
t.Errorf("FAIL: a hash prefix first offers %v, want the check-in it names", hrefs)
}
_, body = getPage(t, srv, "/r/alpha/search.suggest?q=9")
for _, h := range suggestHrefs(body) {
if strings.Contains(h, "/ticket/") {
t.Errorf("FAIL: a number naming no ticket jumped to %s", h)
}
}
}
// Off a repository, the box searches every repository the reader may
// read and says which one each item is in. A private repository is
// never asked.
func TestFleetBoxSuggestsAcrossReadableRepositories(t *testing.T) {
srv := suggestServer(t)
_, body := getPage(t, srv, "/search.suggest?q=stylesheet")
hrefs := suggestHrefs(body)
if len(hrefs) == 0 {
t.Fatal("FAIL: the fleet box offers nothing")
}
for _, h := range hrefs {
if !strings.HasPrefix(h, "/r/alpha/") {
t.Errorf("FAIL: the fleet box offers %s", h)
}
}
if !strings.Contains(body, "<code>#1</code>") {
t.Error("FAIL: a ticket across the fleet is not offered by its number")
}
if !strings.Contains(body, `class="sg-repo"`) {
t.Error("FAIL: items do not say which repository they are in")
}
if !strings.Contains(body, `href="/search?q=stylesheet"`) {
t.Error("FAIL: no way to the full results")
}
if strings.Contains(body, "secret") {
t.Error("FAIL: a private repository answered the fleet box")
}
}
// The boxes are still forms: with scripting off, Enter submits them as
// before. Scripted, each names its list, and the / shortcut uses the
// event spelling Datastar fires.
func TestSearchBoxesKeepTheirForms(t *testing.T) {
srv := suggestServer(t)
for _, c := range []struct{ path, action, suggest string }{
{"/r/alpha/ticket", `action="/r/alpha/search"`, "/r/alpha/search.suggest"},
{"/r/alpha/wiki", `action="/r/alpha/search"`, "/r/alpha/search.suggest"},
{"/search", `action="/search"`, "/search.suggest"},
} {
_, body := getPage(t, srv, c.path)
i := strings.Index(body, `data-hook="search-find"`)
if i < 0 {
t.Errorf("FAIL %s: no search box", c.path)
continue
}
form := body[strings.LastIndex(body[:i], "<form"):]
form = form[:strings.Index(form, "</form>")]
if !strings.Contains(form, c.action) || !strings.Contains(form, `name="q"`) {
t.Errorf("FAIL %s: the box is no longer a plain form", c.path)
}
if !strings.Contains(form, `aria-controls="find-suggest"`) || !strings.Contains(form, `id="find-suggest"`) ||
!strings.Contains(strings.ReplaceAll(form, `\/`, "/"), "@get('"+c.suggest+"'") {
t.Errorf("FAIL %s: the box does not name and fetch its list", c.path)
}
if strings.Contains(body, "data-on-keydown") {
t.Errorf("FAIL %s: a dash-spelled event Datastar never fires", c.path)
}
}
}
// The people filters offer the people the list below would show, each
// opening where that list sends them; a private repository's people
// are never offered across the fleet.
func TestPeopleFiltersSuggest(t *testing.T) {
srv := suggestServer(t)
_, body := getPage(t, srv, "/people.suggest?q=da")
hrefs := suggestHrefs(body)
if len(hrefs) != 1 || hrefs[0] != "/people/dan" {
t.Errorf("FAIL: the fleet filter offers %v, want dan's page", hrefs)
}
if strings.Contains(body, "secret") {
t.Error("FAIL: a private repository answered the fleet filter")
}
if !strings.Contains(body, `href="/people?q=da"`) {
t.Error("FAIL: no way to the filtered list")
}
_, body = getPage(t, srv, "/r/alpha/people.suggest?q=DAN")
if hrefs := suggestHrefs(body); len(hrefs) != 1 || hrefs[0] != "/r/alpha/timeline?user=dan&y=all" {
t.Errorf("FAIL: the repository filter offers %v, want dan's timeline here", hrefs)
}
_, none := getPage(t, srv, "/r/alpha/people.suggest?q=zed")
if len(suggestHrefs(none)) != 0 || !strings.Contains(none, `data-hook="suggest-none"`) {
t.Error("FAIL: a name matching nobody does not say so")
}
for _, path := range []string{"/people", "/r/alpha/people"} {
_, page := getPage(t, srv, path)
i := strings.Index(page, `data-hook="people-find"`)
if i < 0 {
t.Errorf("FAIL %s: no filter", path)
continue
}
form := page[strings.LastIndex(page[:i], "<form"):]
form = form[:strings.Index(form, "</form>")]
if !strings.Contains(form, `name="q"`) || !strings.Contains(form, `id="people-suggest"`) ||
!strings.Contains(strings.ReplaceAll(form, `\/`, "/"), "people.suggest") {
t.Errorf("FAIL %s: the filter is not a form that names and fetches its list", path)
}
}
}
// filesServer is a repository whose tree holds two line-count files in
// different folders and one that only mentions them in its folder.
func filesServer(t *testing.T) (*httptest.Server, string) {
t.Helper()
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
dir := t.TempDir()
run := func(args ...string) string {
cmd := exec.Command("zit", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("zit %v: %v\n%s", args, err, out)
}
return string(out)
}
run("init", ".")
for _, p := range []string{"internal/zitcli/linecount.go", "internal/ingest/linecounts.go",
"linecount/readme.md", "internal/web/web.go"} {
if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, p), []byte("package x\n"), 0o644); err != nil {
t.Fatal(err)
}
run("add", p)
}
run("commit", "-m", "files", "--user", "u")
hash := regexp.MustCompile(`[0-9a-f]{64}`).FindString(run("branch", "info", "trunk"))
if hash == "" {
t.Fatal("no trunk hash")
}
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix, Zit: zitcli.New(dir)}})}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
t.Cleanup(srv.Close)
return srv, hash
}
// On the Files page the header box offers paths in the tree being read,
// the file named by the words first, each opening the file at that
// check-in. Enter still searches the files, contents and all.
func TestFileFinderOffersPathsInTheTree(t *testing.T) {
srv, hash := filesServer(t)
_, body := getPage(t, srv, "/r/alpha/files.suggest/"+hash+"?q=linecount")
want := []string{
"/r/alpha/file/" + hash + "/internal/zitcli/linecount.go",
"/r/alpha/file/" + hash + "/internal/ingest/linecounts.go",
"/r/alpha/file/" + hash + "/linecount/readme.md",
}
if got := suggestHrefs(body); !slices.Equal(got, want) {
t.Errorf("FAIL: the finder offers %v, want %v", got, want)
}
if !strings.Contains(body, `href="/r/alpha/search?q=linecount&kind=file"`) ||
!strings.Contains(body, `href="/r/alpha/search?q=linecount"`) {
t.Error("FAIL: no way to search the files' contents, or everything")
}
_, empty := getPage(t, srv, "/r/alpha/files.suggest/"+hash+"?q=")
if len(suggestHrefs(empty)) != 0 || strings.Contains(empty, "suggest-more") {
t.Error("FAIL: an empty finder suggests things")
}
_, page := getPage(t, srv, "/r/alpha/tree/"+hash)
if strings.Count(page, `type="search"`) != 1 {
t.Error("FAIL: the Files page has a second search box")
}
i := strings.Index(page, `data-hook="search-find"`)
if i < 0 {
t.Fatal("FAIL: the Files page has no search box")
}
form := page[strings.LastIndex(page[:i], "<form"):]
form = form[:strings.Index(form, "</form>")]
if !strings.Contains(form, `action="/r/alpha/search"`) || !strings.Contains(form, `name="kind" value="file"`) ||
!strings.Contains(strings.ReplaceAll(form, `\/`, "/"), "files.suggest/"+hash) {
t.Error("FAIL: the box is not a file search that fetches paths in this tree")
}
}
// Search sits behind the magnifier: a button in the bar's actions opens
// a popover holding the box. Its scope buttons rerun the same words
// wider, the first being what Enter does, and a wider button's empty
// kind comes before the form's hidden one so it is the one read.
func TestSearchLivesBehindTheMagnifier(t *testing.T) {
srv := suggestServer(t)
_, body := getPage(t, srv, "/r/alpha/ticket")
if !strings.Contains(body, `popovertarget="find"`) || !strings.Contains(body, `aria-label="search tickets"`) {
t.Error("FAIL: no magnifier that opens the search panel and names its scope")
}
p := strings.Index(body, `id="find" popover`)
if p < 0 {
t.Fatal("FAIL: no search panel")
}
panel := body[p:]
panel = panel[:strings.Index(panel, "</form>")]
if !strings.Contains(panel, `data-hook="search-find"`) || !strings.Contains(panel, "autofocus") {
t.Error("FAIL: the panel does not hold the box, or opening it does not focus the box")
}
in := panel[strings.Index(panel, `data-hook="find-in"`):]
in = in[:strings.Index(in, "</div>")]
first := in[strings.Index(in, "<button"):]
if !strings.HasPrefix(first, `<button type="submit" class="on" name="kind" value="ticket"`) {
t.Errorf("FAIL: Enter does not search tickets here: %.80s", first)
}
if !strings.Contains(in, `<button type="submit" name="kind" value="">`) ||
!strings.Contains(in, `formaction="/search" name="kind" value="">`) {
t.Error("FAIL: no way to rerun the words across the repository, or every repository")
}
if strings.Index(panel, `data-hook="find-in"`) > strings.Index(panel, `type="hidden" name="kind"`) {
t.Error("FAIL: the hidden kind comes first, so a wider button would not widen")
}
_, top := getPage(t, srv, "/search")
if strings.Contains(top, `data-hook="find-in"`) {
t.Error("FAIL: the forge-wide box offers scopes it has no wider one than")
}
}
// #365: the Docs box suggests a tree document, as its results page
// lists one, and not only wiki pages.
func TestDocsBoxSuggestsTreeDocuments(t *testing.T) {
srv := suggestServer(t)
_, body := getPage(t, srv, "/r/alpha/search.suggest?q=stylesheet&kind=docs")
found := false
for _, h := range suggestHrefs(body) {
if strings.Contains(h, "STYLEGUIDE.md") {
found = true
}
}
if !found {
t.Errorf("FAIL: the docs box does not suggest a tree document: %v", suggestHrefs(body))
}
}