oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "os"
8 "regexp"
9 "strings"
10 "testing"
11
12 "github.com/carlosframework/docs/internal/export"
13 "github.com/carlosframework/docs/internal/fonts"
14)
15
16// exportable creates a document owned by address with the styled
17// fixture applied — headings, lists, a blockquote and the font and
18// size marks — so an export test exercises every shape at once.
19func exportable(t *testing.T, app *App, address, title string) *Document {
20 t.Helper()
21 ctx := context.Background()
22 owner := app.TestPerson(t, address)
23 doc, err := app.Store().Create(ctx, owner, title)
24 if err != nil {
25 t.Fatalf("Create: %v", err)
26 }
27 update := readFixture(t, "../../fixtures/vectors/fragment/styled.bin")
28 // ApplyUpdate returns the accepted row's seq alongside its error
29 // since the live-update stream landed (store.go); this test does not
30 // care which seq, only that the update went in.
31 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), update); err != nil {
32 t.Fatalf("ApplyUpdate: %v", err)
33 }
34 return doc
35}
36
37func TestExportMarkdown(t *testing.T) {
38 app, err := NewForTest(t)
39 if err != nil {
40 t.Fatalf("NewForTest: %v", err)
41 }
42 doc := exportable(t, app, "writer@example.com", "Meeting notes")
43
44 rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/d/"+doc.Sqid+"/export.md", nil))
45 if rec.Code != http.StatusOK {
46 t.Fatalf("got %d, want 200", rec.Code)
47 }
48 if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
49 t.Errorf("Content-Type is %q", ct)
50 }
51 if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="Meeting-notes.md"` {
52 t.Errorf("Content-Disposition is %q", cd)
53 }
54 body := rec.Body.String()
55 for _, want := range []string{"# Meeting notes", "> Quoted.", "1. First", "2. Second"} {
56 if !strings.Contains(body, want) {
57 t.Errorf("markdown is missing %q\n%s", want, body)
58 }
59 }
60}
61
62func TestExportPDF(t *testing.T) {
63 app, err := NewForTest(t)
64 if err != nil {
65 t.Fatalf("NewForTest: %v", err)
66 }
67 doc := exportable(t, app, "writer@example.com", "Meeting notes")
68
69 rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/d/"+doc.Sqid+"/export.pdf", nil))
70 if rec.Code != http.StatusOK {
71 t.Fatalf("got %d, want 200", rec.Code)
72 }
73 if ct := rec.Header().Get("Content-Type"); ct != "application/pdf" {
74 t.Errorf("Content-Type is %q", ct)
75 }
76 if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="Meeting-notes.pdf"` {
77 t.Errorf("Content-Disposition is %q", cd)
78 }
79 if !strings.HasPrefix(rec.Body.String(), "%PDF-1.") {
80 t.Errorf("the body is not a PDF: %q", firstBytes(rec.Body.String()))
81 }
82}
83
84// Spec §4.2: a document you cannot see is indistinguishable from one
85// that does not exist. Both export routes go through the same
86// owner-scoped lookup as every other document route, and this is what
87// holds them to it — an export route that forgot the scope would be a
88// way to read anybody's document by guessing a sqid.
89func TestExportIsOwnerScoped(t *testing.T) {
90 app, err := NewForTest(t)
91 if err != nil {
92 t.Fatalf("NewForTest: %v", err)
93 }
94 doc := exportable(t, app, "owner@example.com", "Private")
95 app.TestPerson(t, "stranger@example.com")
96
97 for _, path := range []string{"/export.md", "/export.pdf"} {
98 rec := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/d/"+doc.Sqid+path, nil))
99 if rec.Code != http.StatusNotFound {
100 t.Errorf("%s: a stranger got %d, want 404", path, rec.Code)
101 }
102 // Unknown and not-yours must be the same answer, byte for
103 // byte, or the difference is an oracle for which sqids exist.
104 missing := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/d/zzzzzzzzzz"+path, nil))
105 if missing.Code != rec.Code || missing.Body.String() != rec.Body.String() {
106 t.Errorf("%s: a document that exists but is not yours answers differently from one that does not exist", path)
107 }
108 }
109}
110
111func TestExportRequiresASession(t *testing.T) {
112 app, err := NewForTest(t)
113 if err != nil {
114 t.Fatalf("NewForTest: %v", err)
115 }
116 doc := exportable(t, app, "owner@example.com", "Private")
117
118 for _, path := range []string{"/export.md", "/export.pdf"} {
119 req, _ := http.NewRequest(http.MethodGet, "/d/"+doc.Sqid+path, nil)
120 rec := serve(app, req)
121 if rec.Code == http.StatusOK {
122 t.Errorf("%s: served a document to a signed-out request", path)
123 }
124 }
125}
126
127// A title is arbitrary text somebody typed, and it ends up in a header
128// and then in a filesystem. Both are places where a quote, a newline
129// or a slash means something.
130func TestExportFilenameIsSanitised(t *testing.T) {
131 cases := []struct {
132 title string
133 want string
134 }{
135 {"Meeting notes", "Meeting-notes.md"},
136 {`say "hello"`, "say-hello.md"},
137 {"../../etc/passwd", "etc-passwd.md"},
138 {"a\r\nSet-Cookie: x=y", "a-Set-Cookie-x-y.md"},
139 {"Café notes", "Caf-notes.md"},
140 {"日報", "SQIDSQIDSQ.md"},
141 {"", "SQIDSQIDSQ.md"},
142 {"!!!", "SQIDSQIDSQ.md"},
143 }
144 for _, c := range cases {
145 doc := &Document{Title: c.title, Sqid: "SQIDSQIDSQ"}
146 if got := exportFilename(doc, ".md"); got != c.want {
147 t.Errorf("title %q: got %q, want %q", c.title, got, c.want)
148 }
149 }
150 // Long titles are capped, and nothing in the result can end a
151 // quoted header value early.
152 long := exportFilename(&Document{Title: strings.Repeat("word ", 40), Sqid: "x"}, ".pdf")
153 if len(long) > 70 {
154 t.Errorf("filename is %d characters: %q", len(long), long)
155 }
156 if strings.ContainsAny(long, `"\/;`+"\r\n") {
157 t.Errorf("filename contains a character that means something in a header: %q", long)
158 }
159}
160
161// The exported PDF and the exported Markdown must be of the SAME
162// document — both read the CRDT through the same core, and a route
163// that read a stale column instead would still return a valid file.
164func TestBothExportsSeeTheSameContent(t *testing.T) {
165 app, err := NewForTest(t)
166 if err != nil {
167 t.Fatalf("NewForTest: %v", err)
168 }
169 doc := exportable(t, app, "writer@example.com", "Both")
170
171 md := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/d/"+doc.Sqid+"/export.md", nil)).Body.String()
172 structure, err := app.Store().JSON(context.Background(), doc)
173 if err != nil {
174 t.Fatal(err)
175 }
176 parsed, err := export.Parse(doc.Title, structure)
177 if err != nil {
178 t.Fatal(err)
179 }
180 if md != export.Markdown(parsed) {
181 t.Error("the route's Markdown differs from the exporter's own")
182 }
183}
184
185// --- the geometry the PDF and the screen must agree about ---
186
187// internal/export lays a PDF out from Go constants; views/show.html
188// lays the same document out from CSS. Nothing makes one drive the
189// other, so this is what stops them drifting: it reads the stylesheet
190// and fails if a number in it stops matching the exporter's.
191//
192// Every lookup below must FIND its pattern, not merely fail to
193// contradict it. A test that silently passes when the stylesheet is
194// restructured would be worse than no test — it is the shape AGENTS.md
195// warns about, a check that cannot tell "no" from "I can't see it".
196func TestPageGeometryMatchesTheStylesheet(t *testing.T) {
197 css, err := os.ReadFile("views/show.html")
198 if err != nil {
199 t.Fatal(err)
200 }
201 sheet := string(css)
202
203 cases := []struct {
204 what string
205 pattern string
206 want float64
207 }{
208 {"page width", `\[docs-page\][^}]*inline-size:\s*min\(100%,\s*([\d.]+)in\)`, export.PageWidthPt / 72},
209 {"page height", `\[docs-page\][^}]*min-block-size:\s*([\d.]+)in`, export.PageHeightPt / 72},
210 {"page margin", `\[docs-page\][^}]*padding:\s*([\d.]+)in`, export.MarginPt / 72},
211 {"body size", `\[docs-text\]\s*\{[^}]*font-size:\s*([\d.]+)pt`, export.BodySizePt},
212 {"body line-height", `\[docs-text\]\s*\{[^}]*line-height:\s*([\d.]+)`, export.BodyLeading},
213 // The indent step, which the stylesheet states in inches and the
214 // exporter in points.
215 {"indent step", `\.docs-indent-1[^}]*margin-inline-start:\s*([\d.]+)in`, export.IndentStepPt / 72},
216 {"deepest indent", `\.docs-indent-8[^}]*margin-inline-start:\s*([\d.]+)in`, export.IndentStepPt * export.MaxIndent / 72},
217 }
218 // Every size mark, in pt on both sides.
219 //
220 // This used to be three hand-written rows comparing `em` — the four
221 // step scale was a multiplier of the body size. The ladder is
222 // absolute now, so the unit changed, and with fourteen marks the
223 // rows are generated from the exporter's own list rather than
224 // written out: a mark added to internal/export and forgotten here
225 // would otherwise be a size the stylesheet never styles, which is
226 // text that renders at the wrong size for every reader.
227 //
228 // Driven from export.SizeMarks() rather than from a list in this
229 // file, because a list here is a list that stops matching.
230 for _, mark := range export.SizeMarks() {
231 pt, ok := export.SizePt(mark)
232 if !ok {
233 t.Errorf("internal/export lists the size mark %q and then does not know its size", mark)
234 continue
235 }
236 cases = append(cases, struct {
237 what string
238 pattern string
239 want float64
240 }{
241 what: "size mark " + mark,
242 pattern: `\.docs-` + regexp.QuoteMeta(strings.ReplaceAll(strings.TrimPrefix(mark, ""), "_", "-")) + `[^}]*font-size:\s*([\d.]+)pt`,
243 want: pt,
244 })
245 }
246 for _, c := range cases {
247 m := regexp.MustCompile(c.pattern).FindStringSubmatch(sheet)
248 if m == nil {
249 t.Errorf("%s: found nothing matching %s in views/show.html — the stylesheet was restructured and this test can no longer see what it is checking", c.what, c.pattern)
250 continue
251 }
252 var got float64
253 if _, err := fmt.Sscanf(m[1], "%g", &got); err != nil {
254 t.Errorf("%s: %q is not a number", c.what, m[1])
255 continue
256 }
257 if got != c.want {
258 t.Errorf("%s: the stylesheet says %v, internal/export says %v", c.what, got, c.want)
259 }
260 }
261
262 // The faces, which the table above cannot see either: it compares
263 // numbers, and a face is a name.
264 //
265 // This is the pairing nothing forced before. The screen's default
266 // family is a literal in a CSS rule and the exporter's is a Go
267 // constant reached through a switch, in two languages, in two
268 // binaries — and a document paginates the same on screen and in its
269 // own PDF only while both measure with the SAME font program. When
270 // they disagree the export does not fail; it just breaks its lines
271 // somewhere else, and the page breaks move with them.
272 //
273 // The want side goes through export.FontCSSName, which resolves a
274 // run through styleFor rather than restating its answer, so this
275 // compares the stylesheet against what the exporter actually does.
276 // The default row (no marks) is the one that matters most: it is
277 // what every paragraph nobody has marked is set in, which is most of
278 // the text in most documents.
279 for _, c := range []struct {
280 what string
281 pattern string
282 want string
283 }{
284 {"default body face", `\[docs-text\]\s*\{[^}]*font-family:\s*"([^"]+)"`, export.FontCSSName()},
285 {"serif mark face", `\.docs-font-serif[^}]*font-family:\s*"([^"]+)"`, export.FontCSSName("font_serif")},
286 {"sans mark face", `\.docs-font-sans[^}]*font-family:\s*"([^"]+)"`, export.FontCSSName("font_sans")},
287 {"mono mark face", `\.docs-font-mono[^}]*font-family:\s*"([^"]+)"`, export.FontCSSName("font_mono")},
288 } {
289 m := regexp.MustCompile(c.pattern).FindStringSubmatch(sheet)
290 if m == nil {
291 t.Errorf("%s: found nothing matching %s in views/show.html — either the rule is missing or the stylesheet was restructured and this test can no longer see what it is checking", c.what, c.pattern)
292 continue
293 }
294 if m[1] != c.want {
295 t.Errorf("%s: the stylesheet names %q, internal/export lays it out in %q", c.what, m[1], c.want)
296 }
297 }
298
299 // The block-level margins, which the table above cannot see.
300 //
301 // internal/export/pdf.go states these three as constants and its
302 // own header calls them a mirror of this stylesheet, but until now
303 // nothing held any of them to it — the table reads eight numbers
304 // and none of them is a margin. That gap bites precisely because
305 // the three are now load-bearing ZEROES: a later edit putting the
306 // paragraph spacing back (`margin-block: 0 1em`, a plausible
307 // "revert the density change") would move the page and leave the
308 // PDF where it was, and only the line-height half of that drift
309 // would have been caught.
310 //
311 // margin-block is a one-or-two-value shorthand and the number that
312 // matters is the END margin, so these read the whole value list and
313 // take its last field rather than capturing the first number they
314 // see — a pattern that captured the first would read "0" out of
315 // "margin-block: 0 1em" and pass while the stylesheet said the
316 // opposite of what pdf.go says.
317 for _, c := range []struct {
318 what string
319 pattern string
320 constant string
321 }{
322 {"paragraph bottom margin", `\[docs-text\] p \{[^}]*margin-block:\s*([^;}]+)`, "paragraph"},
323 {"list bottom margin", `\[docs-text\] ul,\s*\[docs-text\] ol \{[^}]*margin-block:\s*([^;}]+)`, "list"},
324 } {
325 want, ok := export.BlockSpace(c.constant)
326 if !ok {
327 t.Errorf("%s: internal/export has no %q margin — it was renamed and this case is no longer comparing anything", c.what, c.constant)
328 continue
329 }
330 m := regexp.MustCompile(c.pattern).FindStringSubmatch(sheet)
331 if m == nil {
332 t.Errorf("%s: found nothing matching %s in views/show.html — the stylesheet was restructured and this test can no longer see what it is checking", c.what, c.pattern)
333 continue
334 }
335 got, err := blockEndEm(m[1])
336 if err != nil {
337 t.Errorf("%s: %v", c.what, err)
338 continue
339 }
340 if got != want {
341 t.Errorf("%s: the stylesheet says %vem, internal/export says %vem", c.what, got, want)
342 }
343 }
344
345 // listItemSpace has no rule of its own to read: the stylesheet says
346 // "an li has no margin" by having no `[docs-text] li` margin rule at
347 // all, because a browser-default li has none and an explicit 0 would
348 // be a no-op rule.
349 //
350 // An absence is a weak thing to assert alone — it would hold just as
351 // well against an empty file — so this pins a landmark first. If the
352 // `[docs-text] li > :last-child` rule is gone, the list rules were
353 // restructured and the absence below has stopped meaning anything,
354 // which is a failure rather than a pass. And it checks in whichever
355 // direction the constant points, so putting the margin back on both
356 // sides stays possible and putting it back on one does not.
357 itemSpace, ok := export.BlockSpace("list_item")
358 if !ok {
359 t.Error(`internal/export has no "list_item" margin — it was renamed and this case is no longer comparing anything`)
360 } else if !strings.Contains(sheet, "[docs-text] li > :last-child {") {
361 t.Error("list item margin: views/show.html no longer has a `[docs-text] li > :last-child` rule — the list rules were restructured and this check can no longer see what it is checking")
362 } else {
363 liMargin := regexp.MustCompile(`\[docs-text\] li \{[^}]*margin[^}]*\}`).FindString(sheet)
364 switch {
365 case itemSpace == 0 && liMargin != "":
366 t.Errorf("list item margin: internal/export says an li has none, the stylesheet gives it one: %q", liMargin)
367 case itemSpace != 0 && liMargin == "":
368 t.Errorf("list item margin: internal/export says %vem, the stylesheet gives an li no margin rule at all", itemSpace)
369 }
370 }
371}
372
373// blockEndEm reads the block-END margin out of a `margin-block`
374// shorthand, in em: one value sets both edges, two set start then end.
375//
376// A unit other than em is an error rather than a guess. internal/export
377// states these margins in em of the body size, so a value in px or rem
378// is not something this comparison can honestly turn into a number —
379// and saying so loudly is the point, because the alternative is a test
380// that quietly compares the wrong quantity.
381func blockEndEm(value string) (float64, error) {
382 fields := strings.Fields(strings.TrimSpace(value))
383 if len(fields) == 0 || len(fields) > 2 {
384 return 0, fmt.Errorf("margin-block: %q is not one or two values", value)
385 }
386 end := fields[len(fields)-1]
387 if end == "0" {
388 return 0, nil
389 }
390 num, found := strings.CutSuffix(end, "em")
391 if !found {
392 return 0, fmt.Errorf("margin-block ends in %q — internal/export states these margins in em, so this cannot be compared", end)
393 }
394 var f float64
395 if _, err := fmt.Sscanf(num, "%g", &f); err != nil {
396 return 0, fmt.Errorf("margin-block ends in %q, which is not a number", end)
397 }
398 return f, nil
399}
400
401// Every class the Rust renderer can put in the page has to be a class
402// this stylesheet styles.
403//
404// The classes are a contract between core/src/fragment.rs and
405// views/show.html, and nothing but a test holds it: a class the
406// renderer emits and the sheet does not style is formatting that
407// silently does nothing for a reader, and it fails in exactly the way
408// that never produces an error anywhere.
409//
410// It scrapes the classes out of a REAL rendering rather than listing
411// them, so a class added to the renderer and forgotten here is caught
412// by this test rather than missed by it. aligned.bin, styled.bin and
413// fonts.bin between them exercise the whole vocabulary — the block
414// alignments, the size marks, and all three faces.
415func TestEveryClassTheRendererEmitsIsStyled(t *testing.T) {
416 app, err := NewForTest(t)
417 if err != nil {
418 t.Fatalf("NewForTest: %v", err)
419 }
420 ctx := context.Background()
421 owner := app.TestPerson(t, "classes@example.com")
422
423 // One document per fixture: each .bin is a whole Yjs document in
424 // its own right, and applying two of them to one document is a
425 // merge the core rejects rather than the union this wants.
426 classes := map[string]bool{}
427 for _, name := range []string{"aligned.bin", "styled.bin", "fonts.bin"} {
428 doc, err := app.Store().Create(ctx, owner, "Classes: "+name)
429 if err != nil {
430 t.Fatalf("Create: %v", err)
431 }
432 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), readFixture(t, "../../fixtures/vectors/fragment/"+name)); err != nil {
433 t.Fatalf("ApplyUpdate %s: %v", name, err)
434 }
435 html, err := app.Store().HTML(ctx, doc)
436 if err != nil {
437 t.Fatalf("HTML %s: %v", name, err)
438 }
439 for _, m := range regexp.MustCompile(`class="([^"]+)"`).FindAllStringSubmatch(string(html), -1) {
440 for _, c := range strings.Fields(m[1]) {
441 classes[c] = true
442 }
443 }
444 }
445 // A rendering with no classes at all would make every assertion
446 // below vacuous, and that is the failure mode this whole file keeps
447 // warning about.
448 if len(classes) < 2 {
449 t.Fatalf("the rendered fixtures carry %d classes between them — the fixtures or the renderer changed and this test can no longer see what it is checking: %v", len(classes), classes)
450 }
451 if !classes["docs-align-center"] || !classes["docs-align-right"] {
452 t.Errorf("the renderer emitted no alignment classes at all: %v", classes)
453 }
454
455 sheet, err := os.ReadFile("views/show.html")
456 if err != nil {
457 t.Fatal(err)
458 }
459 for class := range classes {
460 if !regexp.MustCompile(`\[docs-text\][^{]*\.` + regexp.QuoteMeta(class) + `[^{]*\{`).Match(sheet) {
461 t.Errorf("core/src/fragment.rs emits class %q and views/show.html has no [docs-text] rule for it — the formatting does nothing for a reader", class)
462 }
463 }
464}
465
466// And alignment specifically has to be styled as alignment. The test
467// above only proves a rule EXISTS; a rule that set a colour would
468// satisfy it while a centred paragraph stayed flush left, so this reads
469// the declaration.
470//
471// The property has to agree with internal/export/pdf.go, which offsets
472// a centred line by half the free space in its column and a right
473// aligned one by all of it — the same two answers CSS's `center` and
474// `right` give.
475func TestTheAlignmentClassesSetTextAlign(t *testing.T) {
476 sheet, err := os.ReadFile("views/show.html")
477 if err != nil {
478 t.Fatal(err)
479 }
480 for _, c := range []struct{ class, want string }{
481 {"docs-align-center", "center"},
482 {"docs-align-right", "right"},
483 } {
484 pattern := `\[docs-text\] \.` + c.class + `\s*\{[^}]*text-align:\s*([a-z-]+)`
485 m := regexp.MustCompile(pattern).FindSubmatch(sheet)
486 if m == nil {
487 t.Errorf("%s: found nothing matching %s in views/show.html — either the rule is missing or the stylesheet was restructured and this test can no longer see what it is checking", c.class, pattern)
488 continue
489 }
490 if got := string(m[1]); got != c.want {
491 t.Errorf("%s sets text-align: %s, want %s", c.class, got, c.want)
492 }
493 }
494}
495
496// The pressed state of a toolbar button must be visible without
497// relying on colour (the spec's rule that colour never carries meaning
498// alone, and AGENTS.md's "WCAG 2.2 AA, no exemptions").
499//
500// This is a regression test for a real defect, found in review: the
501// rule leaned on `font-weight: 700`, which does nothing to an SVG icon,
502// and six of the nine controls here are icon-only -- while the pressed
503// background was the SAME token as :hover, so a hovered unpressed
504// button and a pressed one differed by icon hue alone. The comment
505// beside the rule asserted the mechanism was handled while it was not,
506// which is the failure this codebase names specifically.
507//
508// It compares the two rules rather than looking for one named property,
509// so it stays true to what is actually being asked: whatever
510// distinguishes pressed from hover, at least one of those things must
511// be something an ICON can show. That leaves the choice of cue open --
512// a bar, a ring, an outline -- and still fails if the cue goes away.
513//
514// The first draft of this test passed against the shipped defect,
515// because it excluded only colour and therefore counted the useless
516// `font-weight`. See noCueOnAnIcon below: getting that set right is the
517// whole test.
518func TestThePressedToolbarStateDoesNotRelyOnColour(t *testing.T) {
519 sheet, err := os.ReadFile("views/show.html")
520 if err != nil {
521 t.Fatal(err)
522 }
523 body := func(selector string) string {
524 t.Helper()
525 m := regexp.MustCompile(regexp.QuoteMeta(selector) + `\s*\{([^}]*)\}`).FindSubmatch(sheet)
526 if m == nil {
527 t.Fatalf("no %s rule in views/show.html -- the toolbar styles were restructured and this test can no longer see what it is checking", selector)
528 }
529 return string(m[1])
530 }
531
532 // Properties that cannot distinguish a PRESSED icon button from a
533 // hovered one, and so must not be counted as the cue.
534 //
535 // The three colour properties are obvious. `font-weight` is the one
536 // that matters, and leaving it out of this set is what made the
537 // first draft of this test pass against the very defect it is named
538 // for: weight is not a colour, so a naive reading counts it, but it
539 // does nothing whatsoever to an SVG icon. Six of the nine controls
540 // in this bar are icon-only. A cue has to be something that
541 // actually renders on them.
542 noCueOnAnIcon := map[string]bool{
543 "color": true,
544 "background": true,
545 "background-color": true,
546 "font-weight": true,
547 }
548
549 declared := func(rule string) map[string]string {
550 out := map[string]string{}
551 for _, decl := range strings.Split(rule, ";") {
552 name, value, ok := strings.Cut(decl, ":")
553 if !ok {
554 continue
555 }
556 out[strings.TrimSpace(name)] = strings.TrimSpace(value)
557 }
558 return out
559 }
560
561 hover := declared(body(`[docs-tool]:hover:not(:disabled)`))
562 pressed := declared(body(`[docs-tool][aria-pressed="true"]`))
563 if len(pressed) == 0 {
564 t.Fatal("the pressed rule declares nothing at all")
565 }
566
567 var cues []string
568 for name, value := range pressed {
569 if noCueOnAnIcon[name] {
570 continue
571 }
572 if hover[name] == value {
573 continue // hover has it too, so it distinguishes nothing
574 }
575 cues = append(cues, name)
576 }
577 if len(cues) == 0 {
578 t.Errorf("aria-pressed carries no cue an icon button can show: pressed declares %v, hover declares %v -- colour and font-weight are all that separate them, and weight does nothing to an SVG", pressed, hover)
579 }
580
581 // The trap that was actually shipped: pressed and hover sharing a
582 // background means the background does none of the work, so the cue
583 // found above is carrying the whole distinction on its own.
584 if pressed["background"] != "" && pressed["background"] == hover["background"] {
585 t.Logf("pressed and hover share background %q, so %v carries the whole distinction", pressed["background"], cues)
586 }
587}
588
589// The toolbar's option values are a vocabulary shared by three files:
590// this template, fixtures/toolbar.mjs, and core/src/fragment.rs's mark
591// names. A value here that the bundle does not understand is a control
592// that silently does nothing.
593func TestToolbarOptionValuesMatchTheBundle(t *testing.T) {
594 page, err := os.ReadFile("views/show.html")
595 if err != nil {
596 t.Fatal(err)
597 }
598 bundle, err := os.ReadFile("../../fixtures/toolbar.mjs")
599 if err != nil {
600 t.Fatal(err)
601 }
602 entry, err := os.ReadFile("../../fixtures/editor-entry.js")
603 if err != nil {
604 t.Fatal(err)
605 }
606
607 menu, err := os.ReadFile("../../fixtures/menu.mjs")
608 if err != nil {
609 t.Fatal(err)
610 }
611
612 if !strings.Contains(string(page), "docs-toolbar") {
613 t.Fatal("views/show.html renders no toolbar")
614 }
615
616 // Every command the template names LITERALLY must be handled by one
617 // of the two modules that implement commands.
618 //
619 // "Literally" is the whole scope of this test, and it is worth
620 // being exact about, because the menu bar arriving made that limit
621 // load-bearing for the first time. This reads views/show.html as a
622 // FILE — the template source, not its output — so it only ever sees
623 // names written out in the markup. The toolbar's are
624 // (data-docs-command="strong"); the menu's are not, because
625 // menu.go builds the items and the template renders
626 // data-docs-command="{{.Command}}", which matches no name pattern
627 // at all.
628 //
629 // So this test says nothing whatever about the menu, and adding the
630 // menu bar did not make it fail — it made it silently skip an
631 // entire second surface while continuing to report success. That is
632 // the exact shape AGENTS.md warns about: a check that cannot
633 // distinguish "no" from "I looked at nothing". The menu's half of
634 // the property is checked in menu_test.go's
635 // TestMenuCommandsExistInTheBundle, driven from the Go model rather
636 // than from the markup, which is the only side that can see those
637 // names.
638 //
639 // The pattern is [\w-]+ rather than \w+ so that a hyphenated name
640 // written out literally in a future template edit (insert-link,
641 // insert-page-break) is checked rather than skipped, and
642 // data-docs-unavailable is honoured for the same reason: those
643 // items name commands nobody has built yet, and requiring them to
644 // exist would mean writing stubs — which is what turns "disabled
645 // because unbuilt" into "enabled and does nothing".
646 //
647 // fixtures/menu.mjs is searched alongside toolbar.mjs, for the
648 // commands that are not editor commands. Print is the only one.
649 tag := regexp.MustCompile(`<(?:button|a)[^>]*\sdata-docs-command="([\w-]+)"[^>]*>`)
650 for _, m := range tag.FindAllStringSubmatch(string(page), -1) {
651 if strings.Contains(m[0], "data-docs-unavailable") {
652 continue
653 }
654 if strings.Contains(string(bundle), m[1]) || strings.Contains(string(menu), m[1]) {
655 continue
656 }
657 t.Errorf("the template has a %q control that neither fixtures/toolbar.mjs nor fixtures/menu.mjs implements", m[1])
658 }
659 // Every mark the schema defines must be one core/src/fragment.rs
660 // renders — checked here rather than in Rust because this is the
661 // side that can drift silently: an unknown mark renders in the
662 // browser and vanishes for a reader with no JavaScript.
663 //
664 // The three faces are still spelled out in editor-entry.js, so that
665 // is where they are looked for.
666 for _, mark := range []string{"font_serif", "font_sans", "font_mono"} {
667 if !strings.Contains(string(entry), mark) {
668 t.Errorf("fixtures/editor-entry.js's schema is missing the %q mark", mark)
669 }
670 }
671 // The size marks are NOT, and this clause moved rather than being
672 // deleted.
673 //
674 // They used to be three names written out in editor-entry.js beside
675 // the faces, and this loop found them there. The point ladder made
676 // that fourteen names whose class is mechanically the mark name, so
677 // the schema builds them from fixtures/sizes.mjs's SIZE_MARKS — and
678 // this loop stopped finding anything, which it reported honestly as
679 // three missing marks rather than passing while checking nothing.
680 // That is the better of the two failures and it is why the clause is
681 // worth keeping.
682 //
683 // So it looks where the vocabulary actually is now. The stronger
684 // version of this property — every size the editor can write is one
685 // Rust renders AND the exporter measures — is
686 // sizes_test.go's TestEverySizeMarkIsRenderedAndMeasured, which
687 // derives the list from that same file instead of restating it.
688 sizes, err := os.ReadFile("../../fixtures/sizes.mjs")
689 if err != nil {
690 t.Fatal(err)
691 }
692 for _, mark := range []string{"size_sm", "size_lg", "size_xl"} {
693 if !strings.Contains(string(sizes), mark) {
694 t.Errorf("fixtures/sizes.mjs no longer carries the legacy %q mark — every document using it loses its size on the next edit", mark)
695 }
696 }
697}
698
699// The toolbar's idea of the default face has to be the app's.
700//
701// Three separate statements of one decision, in three languages:
702// internal/fonts' Default (which internal/export/pdf.go lays an unmarked
703// run out with, and which TestPageGeometryMatchesTheStylesheet has
704// already tied to views/show.html's [docs-text] rule),
705// fixtures/fonts.mjs's DEFAULT_FONT (which is what the font control
706// READS when a run carries no font mark), and the order of the options
707// this page renders (which is what that control SHOWS when no script
708// ever sets its value).
709//
710// A disagreement between them is the quietest failure in this change.
711// Nothing throws, nothing is lost, no pixel is wrong except the label:
712// the control says "Serif" over text drawn in sans, so a person who
713// wants serif chooses the face the control already claims to be showing
714// and watches nothing happen. There is no error to find afterwards.
715//
716// Go cannot import an ES module, so the JS half is read as source. That
717// makes the pattern load-bearing in the way this file keeps insisting
718// on: if DEFAULT_FONT is renamed or restructured the match fails and
719// this test says so, rather than finding nothing and passing.
720func TestTheToolbarDefaultFontIsTheDefaultFace(t *testing.T) {
721 want := fonts.Default.Key()
722
723 module, err := os.ReadFile("../../fixtures/fonts.mjs")
724 if err != nil {
725 t.Fatal(err)
726 }
727 m := regexp.MustCompile(`export const DEFAULT_FONT = '([a-z]+)'`).FindSubmatch(module)
728 if m == nil {
729 t.Fatal("fixtures/fonts.mjs no longer declares DEFAULT_FONT the way this test reads it — it was restructured and this check can no longer see what it is checking")
730 }
731 if got := string(m[1]); got != want {
732 t.Errorf("the toolbar reads an unmarked run as %q, internal/fonts lays it out in %q", got, want)
733 }
734
735 // And the scriptless case: a <select> shows its first option until
736 // something sets a value, and with no JavaScript nothing ever does.
737 // Listing the default anywhere but first means the control names a
738 // face the document is not in, for every reader without scripting,
739 // permanently.
740 app, err := NewForTest(t)
741 if err != nil {
742 t.Fatalf("NewForTest: %v", err)
743 }
744 doc := exportable(t, app, "toolbar@example.com", "Fonts")
745 body := serve(app, app.TestRequest(t, "toolbar@example.com", http.MethodGet, "/d/"+doc.Sqid, nil)).Body.String()
746
747 block := regexp.MustCompile(`(?s)data-docs-select="font"[^>]*>(.*?)</select>`).FindStringSubmatch(body)
748 if block == nil {
749 t.Fatal("the page renders no font select — the toolbar markup was restructured and this check can no longer see what it is checking")
750 }
751 options := regexp.MustCompile(`<option value="([^"]+)"`).FindAllStringSubmatch(block[1], -1)
752 if len(options) != 3 {
753 t.Fatalf("the font control offers %d faces, the core set has 3", len(options))
754 }
755 if got := options[0][1]; got != want {
756 t.Errorf("the font control shows %q before any script runs, internal/fonts lays an unmarked run out in %q", got, want)
757 }
758}
759
760// Every face the stylesheet declares must be a face the app serves,
761// and it must actually serve it.
762func TestFontFacesAreDeclaredAndServed(t *testing.T) {
763 app, err := NewForTest(t)
764 if err != nil {
765 t.Fatalf("NewForTest: %v", err)
766 }
767 doc := exportable(t, app, "reader@example.com", "Fonts")
768 body := serve(app, app.TestRequest(t, "reader@example.com", http.MethodGet, "/d/"+doc.Sqid, nil)).Body.String()
769
770 paths := regexp.MustCompile(`url\("(/static/fonts/[^"]+)"\)`).FindAllStringSubmatch(body, -1)
771 if len(paths) != len(fonts.All()) {
772 t.Fatalf("the page declares %d @font-face rules, the core set has %d faces", len(paths), len(fonts.All()))
773 }
774 for _, p := range paths {
775 req, _ := http.NewRequest(http.MethodGet, p[1], nil)
776 rec := serve(app, req)
777 if rec.Code != http.StatusOK {
778 t.Errorf("%s: got %d", p[1], rec.Code)
779 continue
780 }
781 if ct := rec.Header().Get("Content-Type"); ct != "font/ttf" {
782 t.Errorf("%s: Content-Type is %q", p[1], ct)
783 }
784 if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
785 t.Errorf("%s: content-hashed assets should be immutable, got %q", p[1], cc)
786 }
787 if rec.Body.Len() < 10_000 {
788 t.Errorf("%s: served %d bytes, which is not a font", p[1], rec.Body.Len())
789 }
790 }
791
792 // The licence has to travel with the fonts, and be reachable.
793 req, _ := http.NewRequest(http.MethodGet, fonts.LicencePath, nil)
794 if rec := serve(app, req); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "SIL OPEN FONT LICENSE") {
795 t.Errorf("the font licence is not served at %s (got %d)", fonts.LicencePath, rec.Code)
796 }
797}
798
799// A path under the font prefix that names no face must not be served —
800// and must fall through to the rest of /static rather than being
801// answered by the font handler at all.
802func TestUnknownFontPathIsNotServed(t *testing.T) {
803 app, err := NewForTest(t)
804 if err != nil {
805 t.Fatalf("NewForTest: %v", err)
806 }
807 for _, path := range []string{
808 fonts.PathPrefix + "nope.ttf",
809 fonts.PathPrefix + "../../../etc/passwd",
810 fonts.PathPrefix + "LiberationSerif-Regular.ttf", // real name, no hash
811 } {
812 req, _ := http.NewRequest(http.MethodGet, path, nil)
813 if rec := serve(app, req); rec.Code == http.StatusOK {
814 t.Errorf("%s: served something (%d bytes)", path, rec.Body.Len())
815 }
816 }
817}
818
819func firstBytes(s string) string {
820 if len(s) > 40 {
821 return s[:40]
822 }
823 return s
824}
825