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)
566 lines
19 KiB
Go
566 lines
19 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
|
|
)
|
|
|
|
// The tests run against a real build of the repository's content, because the
|
|
// thing under test is "does the server serve what the build wrote". One build is
|
|
// shared by every test.
|
|
var (
|
|
distDir string
|
|
buildErr error
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
dir, err := os.MkdirTemp("", "ai-resume-server-test-")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
root, err := filepath.Abs("../..")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
s, err := site.New(site.Config{Root: root, OutDir: dir})
|
|
if err == nil {
|
|
err = s.Build()
|
|
}
|
|
buildErr, distDir = err, dir
|
|
|
|
code := m.Run()
|
|
_ = os.RemoveAll(dir)
|
|
os.Exit(code)
|
|
}
|
|
|
|
// newServer starts the site under test. Frames are sent as fast as possible so
|
|
// the stream test does not sleep.
|
|
func newServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
if buildErr != nil {
|
|
t.Fatalf("build fixture: %v", buildErr)
|
|
}
|
|
s, err := New(Config{DistDir: distDir, StreamDelay: time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
ts := httptest.NewServer(s.Handler())
|
|
t.Cleanup(ts.Close)
|
|
return ts
|
|
}
|
|
|
|
func get(t *testing.T, ts *httptest.Server, path string, headers map[string]string) (int, http.Header, string) {
|
|
t.Helper()
|
|
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
|
if err != nil {
|
|
t.Fatalf("request %s: %v", path, err)
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
res, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatalf("get %s: %v", path, err)
|
|
}
|
|
defer res.Body.Close()
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
return res.StatusCode, res.Header, string(body)
|
|
}
|
|
|
|
// TestHealthzSeparatesStaticAndAI is §2.1: readiness for static is its own
|
|
// field and does not depend on any AI service.
|
|
func TestHealthzSeparatesStaticAndAI(t *testing.T) {
|
|
ts := newServer(t)
|
|
code, header, body := get(t, ts, "/healthz", nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("healthz status = %d, want 200; body: %s", code, body)
|
|
}
|
|
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
|
t.Errorf("healthz content-type = %q, want JSON", ct)
|
|
}
|
|
var got struct {
|
|
Status string `json:"status"`
|
|
Static struct {
|
|
Ready bool `json:"ready"`
|
|
Pages int `json:"pages"`
|
|
} `json:"static"`
|
|
AI struct {
|
|
Ready bool `json:"ready"`
|
|
Reason string `json:"reason"`
|
|
} `json:"ai"`
|
|
Search struct {
|
|
Ready bool `json:"ready"`
|
|
Documents int `json:"documents"`
|
|
} `json:"search"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &got); err != nil {
|
|
t.Fatalf("healthz is not JSON: %v\n%s", err, body)
|
|
}
|
|
if !got.Static.Ready {
|
|
t.Error("static.ready = false, want true")
|
|
}
|
|
if got.Static.Pages == 0 {
|
|
t.Error("static.pages = 0, want the built page count")
|
|
}
|
|
if got.AI.Ready {
|
|
t.Error("ai.ready = true, want false in Mode A")
|
|
}
|
|
if got.AI.Reason == "" {
|
|
t.Error("ai.reason is empty; the field must say why AI is not ready")
|
|
}
|
|
if !got.Search.Ready || got.Search.Documents == 0 {
|
|
t.Errorf("search = %+v, want ready with documents", got.Search)
|
|
}
|
|
}
|
|
|
|
// TestPagesArePreRendered checks that the two page kinds render from disk with
|
|
// the chrome, the nav and the panel.
|
|
func TestPagesArePreRendered(t *testing.T) {
|
|
ts := newServer(t)
|
|
cases := []struct {
|
|
path string
|
|
wants []string
|
|
}{
|
|
{"/", []string{"Sam Rolfe", `data-stage-driver="route"`, `id="stage-content"`, "Presentation panel", "Skip to content"}},
|
|
{"/edge/", []string{"Edge", `data-stage-driver="route"`, "Breadcrumb"}},
|
|
{"/evidence/", []string{"Evidence index", `class="evidence"`}},
|
|
{"/stream/", []string{`data-stage-driver="stream"`, `sse-connect="/api/stage/stream"`, `sse-swap="stage"`, "/static/sse.js"}},
|
|
}
|
|
for _, tc := range cases {
|
|
code, header, body := get(t, ts, tc.path, nil)
|
|
if code != http.StatusOK {
|
|
t.Errorf("%s: status %d, want 200", tc.path, code)
|
|
continue
|
|
}
|
|
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
t.Errorf("%s: content-type %q", tc.path, ct)
|
|
}
|
|
for _, want := range tc.wants {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("%s: body does not contain %q", tc.path, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestModeADoesNotShipTheStreamExtension keeps the two modes honestly separate:
|
|
// a page only loads htmx's SSE extension when its panel is stream-driven.
|
|
func TestModeADoesNotShipTheStreamExtension(t *testing.T) {
|
|
ts := newServer(t)
|
|
for _, path := range []string{"/", "/edge/", "/evidence/"} {
|
|
if _, _, body := get(t, ts, path, nil); strings.Contains(body, "sse.js") {
|
|
t.Errorf("%s loads sse.js, but it is not a stream page", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRouteDriverFragment is §2.4's first half: a click loads one pre-rendered
|
|
// fragment, and it is a fragment, not a page.
|
|
func TestRouteDriverFragment(t *testing.T) {
|
|
ts := newServer(t)
|
|
code, header, body := get(t, ts, "/stage/doc-edge-overview", nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("fragment status = %d, want 200", code)
|
|
}
|
|
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
t.Errorf("fragment content-type = %q", ct)
|
|
}
|
|
if !strings.Contains(body, `class="frag frag-`) {
|
|
t.Errorf("fragment body does not look like a fragment: %.120s", body)
|
|
}
|
|
if strings.Contains(body, "<!DOCTYPE html>") || strings.Contains(body, "<html") {
|
|
t.Error("fragment response is a whole document; the panel would nest a page")
|
|
}
|
|
|
|
for _, bad := range []string{"/stage/../../etc/passwd", "/stage/", "/stage/..%2f..%2fetc%2fpasswd", "/stage/does-not-exist"} {
|
|
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
|
|
t.Errorf("%s: status 200, want a 404 fragment", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStreamDriverSendsFragments is §2.4's second half: the same fragments,
|
|
// pushed over SSE, with no layout change.
|
|
func TestStreamDriverSendsFragments(t *testing.T) {
|
|
ts := newServer(t)
|
|
code, header, body := get(t, ts, "/api/stage/stream?k=doc-edge-overview", nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("stream status = %d, want 200", code)
|
|
}
|
|
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
|
t.Fatalf("stream content-type = %q, want text/event-stream", ct)
|
|
}
|
|
if got := header.Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
|
t.Errorf("stream cache-control = %q, want no-store", got)
|
|
}
|
|
if !strings.Contains(body, "event: "+StreamEvent) {
|
|
t.Errorf("stream has no %q event: %.200s", StreamEvent, body)
|
|
}
|
|
if got := strings.Count(body, "event: "+StreamEvent); got != 1 {
|
|
t.Errorf("stream sent %d events for one key, want 1", got)
|
|
}
|
|
// Exactly one article per frame: the build rendered it, the server must not
|
|
// wrap it again.
|
|
if got := strings.Count(body, `<article class="frag`); got != 1 {
|
|
t.Errorf("frame contains %d fragment articles, want 1", got)
|
|
}
|
|
if !strings.Contains(body, "data: ") {
|
|
t.Error("stream frame has no data line")
|
|
}
|
|
}
|
|
|
|
// TestStreamDefaultIsSixAreaFragments keeps the demonstration's default stream
|
|
// honest: the six area landing fragments, in navigation order, and no more.
|
|
func TestStreamDefaultIsSixAreaFragments(t *testing.T) {
|
|
ts := newServer(t)
|
|
code, _, body := get(t, ts, "/api/stage/stream", nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("stream status = %d, want 200", code)
|
|
}
|
|
if got := strings.Count(body, "event: "+StreamEvent); got != 6 {
|
|
t.Errorf("default stream has %d frames, want the six areas", got)
|
|
}
|
|
// Titles are HTML-escaped in the frame, so match the distinctive word.
|
|
for _, area := range []string{"Agentic", "Platform", "Edge", "Products", "History", "About"} {
|
|
if !strings.Contains(body, area) {
|
|
t.Errorf("default stream is missing the %s area", area)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSearchWorksWithAndWithoutHTMX covers §2.5 and §2.6: the same handler
|
|
// returns a page for a form post and a fragment for HTMX.
|
|
func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
|
|
ts := newServer(t)
|
|
for _, tc := range []struct {
|
|
q string
|
|
want string
|
|
}{
|
|
{"zigbee", "/edge/"},
|
|
{"signage", "/products/"},
|
|
{"routing", "/ai/"},
|
|
} {
|
|
code, _, body := get(t, ts, "/search?q="+tc.q, nil)
|
|
if code != http.StatusOK {
|
|
t.Errorf("search %q: status %d", tc.q, code)
|
|
continue
|
|
}
|
|
// templ writes a lowercase HTML5 doctype, so compare case-insensitively.
|
|
if !strings.Contains(strings.ToLower(body), "<!doctype html>") {
|
|
t.Errorf("search %q without HTMX did not return a page", tc.q)
|
|
}
|
|
if !strings.Contains(body, tc.want) {
|
|
t.Errorf("search %q has no result in %s", tc.q, tc.want)
|
|
}
|
|
|
|
code, _, frag := get(t, ts, "/search?q="+tc.q, map[string]string{"HX-Request": "true"})
|
|
if code != http.StatusOK {
|
|
t.Errorf("htmx search %q: status %d", tc.q, code)
|
|
continue
|
|
}
|
|
if strings.Contains(frag, "<html") {
|
|
t.Errorf("htmx search %q returned a whole page, want the results block", 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSearchRejectsOperators checks that FTS5 syntax in a visitor's input is
|
|
// never honoured, so a query can only be words.
|
|
func TestSearchRejectsOperators(t *testing.T) {
|
|
ts := newServer(t)
|
|
for _, q := range []string{`" OR "`, `zigbee" OR body:`, `NEAR(zigbee routing)`, `*`} {
|
|
code, _, body := get(t, ts, "/search?q="+strings.ReplaceAll(q, " ", "+"), nil)
|
|
if code != http.StatusOK {
|
|
t.Errorf("query %q: status %d, want 200 with an honest empty/refused result", q, code)
|
|
}
|
|
if strings.Contains(body, "could not be run") && q == "*" {
|
|
t.Logf("query %q reported as unparseable, which is acceptable", q)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnknownPathIsTheBuilt404Page makes sure a typo lands on the pre-rendered
|
|
// page rather than a bare error, and that the status stays 404.
|
|
func TestUnknownPathIsTheBuilt404Page(t *testing.T) {
|
|
ts := newServer(t)
|
|
code, _, body := get(t, ts, "/does-not-exist", nil)
|
|
if code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", code)
|
|
}
|
|
if !strings.Contains(body, "That page is not here") {
|
|
t.Error("404 body is not the built 404 page")
|
|
}
|
|
}
|
|
|
|
// TestStaticAssetsAreServedWithTheirType keeps the stylesheet, htmx and the SSE
|
|
// extension reachable, and confirms content sniffing is refused.
|
|
func TestStaticAssetsAreServedWithTheirType(t *testing.T) {
|
|
ts := newServer(t)
|
|
for _, tc := range []struct{ path, ctype string }{
|
|
{"/static/site.css", "text/css"},
|
|
{"/static/chroma.css", "text/css"},
|
|
{"/static/htmx.min.js", "text/javascript"},
|
|
{"/static/sse.js", "text/javascript"},
|
|
} {
|
|
code, header, _ := get(t, ts, tc.path, nil)
|
|
if code != http.StatusOK {
|
|
t.Errorf("%s: status %d, want 200", tc.path, code)
|
|
continue
|
|
}
|
|
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) {
|
|
t.Errorf("%s: content-type %q, want %q", tc.path, ct, tc.ctype)
|
|
}
|
|
if header.Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Errorf("%s: missing nosniff", tc.path)
|
|
}
|
|
}
|
|
for _, bad := range []string{"/static/../build.json", "/static/%2e%2e/build.json", "/assets/../dist"} {
|
|
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
|
|
t.Errorf("%s: status 200, want a rejection", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSecurityHeadersAreAlwaysPresent mirrors the phase 3 edge rules at the
|
|
// origin, so a direct request sees the same policy as one through Caddy.
|
|
func TestSecurityHeadersAreAlwaysPresent(t *testing.T) {
|
|
ts := newServer(t)
|
|
for _, path := range []string{"/", "/search?q=zigbee", "/healthz", "/does-not-exist"} {
|
|
_, header, _ := get(t, ts, path, nil)
|
|
if header.Get("Content-Security-Policy") == "" {
|
|
t.Errorf("%s: no CSP", path)
|
|
}
|
|
if header.Get("Referrer-Policy") == "" {
|
|
t.Errorf("%s: no referrer policy", path)
|
|
}
|
|
if header.Get("X-Frame-Options") == "" {
|
|
t.Errorf("%s: no frame options", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFirstByteBudget is a coarse A2 check: on loopback, well inside the 100 ms
|
|
// budget the specification sets for a page served over the LAN.
|
|
func TestFirstByteBudget(t *testing.T) {
|
|
ts := newServer(t)
|
|
started := time.Now()
|
|
code, _, _ := get(t, ts, "/", nil)
|
|
elapsed := time.Since(started)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status = %d", code)
|
|
}
|
|
if elapsed > 100*time.Millisecond {
|
|
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")
|
|
}
|
|
}
|