| 1 | package memoriatest |
| 2 | |
| 3 | // Gates for the shared chrome (estilo spec/shared-chrome, round 2; Memoria |
| 4 | // is its §12.1 row 6). Each was run against origin/main 97b8a23 before the |
| 5 | // change and failed there; the discussion quotes both runs. |
| 6 | |
| 7 | import ( |
| 8 | "encoding/base64" |
| 9 | "net/http" |
| 10 | "net/url" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "regexp" |
| 14 | "strconv" |
| 15 | "strings" |
| 16 | "testing" |
| 17 | |
| 18 | "golang.org/x/net/html" |
| 19 | ) |
| 20 | |
| 21 | var titleRe = regexp.MustCompile(`(?s)<title>(.*?)</title>`) |
| 22 | |
| 23 | func pageTitle(body string) string { |
| 24 | m := titleRe.FindStringSubmatch(body) |
| 25 | if m == nil { |
| 26 | return "" |
| 27 | } |
| 28 | return html.UnescapeString(strings.TrimSpace(m[1])) |
| 29 | } |
| 30 | |
| 31 | func storedID(t *testing.T, h http.Handler) string { |
| 32 | t.Helper() |
| 33 | m := regexp.MustCompile(`href="/documents/(\d+)"`).FindStringSubmatch(get(t, h, "/").Body.String()) |
| 34 | if m == nil { |
| 35 | t.Fatal("no stored document on the index") |
| 36 | } |
| 37 | return m[1] |
| 38 | } |
| 39 | |
| 40 | // §3: an error is a page with the bar, for a browser; plain text for |
| 41 | // anything else. Before: Go's "404 page not found" in monospace, and |
| 42 | // "could not read that document". |
| 43 | func TestHTMLErrorsRenderTheErrorPage(t *testing.T) { |
| 44 | dbPath := tempDB(t) |
| 45 | in, _ := signedInClient(t, dbPath) |
| 46 | h, _ := admissionApp(t, dbPath, []string{chromeOwner}) |
| 47 | out := newClient(t, h) |
| 48 | |
| 49 | cases := []struct { |
| 50 | name, path, title string |
| 51 | }{ |
| 52 | {"unknown route", "/no/such/page", "Page not found · Memoria"}, |
| 53 | {"missing document", "/documents/99999", "Document not available · Memoria"}, |
| 54 | {"bad parameter", "/documents/not-a-number", "Document not available · Memoria"}, |
| 55 | {"GET of a POST-only path", "/connectors/notion/sync", "Page not found · Memoria"}, |
| 56 | } |
| 57 | for _, tc := range cases { |
| 58 | t.Run("signed in, "+tc.name, func(t *testing.T) { |
| 59 | rec := in.get(tc.path) |
| 60 | body := rec.Body.String() |
| 61 | if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { |
| 62 | t.Fatalf("%s = %d %q, want a 404 page", tc.path, rec.Code, rec.Header().Get("Content-Type")) |
| 63 | } |
| 64 | if got := pageTitle(body); got != tc.title { |
| 65 | t.Errorf("title %q, want %q", got, tc.title) |
| 66 | } |
| 67 | if !strings.Contains(body, "<header estilo-bar") || !strings.Contains(body, "<details estilo-account") { |
| 68 | t.Error("the error page does not carry the bar with the viewer's account") |
| 69 | } |
| 70 | if !strings.Contains(body, "rst-error") { |
| 71 | t.Error("the error page is not estilo's error body") |
| 72 | } |
| 73 | }) |
| 74 | } |
| 75 | t.Run("signed out, unknown route", func(t *testing.T) { |
| 76 | rec := out.get("/no/such/page") |
| 77 | body := rec.Body.String() |
| 78 | if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { |
| 79 | t.Fatalf("= %d %q, want a 404 page", rec.Code, rec.Header().Get("Content-Type")) |
| 80 | } |
| 81 | if got := pageTitle(body); got != "Page not found · Memoria" { |
| 82 | t.Errorf("title %q", got) |
| 83 | } |
| 84 | if !strings.Contains(body, "<header estilo-bar") || strings.Contains(body, "estilo-account") || !strings.Contains(body, "estilo-bar-signin") { |
| 85 | t.Error("signed out, the error page should carry the bar with Sign in and no account") |
| 86 | } |
| 87 | }) |
| 88 | t.Run("signed out, a guarded page is sign-in with the way back", func(t *testing.T) { |
| 89 | if got := location(t, out.get("/documents/99999")); got != "/signin?return_to=%2Fdocuments%2F99999" { |
| 90 | t.Errorf("Location %q", got) |
| 91 | } |
| 92 | }) |
| 93 | t.Run("not a browser: plain text", func(t *testing.T) { |
| 94 | in.accept = "application/json" |
| 95 | defer func() { in.accept = "text/html" }() |
| 96 | for _, p := range []string{"/no/such/page", "/documents/99999"} { |
| 97 | rec := in.get(p) |
| 98 | if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") { |
| 99 | t.Errorf("%s with Accept: application/json = %d %q, want plain text", p, rec.Code, rec.Header().Get("Content-Type")) |
| 100 | } |
| 101 | } |
| 102 | }) |
| 103 | t.Run("another method is a 405 that names what is allowed", func(t *testing.T) { |
| 104 | rec := in.do(http.MethodDelete, "/documents/1", nil) |
| 105 | if rec.Code != http.StatusMethodNotAllowed || !strings.Contains(rec.Header().Get("Allow"), "GET") { |
| 106 | t.Errorf("DELETE /documents/1 = %d, Allow %q", rec.Code, rec.Header().Get("Allow")) |
| 107 | } |
| 108 | }) |
| 109 | } |
| 110 | |
| 111 | // §4: one notice vocabulary. rst-notice, a valued rst-callout and the |
| 112 | // "warn" tone style nothing; a <p rst-callout-body> stacks its own margins |
| 113 | // on the callout's padding (Memoria's double-height callouts). |
| 114 | func TestTemplatesUseOnlyCalloutTones(t *testing.T) { |
| 115 | dir := filepath.Join("..", "memoria", "templates") |
| 116 | entries, err := os.ReadDir(dir) |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"|<p rst-callout-body|rst-tone="info"`) |
| 121 | checked := 0 |
| 122 | for _, e := range entries { |
| 123 | raw, err := os.ReadFile(filepath.Join(dir, e.Name())) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | checked++ |
| 128 | for _, m := range bad.FindAllString(string(raw), -1) { |
| 129 | t.Errorf("%s writes %q, which is outside the callout vocabulary", e.Name(), m) |
| 130 | } |
| 131 | } |
| 132 | if checked == 0 { |
| 133 | t.Fatal("no templates read") |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // §10: every <title> is "{part} · … · Memoria", built by the layout, and |
| 138 | // the first part is the page's h1. The titles mixed "—" and "·" before. |
| 139 | func TestPageTitlesFollowTheSuitePattern(t *testing.T) { |
| 140 | h := newApp(t) |
| 141 | store(t, h, "Minutes, 12 September", "We agreed to move the offsite to November.") |
| 142 | id := storedID(t, h) |
| 143 | for path, want := range map[string]string{ |
| 144 | "/": "Memoria", |
| 145 | "/?q=offsite": "“offsite” · Memoria", |
| 146 | "/add": "Add content · Memoria", |
| 147 | "/documents/" + id: "Minutes, 12 September · Memoria", |
| 148 | "/documents/" + id + "/delete": "Delete document · Minutes, 12 September · Memoria", |
| 149 | "/connectors": "Sources · Memoria", |
| 150 | "/connectors/notion": "Notion · Memoria", |
| 151 | "/help/open-source": "Open source · Memoria", |
| 152 | } { |
| 153 | if got := pageTitle(get(t, h, path).Body.String()); got != want { |
| 154 | t.Errorf("%s: title %q, want %q", path, got, want) |
| 155 | } |
| 156 | } |
| 157 | so, _ := signinApp(t) |
| 158 | for path, want := range map[string]string{ |
| 159 | "/signin": "Sign in · Memoria", |
| 160 | "/signin?sent=1": "Check your email · Memoria", |
| 161 | "/signin?err=expired": "Sign in · Memoria", |
| 162 | "/signed-out": "Signed out · Memoria", |
| 163 | } { |
| 164 | if got := pageTitle(get(t, so, path).Body.String()); got != want { |
| 165 | t.Errorf("%s: title %q, want %q", path, got, want) |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // §8.2: one account menu, the last thing in the bar, with the one Sign |
| 171 | // out in the app; Appearance; Help ending in Open source. Before: a Help |
| 172 | // dropdown, Sources and a Sign out button in a nav that wrapped. |
| 173 | func TestEverySignedInPageHasOneSignOutInTheSameCorner(t *testing.T) { |
| 174 | h := newApp(t) |
| 175 | store(t, h, "Notes", "hosting migration notes") |
| 176 | id := storedID(t, h) |
| 177 | for _, path := range []string{"/", "/?q=hosting", "/add", "/documents/" + id, "/documents/" + id + "/delete", "/connectors", "/connectors/notion", "/help/open-source"} { |
| 178 | doc := parsePage(t, get(t, h, path).Body.String()) |
| 179 | signOuts := findAll(doc, func(n *html.Node) bool { |
| 180 | return (n.Data == "button" || n.Data == "a") && strings.TrimSpace(textOf(n)) == "Sign out" |
| 181 | }) |
| 182 | if len(signOuts) != 1 { |
| 183 | t.Errorf("%s: %d Sign outs, want exactly one", path, len(signOuts)) |
| 184 | continue |
| 185 | } |
| 186 | ends := findAll(doc, hasAttr("estilo-bar-end")) |
| 187 | if len(ends) != 1 { |
| 188 | t.Errorf("%s: %d [estilo-bar-end]", path, len(ends)) |
| 189 | continue |
| 190 | } |
| 191 | var last *html.Node |
| 192 | for c := ends[0].FirstChild; c != nil; c = c.NextSibling { |
| 193 | if c.Type == html.ElementNode { |
| 194 | last = c |
| 195 | } |
| 196 | } |
| 197 | if last == nil || last.Data != "details" { |
| 198 | t.Errorf("%s: the bar does not end with the account menu", path) |
| 199 | continue |
| 200 | } |
| 201 | if _, ok := attrOf(last, "estilo-account"); !ok { |
| 202 | t.Errorf("%s: the last thing in the bar is not the account menu", path) |
| 203 | } |
| 204 | if !within(signOuts[0], last) { |
| 205 | t.Errorf("%s: Sign out is not in the account menu", path) |
| 206 | } |
| 207 | account := textOf(last) |
| 208 | for _, want := range []string{"Signed in as", memoriaTestOwner(), "Appearance", "Light", "Dark", "System", "Help", "Open source"} { |
| 209 | if !strings.Contains(account, want) { |
| 210 | t.Errorf("%s: the account menu lacks %q", path, want) |
| 211 | } |
| 212 | } |
| 213 | pressed := findAll(last, func(n *html.Node) bool { |
| 214 | v, ok := attrOf(n, "aria-pressed") |
| 215 | return ok && (v == "true" || v == "false") |
| 216 | }) |
| 217 | if len(pressed) != 3 { |
| 218 | t.Errorf("%s: %d Appearance options carry aria-pressed, want 3", path, len(pressed)) |
| 219 | } |
| 220 | if strings.Contains(textOf(doc), "Connect Home account") || len(findAll(doc, hasAttr("rst-dropdown"))) != 0 { |
| 221 | t.Errorf("%s: the old header's Help dropdown or \"Connect Home account\" is still there", path) |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | func memoriaTestOwner() string { return "owner@test" } |
| 227 | |
| 228 | // §8.4: signed out, the corner is a plain "Sign in" back to this page; on |
| 229 | // the sign-in pages themselves it is empty. |
| 230 | func TestTheSignedOutCorner(t *testing.T) { |
| 231 | h, _ := signinApp(t) |
| 232 | body := newClient(t, h).get("/no/such/page?x=1").Body.String() |
| 233 | doc := parsePage(t, body) |
| 234 | signIns := findAll(doc, hasAttr("estilo-bar-signin")) |
| 235 | if len(signIns) != 1 { |
| 236 | t.Fatalf("%d Sign in links in the bar", len(signIns)) |
| 237 | } |
| 238 | if href, _ := attrOf(signIns[0], "href"); href != "/signin?return_to=%2Fno%2Fsuch%2Fpage%3Fx%3D1" { |
| 239 | t.Errorf("Sign in goes to %q, want back to this page", href) |
| 240 | } |
| 241 | for _, path := range []string{"/signin", "/signin?sent=1", "/signed-out"} { |
| 242 | b := get(t, h, path).Body.String() |
| 243 | if !strings.Contains(b, "<header estilo-bar") || strings.Contains(b, "estilo-bar-signin") || strings.Contains(b, "estilo-account") { |
| 244 | t.Errorf("%s: the corner of a sign-in page should be empty, under the bar", path) |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // §12.1 (Memoria): Sources is a nav link with aria-current, rendered only |
| 250 | // for owners — /connectors 404s everyone else, so a non-owner must never |
| 251 | // be shown the link. |
| 252 | func TestSourcesIsANavLinkForOwnersOnly(t *testing.T) { |
| 253 | h, app := newAppWithTestApp(t) |
| 254 | doc := parsePage(t, get(t, h, "/connectors").Body.String()) |
| 255 | links := findAll(doc, func(n *html.Node) bool { |
| 256 | href, _ := attrOf(n, "href") |
| 257 | return n.Data == "a" && href == "/connectors" && n.Parent != nil && hasAttr("estilo-bar-nav")(n.Parent) |
| 258 | }) |
| 259 | if len(links) != 1 { |
| 260 | t.Fatalf("%d Sources links in the bar's nav, want 1", len(links)) |
| 261 | } |
| 262 | if v, _ := attrOf(links[0], "aria-current"); v != "page" { |
| 263 | t.Error("on /connectors the Sources link is not aria-current") |
| 264 | } |
| 265 | app.AsPerson("reader@test") |
| 266 | if b := get(t, h, "/").Body.String(); strings.Contains(b, `href="/connectors"`) { |
| 267 | t.Error("a non-owner is shown the Sources link") |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // §8.3: Appearance posts to /appearance, sets one host-only cookie and |
| 272 | // returns; the layout writes data-theme from it. Outside the session gate, |
| 273 | // so an expired session cannot turn the click into a sign-in loop. |
| 274 | func TestAppearance(t *testing.T) { |
| 275 | dbPath := tempDB(t) |
| 276 | c, _ := signedInClient(t, dbPath) |
| 277 | rec := c.do(http.MethodPost, "/appearance", url.Values{"mode": {"dark"}, "return_to": {"/add"}}) |
| 278 | if got := location(t, rec); got != "/add" { |
| 279 | t.Errorf("Appearance returned to %q, want /add", got) |
| 280 | } |
| 281 | if c.cookies["estilo_appearance"] == nil || c.cookies["estilo_appearance"].Value != "dark" { |
| 282 | t.Fatal("no estilo_appearance=dark cookie") |
| 283 | } |
| 284 | if !strings.Contains(c.get("/add").Body.String(), `<html lang="en" data-theme="dark">`) { |
| 285 | t.Error("the page does not carry data-theme=dark") |
| 286 | } |
| 287 | h, _ := admissionApp(t, tempDB(t), []string{chromeOwner}) |
| 288 | out := newClient(t, h) |
| 289 | if got := location(t, out.do(http.MethodPost, "/appearance", url.Values{"mode": {"light"}, "return_to": {"//evil.example/"}})); got != "/" { |
| 290 | t.Errorf("an off-site return_to went to %q, want /", got) |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | // §9.4: a document link opened signed out comes back to that document |
| 295 | // after sign-in, through a sealed cookie; "Use a different address" keeps |
| 296 | // it; a plain /signin forgets it. |
| 297 | func TestASignedOutDeepLinkComesBackAfterSignIn(t *testing.T) { |
| 298 | h, mailer := admissionApp(t, tempDB(t), []string{chromeOwner}) |
| 299 | c := newClient(t, h) |
| 300 | const deep = "/documents/7?x=1" |
| 301 | signin := location(t, c.get(deep)) |
| 302 | if signin != "/signin?return_to=%2Fdocuments%2F7%3Fx%3D1" { |
| 303 | t.Fatalf("a signed-out deep link went to %q", signin) |
| 304 | } |
| 305 | c.get(signin) |
| 306 | var ret *http.Cookie |
| 307 | for name, ck := range c.cookies { |
| 308 | if strings.Contains(name, "return") { |
| 309 | ret = ck |
| 310 | } |
| 311 | } |
| 312 | if ret == nil { |
| 313 | t.Fatal("GET /signin?return_to= remembered nothing") |
| 314 | } |
| 315 | raw, _ := base64.RawURLEncoding.DecodeString(ret.Value) |
| 316 | if strings.Contains(ret.Value, "documents") || strings.Contains(string(raw), "documents") || !ret.HttpOnly { |
| 317 | t.Error("the return cookie is not sealed (the path reads straight out of it)") |
| 318 | } |
| 319 | sent := location(t, c.do(http.MethodPost, "/auth/begin", url.Values{"address": {chromeOwner}})) |
| 320 | body := c.get(sent).Body.String() |
| 321 | if !strings.Contains(body, `href="/signin?return_to=%2Fdocuments%2F7%3Fx%3D1"`) || !strings.Contains(body, "Use a different address") { |
| 322 | t.Errorf("\"Use a different address\" does not keep the return:\n%s", body) |
| 323 | } |
| 324 | u, _ := url.Parse(mailer.link()) |
| 325 | dest := location(t, c.openLink(u.RequestURI())) |
| 326 | for i := 0; i < 3 && dest != deep && strings.HasPrefix(dest, "/auth/"); i++ { |
| 327 | dest = location(t, c.get(dest)) |
| 328 | } |
| 329 | if dest != deep { |
| 330 | t.Errorf("sign-in landed on %q, want %q", dest, deep) |
| 331 | } |
| 332 | |
| 333 | // Tampered, the cookie is ignored: the landing is the index. |
| 334 | c2 := newClient(t, h) |
| 335 | c2.get("/signin?return_to=%2Fadd") |
| 336 | for name, ck := range c2.cookies { |
| 337 | if strings.Contains(name, "return") { |
| 338 | v := []byte(ck.Value) |
| 339 | v[len(v)/2] ^= 1 |
| 340 | c2.cookies[name] = &http.Cookie{Name: name, Value: string(v)} |
| 341 | } |
| 342 | } |
| 343 | if got := location(t, c2.get("/auth/landed")); got != "/" { |
| 344 | t.Errorf("a tampered return cookie still steered the landing to %q", got) |
| 345 | } |
| 346 | // And an off-site return_to is not remembered at all. |
| 347 | c3 := newClient(t, h) |
| 348 | c3.get("/signin?return_to=%2F%2Fevil.example%2F") |
| 349 | if got := location(t, c3.get("/auth/landed")); got != "/" { |
| 350 | t.Errorf("an off-site return_to steered the landing to %q", got) |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | // §9.2: already signed in, /signin skips the form to a safe return_to. And |
| 355 | // the trap Astra found in Docs, Sheets and Slides: the admission check |
| 356 | // runs FIRST, so a removed owner is signed out and told, once — not |
| 357 | // bounced between /signin and a page that refuses them. |
| 358 | func TestSigninWhenAlreadySignedInRunsTheAdmissionCheckFirst(t *testing.T) { |
| 359 | dbPath := tempDB(t) |
| 360 | c, _ := signedInClient(t, dbPath) |
| 361 | if got := location(t, c.get("/signin?return_to=%2Fadd")); got != "/add" { |
| 362 | t.Errorf("signed in, /signin?return_to=/add went to %q", got) |
| 363 | } |
| 364 | if got := location(t, c.get("/signin?return_to=%2F%2Fevil.example")); got != "/" { |
| 365 | t.Errorf("signed in, an off-site return_to went to %q", got) |
| 366 | } |
| 367 | |
| 368 | // The owner is removed. Same database, same session cookie. |
| 369 | h2, _ := admissionApp(t, dbPath, []string{"someone-else@example.test"}) |
| 370 | c.h = h2 |
| 371 | if got := location(t, c.get("/signin")); got != "/signin?err=removed" { |
| 372 | t.Fatalf("a removed owner at /signin went to %q, want /signin?err=removed", got) |
| 373 | } |
| 374 | rec := c.get("/signin?err=removed") |
| 375 | if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Your access has been removed") { |
| 376 | t.Errorf("the removed page = %d, and does not say so", rec.Code) |
| 377 | } |
| 378 | if !strings.Contains(location(t, c.get("/")), "/signin?return_to=") { |
| 379 | t.Error("the removed owner's session survived") |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | // And the gate itself: a removed owner meets the same sentence on any |
| 384 | // page, instead of a 404 on every page until the session lapses. |
| 385 | func TestARemovedOwnerIsToldOnAnyPage(t *testing.T) { |
| 386 | dbPath := tempDB(t) |
| 387 | c, _ := signedInClient(t, dbPath) |
| 388 | h2, _ := admissionApp(t, dbPath, []string{"someone-else@example.test"}) |
| 389 | c.h = h2 |
| 390 | if got := location(t, c.get("/add")); got != "/signin?err=removed" { |
| 391 | t.Errorf("a removed owner at /add went to %q", got) |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | // §9.2: Sign out lands on "You're signed out", not a bare sign-in form. |
| 396 | func TestSignOutLandsOnTheSignedOutPage(t *testing.T) { |
| 397 | c, _ := signedInClient(t, tempDB(t)) |
| 398 | dest := location(t, c.do(http.MethodPost, "/signout", url.Values{})) |
| 399 | if dest != "/signed-out" { |
| 400 | t.Fatalf("Sign out went to %q", dest) |
| 401 | } |
| 402 | rec := c.get(dest) |
| 403 | b := html.UnescapeString(rec.Body.String()) |
| 404 | if rec.Code != http.StatusOK || !strings.Contains(b, "You’re signed out") || !strings.Contains(b, "You’ve signed out of Memoria on this device.") { |
| 405 | t.Errorf("the signed-out page = %d:\n%s", rec.Code, b) |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | // Delete asks first. The index's Delete was a one-click POST with no undo. |
| 410 | func TestDeleteAsksFirst(t *testing.T) { |
| 411 | h := newApp(t) |
| 412 | store(t, h, "Keep me", "a document someone might delete by accident") |
| 413 | id := storedID(t, h) |
| 414 | index := get(t, h, "/").Body.String() |
| 415 | if strings.Contains(index, `action="/documents/`+id+`/delete"`) { |
| 416 | t.Error("the index still deletes in one click") |
| 417 | } |
| 418 | if !strings.Contains(index, `href="/documents/`+id+`/delete"`) { |
| 419 | t.Error("the index has no way to ask for a delete") |
| 420 | } |
| 421 | confirm := get(t, h, "/documents/"+id+"/delete") |
| 422 | b := html.UnescapeString(confirm.Body.String()) |
| 423 | if confirm.Code != http.StatusOK || !strings.Contains(b, `method="post" action="/documents/`+id+`/delete"`) || !strings.Contains(b, "Keep me") || !strings.Contains(b, ">Cancel<") { |
| 424 | t.Fatalf("the confirmation page = %d:\n%s", confirm.Code, b) |
| 425 | } |
| 426 | if storedCount(t, h) != 1 { |
| 427 | t.Fatal("asking deleted the document") |
| 428 | } |
| 429 | post(t, h, "/documents/"+id+"/delete", url.Values{}) |
| 430 | if storedCount(t, h) != 0 { |
| 431 | t.Error("the confirmed delete did not delete") |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | // Live search does not say "0 results…" while the person is still typing. |
| 436 | // The zero state is one quiet line, marked for search.js to hold back |
| 437 | // until a pause; a submitted search shows it at once. |
| 438 | func TestNoZeroResultsWhileTyping(t *testing.T) { |
| 439 | h := newApp(t) |
| 440 | store(t, h, "Revenue", "revenue grows next year") |
| 441 | frag := get(t, h, "/search?q=reve").Body.String() |
| 442 | if strings.Contains(frag, "0 results") || !strings.Contains(frag, "rst-search-empty") || strings.Contains(frag, "rst-callout") { |
| 443 | t.Errorf("the live zero state is not one marked quiet line:\n%s", frag) |
| 444 | } |
| 445 | js, err := os.ReadFile(filepath.Join("..", "memoria", "static", "search.js")) |
| 446 | if err != nil { |
| 447 | t.Fatal(err) |
| 448 | } |
| 449 | css, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| 450 | if err != nil { |
| 451 | t.Fatal(err) |
| 452 | } |
| 453 | if !strings.Contains(string(js), `setAttribute("rst-search-pending"`) || !strings.Contains(string(js), `removeAttribute("rst-search-pending")`) { |
| 454 | t.Error("search.js never holds a live zero state back") |
| 455 | } |
| 456 | if !regexp.MustCompile(`\[rst-search-pending\] \[rst-search-empty\] \{[^}]*visibility: hidden`).Match(css) { |
| 457 | t.Error("memoria.css does not hide a held zero state") |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | // The layout: charset first, then the suite's stylesheet order (§0.5, |
| 462 | // §12.1 item 2): tokens → theme → brand → base → menu → chrome → app. |
| 463 | func TestTheLayoutLinksTheSharedChromeInOrder(t *testing.T) { |
| 464 | body := get(t, newApp(t), "/").Body.String() |
| 465 | head := body[strings.Index(body, "<head>")+len("<head>"):] |
| 466 | if !strings.HasPrefix(strings.TrimSpace(head), `<meta charset="utf-8">`) { |
| 467 | t.Error("<meta charset> is not the first thing in <head>") |
| 468 | } |
| 469 | last := -1 |
| 470 | for _, name := range []string{"tokens", "theme", "brand/brand", "base", "menu", "chrome", "memoria"} { |
| 471 | m := regexp.MustCompile(`href="/static/` + name + `\.[^"]*css"`).FindStringIndex(body) |
| 472 | if m == nil { |
| 473 | t.Errorf("the layout does not link %s.css", name) |
| 474 | continue |
| 475 | } |
| 476 | if m[0] < last { |
| 477 | t.Errorf("%s.css is linked out of the suite's order", name) |
| 478 | } |
| 479 | last = m[0] |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | // Nineteen template sites wrote rst-cell-mut as an attribute, which styles |
| 484 | // nothing (it is a class-only utility): muted text rendered at body colour. |
| 485 | func TestMutedTextIsStyled(t *testing.T) { |
| 486 | dir := filepath.Join("..", "memoria", "templates") |
| 487 | entries, _ := os.ReadDir(dir) |
| 488 | for _, e := range entries { |
| 489 | raw, _ := os.ReadFile(filepath.Join(dir, e.Name())) |
| 490 | if n := regexp.MustCompile(`<[^>]*\srst-cell-mut[\s>]`).FindAll(raw, -1); len(n) > 0 { |
| 491 | t.Errorf("%s writes rst-cell-mut as an attribute %d times", e.Name(), len(n)) |
| 492 | } |
| 493 | } |
| 494 | css, _ := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| 495 | if !regexp.MustCompile(`\[rst-muted\] \{ color: var\(--rst-text-muted\); \}`).Match(css) { |
| 496 | t.Error("memoria.css does not style [rst-muted]") |
| 497 | } |
| 498 | if !strings.Contains(get(t, newApp(t), "/").Body.String(), "<p rst-muted>") { |
| 499 | t.Error("the footer is not muted") |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | // Fields fit the column: width 100% plus padding overflowed by 22px at |
| 504 | // 1440 and off screen at 390 (content-box); password fields were the |
| 505 | // browser's own; the search input drew a second box inside [rst-search]. |
| 506 | func TestFormFieldsFitTheColumn(t *testing.T) { |
| 507 | css, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| 508 | if err != nil { |
| 509 | t.Fatal(err) |
| 510 | } |
| 511 | rule := regexp.MustCompile(`(?s)\[rst-stack\] input\[type="text"\],[^{]*\{[^}]*\}`).Find(css) |
| 512 | if rule == nil { |
| 513 | t.Fatal("no [rst-stack] field rule") |
| 514 | } |
| 515 | for _, want := range []string{`input[type="password"]`, "box-sizing: border-box"} { |
| 516 | if !strings.Contains(string(rule), want) { |
| 517 | t.Errorf("the field rule lacks %q", want) |
| 518 | } |
| 519 | } |
| 520 | if regexp.MustCompile(`\[rst-search\] input\[type="search"\] \{`).Match(css) { |
| 521 | t.Error("memoria.css still draws its own box on the search input, inside rastrillo's") |
| 522 | } |
| 523 | if !strings.Contains(string(css), "::file-selector-button") { |
| 524 | t.Error("the file chooser's button is the browser's own") |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | // The Sources lists are rows, not a card of bare bulleted <li>s. |
| 529 | func TestSourcesAreRows(t *testing.T) { |
| 530 | b := get(t, newApp(t), "/connectors").Body.String() |
| 531 | if strings.Contains(b, "rst-list-item") || strings.Contains(b, "<ul rst-list>") { |
| 532 | t.Error("the Sources page still puts bare list items in a card") |
| 533 | } |
| 534 | if !strings.Contains(b, "<ul rst-sources>") { |
| 535 | t.Error("the Sources page has no rows") |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | var _ = strconv.Itoa |
| 540 | |
| 541 | // Estilo's chrome is tested against rastrillo v0.23's tokens in estilo; |
| 542 | // this is the same check against the tokens.css and theme.css THIS app |
| 543 | // serves (spec §12.1 item 1): a var(--rst-*) nothing defines makes its |
| 544 | // whole declaration invalid, and the rule vanishes with no error. |
| 545 | func TestEstilosChromeUsesOnlyDefinedTokens(t *testing.T) { |
| 546 | static := filepath.Join("..", "memoria", "static") |
| 547 | defined := map[string]bool{} |
| 548 | def := regexp.MustCompile(`(?:^|[;{,\s])(--(?:rst|estilo)-[a-zA-Z0-9_-]+)\s*:`) |
| 549 | for _, name := range []string{"tokens.css", "theme.css", "base.css", "menu.css", "chrome.css"} { |
| 550 | raw, err := os.ReadFile(filepath.Join(static, name)) |
| 551 | if err != nil { |
| 552 | t.Fatal(err) |
| 553 | } |
| 554 | for _, m := range def.FindAllStringSubmatch(string(raw), -1) { |
| 555 | defined[m[1]] = true |
| 556 | } |
| 557 | } |
| 558 | if !defined["--rst-accent"] || !defined["--estilo-bar-block-size"] { |
| 559 | t.Fatal("the definition pattern found neither --rst-accent nor --estilo-bar-block-size; this gate is measuring nothing") |
| 560 | } |
| 561 | use := regexp.MustCompile(`var\(\s*(--(?:rst|estilo)-[a-zA-Z0-9_-]+)\s*([,)])`) |
| 562 | seen := 0 |
| 563 | for _, name := range []string{"base.css", "menu.css", "chrome.css"} { |
| 564 | raw, err := os.ReadFile(filepath.Join(static, name)) |
| 565 | if err != nil { |
| 566 | t.Fatal(err) |
| 567 | } |
| 568 | for _, m := range use.FindAllStringSubmatch(string(raw), -1) { |
| 569 | seen++ |
| 570 | if m[2] == ")" && !defined[m[1]] { |
| 571 | t.Errorf("%s reads var(%s), which neither this app's tokens nor estilo define", name, m[1]) |
| 572 | } |
| 573 | } |
| 574 | } |
| 575 | if seen < 20 { |
| 576 | t.Fatalf("only %d token references in estilo's chrome; the walk is broken", seen) |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | // polish audit r3 B7 and C5: the Title hint read as bold label text, and |
| 581 | // /add put a section rule between "← Back to search" and its h1, where the |
| 582 | // document page uses the breadcrumb nav. |
| 583 | func TestTheAddPageReadsLikeTheDocumentPage(t *testing.T) { |
| 584 | b := get(t, newApp(t), "/add").Body.String() |
| 585 | if !strings.Contains(b, `<label for="title">Title <span rst-muted rst-hint>(optional)</span></label>`) { |
| 586 | t.Error("the Title hint is not a quiet \"(optional)\"") |
| 587 | } |
| 588 | if !strings.Contains(b, `<nav rst-breadcrumb><a href="/">← Back to search</a></nav>`) { |
| 589 | t.Error("/add's back link is not the breadcrumb the document page uses") |
| 590 | } |
| 591 | if regexp.MustCompile(`(?s)<section rst-section>\s*<h1>Add content</h1>`).MatchString(b) { |
| 592 | t.Error("a section rule still sits between the back link and the h1") |
| 593 | } |
| 594 | css, _ := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| 595 | if !regexp.MustCompile(`\[rst-hint\] \{[^}]*font-weight: 400`).Match(css) { |
| 596 | t.Error("the hint inherits the label's weight") |
| 597 | } |
| 598 | } |
| 599 | |