search: make the navigation form work from every page (AUD-151)

The nav form carried hx-get="/search" hx-target="#search-results", but
#search-results exists only on /search. On every other page htmx found no
target and silently discarded the response: a search box that looks like it
works and does nothing.

- SearchForm is now a plain GET with no htmx attributes (navigation and the
  landing page), so it navigates to /search?q=… from any page and with
  JavaScript disabled.
- SearchFormLive keeps the in-place swap on the search page, where the target
  does exist. For HX-Request the server now returns the block's *contents*
  (SearchResultsBody), so the fragment no longer nests a second element with
  id="search-results" inside the live region.
- Empty query shows a prompt instead of an empty list. A zero-match query says
  "Nothing found" and offers the six areas plus the evidence index.
- The result count is role="status" inside the existing aria-live container, so
  a screen reader reads "4 results for …" rather than a run of titles.

Tests (internal/server, internal/site):
- the nav form carries no htmx attribute on nine pages, and no built page
  carries an hx-target whose element is missing (walks all of dist/);
- search submitted from /, /edge/ and /platform/ lands on a page with results
  with no HX-Request header, i.e. the JavaScript-disabled path;
- empty query, zero match and the announced count;
- a content change followed by a build is visible in dist/search.db. That is
  the deploy path: deploy/deploy.sh runs `make dist` before it stages a
  release, and search.Build removes and rewrites the index every build.

DoD: search from home, /edge and /platform lands on results; works with
JavaScript disabled; zero-match query shows a message; content change followed
by a deploy is reflected in search; no htmx request targets a missing element.

(cherry picked from commit a56c795768e1e9b10204aae04c3612d7a96775a7)
This commit is contained in:
2026-10-28 15:45:54 +11:00
parent 286aa12bb0
commit d793ab0871
8 changed files with 400 additions and 36 deletions

View File

