oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package 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
7import (
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
21var titleRe = regexp.MustCompile(`(?s)<title>(.*?)</title>`)
22
23func 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
31func 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".
43func 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).
114func 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.
139func 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.
173func 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
226func 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.
230func 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.
252func 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.
274func 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.
297func 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.
358func 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.
385func 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.
396func 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.
410func 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.
438func 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.
463func 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.
485func 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].
506func 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.
529func 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
539var _ = 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.
545func 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.
583func 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