oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package 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
8import (
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
20func 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
35func 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.
45func 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.
74func 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.
101func 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.
139func 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).
165func 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.
199func 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.
231func 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).
243func 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 &#43;; 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.
268func 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