@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
@@ -274,8 +275,14 @@ func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
if strings.Contains(frag, "<html") {
t.Errorf("htmx search %q returned a whole page, want the results block", tc.q)
}
if !strings.Contains(frag, `id="search-results"`) {
t.Errorf("htmx search %q did not return #search-results", tc.q)
// The fragment is swapped into #search-results with innerHTML, so it
// must not carry the wrapper again: a second element with the same id
// inside the first is invalid and makes the live region announce twice.
if strings.Contains(frag, `id="search-results"`) {
t.Errorf("htmx search %q re-declares #search-results inside itself", tc.q)
}
if !strings.Contains(frag, "results for") {
t.Errorf("htmx search %q returned no results", tc.q)
}
}
}
@@ -369,3 +376,190 @@ func TestFirstByteBudget(t *testing.T) {
t.Errorf("first byte in %s, budget is 100ms", elapsed)
}
}
// navSearchForm isolates the navigation search form's opening tag and markup.
// The layout has one such form on every page; there is nothing to search for if
// it is missing.
func navSearchForm(t *testing.T, path, body string) string {
t.Helper()
marker := strings.Index(body, `class="search nav-search"`)
if marker < 0 {
t.Fatalf("%s: no navigation search form", path)
}
start := strings.LastIndex(body[:marker], "<form")
end := strings.Index(body[marker:], "</form>")
if start < 0 || end < 0 {
t.Fatalf("%s: could not isolate the navigation search form", path)
}
return body[start : marker+end]
}
// TestNavigationSearchNavigatesFromEveryPage is the AUD-151 regression: the nav
// form used to carry hx-target="#search-results", which only exists on /search,
// so htmx silently discarded the response everywhere else. The nav form must be
// a plain GET, and no page may target a fragment it does not carry.
func TestNavigationSearchNavigatesFromEveryPage(t *testing.T) {
ts := newServer(t)
pages := []string{
"/",
"/ai/", "/platform/", "/edge/", "/products/", "/history/", "/about/",
"/evidence/", "/edge/edge-fleet/", "/404.html",
}
for _, path := range pages {
code, _, body := get(t, ts, path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d", path, code)
continue
}
form := navSearchForm(t, path, body)
if strings.Contains(form, "hx-") {
t.Errorf("%s: navigation search form carries an htmx attribute:\n%s", path, form)
}
if !strings.Contains(form, `action="/search"`) {
t.Errorf("%s: navigation search form does not post to /search:\n%s", path, form)
}
if !strings.Contains(form, `method="get"`) {
t.Errorf("%s: navigation search form is not a GET:\n%s", path, form)
}
if strings.Contains(body, `hx-target="#search-results"`) {
t.Errorf("%s: page targets #search-results, which only the search page carries", path)
}
}
}
// TestSearchFromAnyPageLandsOnResults follows the nav form the way a browser with
// JavaScript disabled would: GET the form action with q set, expect a page.
func TestSearchFromAnyPageLandsOnResults(t *testing.T) {
ts := newServer(t)
for _, path := range []string{"/", "/edge/", "/platform/"} {
code, _, body := get(t, ts, path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d", path, code)
continue
}
form := navSearchForm(t, path, body)
if !strings.Contains(form, `action="/search"`) {
t.Errorf("%s: nav form has no /search action to follow", path)
continue
}
// No HX-Request header: this is the no-JavaScript path.
code, _, landed := get(t, ts, "/search?q=zigbee", nil)
if code != http.StatusOK {
t.Errorf("search from %s: status %d", path, code)
continue
}
if !strings.Contains(strings.ToLower(landed), "<!doctype html>") {
t.Errorf("search from %s did not land on a page", path)
}
if !strings.Contains(landed, "results for") || !strings.Contains(landed, "/edge/") {
t.Errorf("search from %s landed on a page with no results", path)
}
}
}
// TestNoHTMXTargetIsMissingFromItsPage walks the whole built site: every
// hx-target selector must name an element in the same document. This is the
// general form of the AUD-151 bug, so the next one fails a test instead of
// failing silently in a browser.
func TestNoHTMXTargetIsMissingFromItsPage(t *testing.T) {
target := regexp.MustCompile(`hx-target="([^"]+)"`)
files := 0
err := filepath.WalkDir(distDir, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(p, ".html") {
return nil
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
files++
body := string(raw)
for _, m := range target.FindAllStringSubmatch(body, -1) {
selector := m[1]
if !strings.HasPrefix(selector, "#") {
continue // closest/find targets are resolved from the trigger
}
id := strings.TrimPrefix(selector, "#")
if !strings.Contains(body, `id="`+id+`"`) {
t.Errorf("%s: hx-target=%q has no matching element", p, selector)
}
}
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", distDir, err)
}
if files == 0 {
t.Fatal("no HTML files scanned; the build fixture is empty")
}
}
// TestSearchEmptyQueryShowsAPrompt covers "an empty query shows a prompt, not an
// empty list".
func TestSearchEmptyQueryShowsAPrompt(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/search", nil)
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "Type one or two words above") {
t.Error("empty search shows no prompt")
}
if strings.Contains(body, `class="results"`) {
t.Error("empty search renders a results list")
}
}
// TestSearchWithNoMatchesSuggestsTheAreas covers "a query with no matches shows a
// clear nothing-found message and suggests browsing the areas".
func TestSearchWithNoMatchesSuggestsTheAreas(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/search?q=appaloosafoundation", nil)
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "Nothing found for") {
t.Error("no nothing-found message")
}
for _, href := range []string{`href="/ai/"`, `href="/platform/"`, `href="/edge/"`, `href="/evidence/"`} {
if !strings.Contains(body, href) {
t.Errorf("nothing-found message does not offer %s", href)
}
}
}
// TestSearchCountIsAnnounced checks the count is inside the polite live region
// and marked as a status, so a screen reader reads "4 results for …" rather than
// an undifferentiated block of titles.
func TestSearchCountIsAnnounced(t *testing.T) {
ts := newServer(t)
_, _, body := get(t, ts, "/search?q=zigbee", nil)
if !strings.Contains(body, `id="search-results" aria-live="polite"`) {
t.Error("results container is not a live region")
}
if !regexp.MustCompile(`<p class="search-count" role="status">\d+ results for`).MatchString(body) {
t.Error("result count is not announced as a status inside the live region")
}
}
// TestHTMXSearchStillReturnsTheFragment keeps the search page's own upgrade: with
// the header set, the handler returns the block and not a whole page.
func TestHTMXSearchStillReturnsTheFragment(t *testing.T) {
ts := newServer(t)
code, _, frag := get(t, ts, "/search?q=zigbee", map[string]string{"HX-Request": "true"})
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if strings.Contains(frag, "<html") {
t.Error("htmx search returned a whole page")
}
if strings.Contains(frag, `id="search-results"`) {
t.Error("htmx search re-declares #search-results inside itself")
}
if !strings.Contains(frag, `role="status"`) || !strings.Contains(frag, "results for") {
t.Error("htmx search did not return the results block")
}
}