| 1 | package docs_test |
| 2 | |
| 3 | // The shared chrome as Docs renders it (estilo chrome, spec |
| 4 | // 2026-09-23-shared-chrome.md). A direct App.Handler() call installs no |
| 5 | // locale middleware, so rastrillo.T returns catalog keys verbatim: every |
| 6 | // label below is its key. |
| 7 | |
| 8 | import ( |
| 9 | "html" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "net/url" |
| 13 | "regexp" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | |
| 17 | "github.com/carlosframework/docs/internal/docs" |
| 18 | ) |
| 19 | |
| 20 | func getAccept(t *testing.T, app *docs.App, cookies []*http.Cookie, path string, header ...string) *httptest.ResponseRecorder { |
| 21 | t.Helper() |
| 22 | req := httptest.NewRequest(http.MethodGet, path, nil) |
| 23 | req.Header.Set("Accept", "text/html,application/xhtml+xml") |
| 24 | for i := 0; i+1 < len(header); i += 2 { |
| 25 | req.Header.Set(header[i], header[i+1]) |
| 26 | } |
| 27 | for _, c := range cookies { |
| 28 | req.AddCookie(c) |
| 29 | } |
| 30 | rec := httptest.NewRecorder() |
| 31 | app.Handler().ServeHTTP(rec, req) |
| 32 | return rec |
| 33 | } |
| 34 | |
| 35 | func titleOf(body string) string { |
| 36 | m := regexp.MustCompile(`<title>([^<]*)</title>`).FindStringSubmatch(body) |
| 37 | if m == nil { |
| 38 | return "" |
| 39 | } |
| 40 | return m[1] |
| 41 | } |
| 42 | |
| 43 | // Every page's <title> is "{part} · {part} · Docs", built by the layout |
| 44 | // alone (spec §10): the root is just the app's name. |
| 45 | func TestPageTitlesFollowTheSuitePattern(t *testing.T) { |
| 46 | app, _ := docs.NewForTest(t) |
| 47 | alice := signInCookies(t, app, "alice@example.com") |
| 48 | sqid := createDoc(t, app, alice, "Minutes") |
| 49 | |
| 50 | cases := []struct { |
| 51 | name, path string |
| 52 | cookies []*http.Cookie |
| 53 | want string |
| 54 | }{ |
| 55 | {"list", "/", alice, "docs.app.name"}, |
| 56 | {"search", "/?q=min", alice, "“min” · docs.app.name"}, |
| 57 | {"document", "/d/" + sqid, alice, "Minutes · docs.app.name"}, |
| 58 | {"share", "/d/" + sqid + "/share", alice, "docs.share.title · Minutes · docs.app.name"}, |
| 59 | {"sign in", "/signin", nil, "Sign in · docs.app.name"}, |
| 60 | {"sent", "/signin?sent=1", nil, "Check your email · docs.app.name"}, |
| 61 | {"signed out", "/signed-out", nil, "Signed out · docs.app.name"}, |
| 62 | } |
| 63 | for _, c := range cases { |
| 64 | rec := getAccept(t, app, c.cookies, c.path) |
| 65 | if got := titleOf(rec.Body.String()); got != c.want { |
| 66 | t.Errorf("%s: <title> = %q, want %q (status %d)", c.name, got, c.want, rec.Code) |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // The bar is the same bar on every signed-in page: 52px is estilo's, and |
| 72 | // this is the structure behind it. Exactly one Sign out, the account menu |
| 73 | // last in the end group, and no rastrillo shell left anywhere. |
| 74 | func TestEverySignedInPageHasOneSignOutInTheSameCorner(t *testing.T) { |
| 75 | app, _ := docs.NewForTest(t) |
| 76 | alice := signInCookies(t, app, "alice@example.com") |
| 77 | sqid := createDoc(t, app, alice, "Minutes") |
| 78 | for _, path := range []string{"/", "/?q=min", "/d/" + sqid, "/d/" + sqid + "/share", "/no/such/page", "/d/doesnotexist"} { |
| 79 | body := getAccept(t, app, alice, path).Body.String() |
| 80 | if n := strings.Count(body, "docs.signout.label"); n != 1 { |
| 81 | t.Errorf("%s: Sign out appears %d times, want exactly 1", path, n) |
| 82 | } |
| 83 | if strings.Contains(body, "rst-shell") { |
| 84 | t.Errorf("%s: the rastrillo shell is still rendered", path) |
| 85 | } |
| 86 | end := strings.Index(body, "<div estilo-bar-end>") |
| 87 | acct := strings.Index(body, "<details estilo-account") |
| 88 | closeEnd := strings.Index(body[acct:], "</details>\n </div>\n</header>") |
| 89 | if end < 0 || acct < end || closeEnd < 0 { |
| 90 | t.Errorf("%s: the account menu is not the last thing in the bar's end group", path) |
| 91 | } |
| 92 | if !strings.Contains(body, "alice@example.com") { |
| 93 | t.Errorf("%s: the account menu does not say who is signed in", path) |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // Signed out, the corner is "Sign in", carrying the page to come back to; |
| 99 | // on the sign-in pages themselves the corner is empty; on the public page |
| 100 | // the brand is plain text and Sign in carries no capability path. |
| 101 | func TestTheSignedOutCorner(t *testing.T) { |
| 102 | app, _ := docs.NewForTest(t) |
| 103 | alice := signInCookies(t, app, "alice@example.com") |
| 104 | sqid := createDoc(t, app, alice, "Minutes") |
| 105 | body := getAccept(t, app, nil, "/no/such/page?x=1").Body.String() |
| 106 | if !strings.Contains(body, `<a rst-btn href="/signin?return_to=`+url.QueryEscape("/no/such/page?x=1")+`" estilo-bar-signin>docs.chrome.signin</a>`) { |
| 107 | t.Error("the signed-out corner has no Sign in carrying this page", body) |
| 108 | } |
| 109 | for _, path := range []string{"/signin", "/signin?sent=1", "/signin?err=expired", "/signed-out"} { |
| 110 | body := getAccept(t, app, nil, path).Body.String() |
| 111 | if strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") { |
| 112 | t.Errorf("%s: a sign-in page shows the sign-in corner", path) |
| 113 | } |
| 114 | if !strings.Contains(body, "<header estilo-bar") { |
| 115 | t.Errorf("%s: a sign-in page has no bar (and so no name)", path) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | post(t, app, alice, "/d/"+sqid+"/share/link", "on=1") |
| 120 | share := get(t, app, alice, "/d/"+sqid+"/share").Body.String() |
| 121 | m := regexp.MustCompile(`/p/[A-Za-z0-9_-]+`).FindString(share) |
| 122 | if m == "" { |
| 123 | t.Fatal("no public link to follow") |
| 124 | } |
| 125 | public := getAccept(t, app, nil, m).Body.String() |
| 126 | if strings.Contains(public, "<a estilo-bar-brand") || !strings.Contains(public, "<span estilo-bar-brand>") { |
| 127 | t.Error("the public page's brand is a link a stranger is bounced by") |
| 128 | } |
| 129 | if !strings.Contains(public, `<a rst-btn href="/signin" estilo-bar-signin>`) { |
| 130 | t.Error("the public page's Sign in carries the capability path, or is missing") |
| 131 | } |
| 132 | if !strings.Contains(public, `rst-tone="info"`) || strings.Contains(public, "rst-notice") { |
| 133 | t.Error("the public notice is not an info callout") |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // The switcher appears only with a hub directory configured, and until |
| 138 | // Home's /apps/<slug> changes land it is "All apps" alone, with no tiles. |
| 139 | func TestTheSwitcherIsAllAppsOnly(t *testing.T) { |
| 140 | plain, _ := docs.NewForTest(t) |
| 141 | if body := get(t, plain, signInCookies(t, plain, "a@example.com"), "/").Body.String(); strings.Contains(body, "estilo-switcher") { |
| 142 | t.Error("a switcher with no hub directory") |
| 143 | } |
| 144 | app, err := docs.NewForTestWith(t, docs.Config{Directory: "https://oficina.works"}) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | body := get(t, app, signInCookies(t, app, "a@example.com"), "/").Body.String() |
| 149 | if !strings.Contains(body, `<details estilo-switcher name="estilo-menus">`) || !strings.Contains(body, `<a estilo-menu-item href="https://oficina.works/"><span estilo-menu-label>docs.chrome.allapps</span></a>`) { |
| 150 | t.Error("the switcher does not offer All apps", body) |
| 151 | } |
| 152 | if strings.Contains(body, "estilo-switcher-app") { |
| 153 | t.Error("the switcher renders tiles before Home can serve them") |
| 154 | } |
| 155 | for _, bad := range []string{"http://oficina.works", "https://oficina.works/", "https://Oficina.works", "https://127.0.0.1", "https://localhost", "https://a.b:8443", "https://user@a.b"} { |
| 156 | if _, err := docs.NewForTestWith(t, docs.Config{Directory: bad}); err == nil { |
| 157 | t.Errorf("directory %q was accepted", bad) |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // A writer renames in the bar: a real form, so Enter works with scripts |
| 163 | // off, and it lands back on the document. A reader gets the title |
| 164 | // read-only (spec §5.4). |
| 165 | func TestTheBarRenamesTheDocument(t *testing.T) { |
| 166 | app, _ := docs.NewForTest(t) |
| 167 | alice := signInCookies(t, app, "alice@example.com") |
| 168 | sqid := createDoc(t, app, alice, "Untitled") |
| 169 | body := get(t, app, alice, "/d/"+sqid).Body.String() |
| 170 | if !strings.Contains(body, `<form estilo-bar-title method="post" action="/d/`+sqid+`/title">`) || !strings.Contains(body, `name="title" value="Untitled" aria-label="docs.chrome.title.label"`) { |
| 171 | t.Fatal("a writer has no editable title in the bar") |
| 172 | } |
| 173 | req := httptest.NewRequest(http.MethodPost, "/d/"+sqid+"/title", strings.NewReader("title=Board+minutes")) |
| 174 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 175 | req.Header.Set("Accept", "text/html") |
| 176 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 177 | for _, c := range alice { |
| 178 | req.AddCookie(c) |
| 179 | } |
| 180 | rec := httptest.NewRecorder() |
| 181 | app.Handler().ServeHTTP(rec, req) |
| 182 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/d/"+sqid { |
| 183 | t.Fatalf("rename: %d %q, want 303 back to the document", rec.Code, rec.Header().Get("Location")) |
| 184 | } |
| 185 | if title := titleOf(get(t, app, alice, "/d/"+sqid).Body.String()); title != "Board minutes · docs.app.name" { |
| 186 | t.Fatalf("after rename the title is %q", title) |
| 187 | } |
| 188 | |
| 189 | bob := signInCookies(t, app, "bob@example.com") |
| 190 | post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader") |
| 191 | reader := get(t, app, bob, "/d/"+sqid).Body.String() |
| 192 | if strings.Contains(reader, "estilo-bar-title") || !strings.Contains(reader, `<span estilo-bar-context dir="auto" title="Board minutes">Board minutes</span>`) { |
| 193 | t.Error("a reader is offered rename, or loses the title") |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // Appearance is per app, in a host-only cookie, and the layout writes it |
| 198 | // into <html data-theme> so the first paint is right. |
| 199 | func TestAppearanceIsRememberedAndRendered(t *testing.T) { |
| 200 | app, _ := docs.NewForTest(t) |
| 201 | alice := signInCookies(t, app, "alice@example.com") |
| 202 | if body := get(t, app, alice, "/").Body.String(); strings.Contains(body, "data-theme") || !strings.Contains(body, `value="system" aria-pressed="true"`) { |
| 203 | t.Fatal("no preference should follow the system") |
| 204 | } |
| 205 | rec := post(t, app, alice, "/appearance", "mode=dark&return_to=%2F%3Fq%3Dx") |
| 206 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/?q=x" { |
| 207 | t.Fatalf("POST /appearance: %d %q", rec.Code, rec.Header().Get("Location")) |
| 208 | } |
| 209 | cookies := append(rec.Result().Cookies(), alice...) |
| 210 | body := get(t, app, cookies, "/").Body.String() |
| 211 | if !strings.Contains(body, `<html lang="en" dir="ltr" data-theme="dark">`) || !strings.Contains(body, `value="dark" aria-pressed="true"`) { |
| 212 | t.Fatal("the chosen appearance is not rendered") |
| 213 | } |
| 214 | // Outside the session guard, inside the CSRF check. |
| 215 | signedOut := post(t, app, nil, "/appearance", "mode=light&return_to=%2F") |
| 216 | if signedOut.Code != http.StatusSeeOther { |
| 217 | t.Errorf("an expired session turns Appearance into %d", signedOut.Code) |
| 218 | } |
| 219 | cross := httptest.NewRequest(http.MethodPost, "/appearance", strings.NewReader("mode=light")) |
| 220 | cross.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 221 | cross.Header.Set("Sec-Fetch-Site", "cross-site") |
| 222 | crossRec := httptest.NewRecorder() |
| 223 | app.Handler().ServeHTTP(crossRec, cross) |
| 224 | if crossRec.Code == http.StatusSeeOther || len(crossRec.Result().Cookies()) != 0 { |
| 225 | t.Error("a cross-site form set the appearance") |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | // Every notice is a rastrillo callout with one of its four tones (spec |
| 230 | // §4): rst-notice, a valued rst-callout attribute and "warn" are gone. |
| 231 | func TestTemplatesUseOnlyCalloutTones(t *testing.T) { |
| 232 | for _, bad := range []string{"rst-notice", `rst-callout="`, `rst-tone="warn"`} { |
| 233 | for path, body := range docs.TestViewSources(t) { |
| 234 | if strings.Contains(body, bad) { |
| 235 | t.Errorf("%s writes %s", path, bad) |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // Shortcut labels are the reader's platform's, decided on the server so |
| 242 | // nothing flips after paint (spec §7.3). |
| 243 | func TestShortcutLabelsFollowThePlatform(t *testing.T) { |
| 244 | app, _ := docs.NewForTest(t) |
| 245 | alice := signInCookies(t, app, "alice@example.com") |
| 246 | sqid := createDoc(t, app, alice, "Minutes") |
| 247 | // html/template writes "+" in an attribute as +; a browser reads it |
| 248 | // back as "+", and so does this. |
| 249 | mac := html.UnescapeString(getAccept(t, app, alice, "/d/"+sqid, "User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)").Body.String()) |
| 250 | win := html.UnescapeString(getAccept(t, app, alice, "/d/"+sqid, "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)").Body.String()) |
| 251 | for _, want := range []string{`data-docs-shortcut="⌘Z"`, `data-docs-shortcut="⇧⌘Z"`, `data-docs-shortcut="⌘B"`, `data-docs-shortcut="⌘K"`, `data-keys="⇧⇥"`, `data-keys="⎋"`} { |
| 252 | if !strings.Contains(mac, want) { |
| 253 | t.Errorf("Mac page lacks %s", want) |
| 254 | } |
| 255 | } |
| 256 | for _, want := range []string{`data-docs-shortcut="Ctrl+Z"`, `data-docs-shortcut="Ctrl+Y"`, `data-docs-shortcut="Ctrl+B"`, `data-docs-shortcut="Ctrl+K"`, `data-keys="Shift+Tab"`, `data-keys="Esc"`} { |
| 257 | if !strings.Contains(win, want) { |
| 258 | t.Errorf("Windows page lacks %s", want) |
| 259 | } |
| 260 | } |
| 261 | if strings.Contains(mac, "Ctrl+") { |
| 262 | t.Error("the Mac page still says Ctrl") |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // Unbuilt features are hidden, not disabled (spec §7.3): no Insert menu, |
| 267 | // no Find and replace, no Show ruler, and Sign out is not in File. |
| 268 | func TestUnbuiltMenuItemsAreHidden(t *testing.T) { |
| 269 | app, _ := docs.NewForTest(t) |
| 270 | alice := signInCookies(t, app, "alice@example.com") |
| 271 | sqid := createDoc(t, app, alice, "Minutes") |
| 272 | body := get(t, app, alice, "/d/"+sqid).Body.String() |
| 273 | menubar := body[strings.Index(body, "<nav estilo-menubar"):] |
| 274 | menubar = menubar[:strings.Index(menubar, "</nav>")] |
| 275 | for _, gone := range []string{"docs.editor.menu.insert", "insert-", "find_replace", "view_ruler", "data-docs-unavailable", "/signout"} { |
| 276 | if strings.Contains(menubar, gone) { |
| 277 | t.Errorf("the menu bar still offers %s", gone) |
| 278 | } |
| 279 | } |
| 280 | for _, want := range []string{"docs.editor.menu.file", "docs.editor.menu.edit", "docs.editor.menu.view", "docs.editor.menu.format", "docs.editor.menu.tools", `data-docs-jumpto`} { |
| 281 | if !strings.Contains(menubar, want) { |
| 282 | t.Errorf("the menu bar lost %s", want) |
| 283 | } |
| 284 | } |
| 285 | if n := strings.Count(menubar, `name="estilo-menus"`); n != 5 { |
| 286 | t.Errorf("%d menubar menus in the shared exclusivity group, want 5", n) |
| 287 | } |
| 288 | } |
| 289 | |