Skip to content
quarry Artifact [df5dcd129c]
Login

Artifact [df5dcd129c]

Artifact df5dcd129c8fab1bffa89a270e3c23bf60d47df19d4f4ee4a138f274a0a57b41:


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)
	}
	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], "&amp;", "&"))
	}
	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&amp;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&amp;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")
	}
}