| 1 | package docs |
| 2 | |
| 3 | import ( |
| 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. |
| 19 | func 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 | |
| 37 | func 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 | |
| 62 | func 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. |
| 89 | func 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 | |
| 111 | func 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. |
| 130 | func 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. |
| 164 | func 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". |
| 196 | func 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. |
| 381 | func 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. |
| 415 | func 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. |
| 475 | func 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. |
| 518 | func 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. |
| 593 | func 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. |
| 720 | func 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. |
| 762 | func 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. |
| 802 | func 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 | |
| 819 | func firstBytes(s string) string { |
| 820 | if len(s) > 40 { |
| 821 | return s[:40] |
| 822 | } |
| 823 | return s |
| 824 | } |
| 825 | |