oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "regexp"
8 "strings"
9 "testing"
10)
11
12// bundleLink finds the editor bundle's script tag on a page: the one
13// committed file under internal/slides/static, served through the
14// fingerprinted asset handler.
15var bundleLink = regexp.MustCompile(`/static/editor\.[0-9a-f]{16}(\.[0-9a-f]{16})?\.js`)
16
17// TestEditorPageLinksTheCommittedBundle: GET /d/{sqid}/edit is the one
18// screen that needs the bundle (spec §8), and the page carries
19// everything the bundle reads back — the deck, the seq, the current
20// slide, the person — as data rather than as script. The strip and the
21// stage are server-rendered too, so the first paint is right before a
22// byte of JS has run.
23func TestEditorPageLinksTheCommittedBundle(t *testing.T) {
24 a := newApp(t)
25 cookies := signedIn(t, a, allowedAddress)
26 path := sampleDeck(t, a, cookies)
27 sqid := strings.TrimPrefix(path, "/d/")
28
29 page := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String()
30
31 src := bundleLink.FindString(page)
32 if src == "" {
33 t.Fatalf("the editor page links no committed bundle:\n%s", page)
34 }
35 if !strings.Contains(page, `type="module"`) {
36 t.Errorf("the bundle is an ES module and must be loaded as one:\n%s", page)
37 }
38 js := mustGet(t, a, src, http.StatusOK, nil)
39 if ct := js.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
40 t.Errorf("GET %s: Content-Type = %q, want text/javascript", src, ct)
41 }
42 if !strings.Contains(js.Header().Get("Cache-Control"), "immutable") {
43 t.Errorf("GET %s: Cache-Control = %q, want immutable", src, js.Header().Get("Cache-Control"))
44 }
45 for _, css := range []string{"editor", "slides"} {
46 if !regexp.MustCompile(`/static/` + css + `\.[0-9a-f]{16}\.css`).MatchString(page) {
47 t.Errorf("the editor page links no fingerprinted %s.css:\n%s", css, page)
48 }
49 }
50
51 for _, attr := range []string{
52 `data-deck="` + sqid + `"`,
53 `data-seq="`,
54 `data-slide="`,
55 `data-client-name="`,
56 `data-client-colour="`,
57 } {
58 if !strings.Contains(page, attr) {
59 t.Errorf("the editor page carries no %s:\n%s", attr, page)
60 }
61 }
62 if strings.Contains(page, `data-slide=""`) {
63 t.Errorf("the sample deck has slides, so data-slide must name the first one:\n%s", page)
64 }
65
66 // The strip: one <li> per slide, each an <img> of the server's SVG
67 // of it (thumbs.go), the current one saying so in words.
68 // The <template> the bundle clones for a new slide is an item too,
69 // with no slide id; the eight with one are the deck's.
70 if n := strings.Count(page, `<li slides-strip-item draggable="true" data-slide-id="`); n != 8 {
71 t.Errorf("the strip lists %d slides, want the sample deck's 8:\n%s", n, page)
72 }
73 // One section on the page, and it is the stage. The strip used to
74 // carry eight more — a whole live copy of every slide, scaled down
75 // by CSS — and each is now one picture.
76 if n := strings.Count(page, "<section slides-slide"); n != 1 {
77 t.Errorf("the page renders %d sections, want only the stage:\n%s", n, page)
78 }
79 if n := strings.Count(page, `<img slides-thumb-img src="`); n != 8 {
80 t.Errorf("the strip has %d thumbnails with a picture, want the sample deck's 8:\n%s", n, page)
81 }
82 if !strings.Contains(page, `.svg?seq=`) {
83 t.Errorf("a strip thumbnail does not point at the SVG route:\n%s", page)
84 }
85 if !strings.Contains(page, `aria-current="true"`) {
86 t.Errorf("no strip item is marked current:\n%s", page)
87 }
88 if !strings.Contains(page, "Current slide") {
89 t.Errorf("the current slide is not said in words:\n%s", page)
90 }
91 if !strings.Contains(page, sampleSlide2Heading) {
92 t.Errorf("the strip does not label slide 2 by its heading:\n%s", page)
93 }
94
95 // The status line, with every state's text server-rendered from the
96 // catalog so the bundle never carries English.
97 for _, want := range []string{`data-text-saved="Saved"`, `data-text-saving="Saving…"`, `data-text-retrying="Offline, retrying"`, `data-text-failed="Some changes could not be saved. Reload to continue."`, "<noscript>"} {
98 if !strings.Contains(page, want) {
99 t.Errorf("the editor page lacks %s:\n%s", want, page)
100 }
101 }
102 if !strings.Contains(page, `href="`+path+`"`) {
103 t.Errorf("the noscript fallback does not link the viewer:\n%s", page)
104 }
105 // Every shortcut has a visible menu equivalent.
106 for _, want := range []string{"New slide", "Duplicate slide", `action="` + path + `/present"`} {
107 if !strings.Contains(page, want) {
108 t.Errorf("the editor's menu lacks %s:\n%s", want, page)
109 }
110 }
111}
112
113// TestEditorRequiresMembership: the editor is deckFor's audience — the
114// owner and, on a team deck, every member — and 404 to anybody else, so
115// the sqid of a private deck stays unconfirmed. The viewer offers the
116// Edit button to exactly the people the editor lets in.
117func TestEditorRequiresMembership(t *testing.T) {
118 a := newSharedApp(t)
119 mine := signedIn(t, a, allowedAddress)
120 theirs := signedIn(t, a, otherAddress)
121
122 path := createDeck(t, a, mine, "Board update")
123 mustGet(t, a, path+"/edit", http.StatusNotFound, theirs)
124 mustGet(t, a, path+"/edit", http.StatusOK, mine)
125
126 owner := mustGet(t, a, path, http.StatusOK, mine).Body.String()
127 if !strings.Contains(owner, `href="`+path+`/edit"`) {
128 t.Errorf("the owner's viewer offers no Edit:\n%s", owner)
129 }
130
131 if rec := post(t, a, path+"/settings", url.Values{
132 "title": {"Board update"}, "theme": {"ink"}, "rung": {"team"},
133 }, mine...); rec.Code != http.StatusSeeOther {
134 t.Fatalf("raising the rung: status %d\n%s", rec.Code, rec.Body)
135 }
136 mustGet(t, a, path+"/edit", http.StatusOK, theirs)
137 member := mustGet(t, a, path, http.StatusOK, theirs).Body.String()
138 if !strings.Contains(member, `href="`+path+`/edit"`) {
139 t.Errorf("a member's viewer offers no Edit:\n%s", member)
140 }
141
142 // Signed out, the editor is behind the gate like every other
143 // signed-in screen: a redirect to sign in, never the page.
144 if rec := get(t, a, path+"/edit"); rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
145 t.Errorf("GET %s/edit signed out: status %d, want a redirect to /signin", path, rec.Code)
146 }
147}
148
149// TestEditorOfAnEmptyDeck: a deck with no slides is a real state (the
150// viewer's own rule), so the editor opens on it with no current slide
151// and a way to make the first one, rather than a 404 or a 500.
152func TestEditorOfAnEmptyDeck(t *testing.T) {
153 a := newApp(t)
154 cookies := signedIn(t, a, allowedAddress)
155 path := createDeck(t, a, cookies, "Blank")
156 sqid := strings.TrimPrefix(path, "/d/")
157
158 // A new deck opens with one slide; an agent takes it away, which
159 // is the only way a deck ends up with none.
160 token := mintAgent(t, a, cookies, "claude")
161 before := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String()
162 m := regexp.MustCompile(`data-slide="([a-z2-7]{12})"`).FindStringSubmatch(before)
163 if m == nil {
164 t.Fatalf("a new deck's editor names no current slide:\n%s", before)
165 }
166 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
167 `{"ops":[{"op":"remove_slide","id":"`+m[1]+`"}]}`, http.StatusOK)
168
169 page := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String()
170 if !strings.Contains(page, `data-slide=""`) {
171 t.Errorf("an empty deck's editor names a current slide:\n%s", page)
172 }
173 if !strings.Contains(page, "No slides yet") {
174 t.Errorf("an empty deck's editor does not say so:\n%s", page)
175 }
176 if !strings.Contains(page, "New slide") {
177 t.Errorf("an empty deck's editor offers no way to add a slide:\n%s", page)
178 }
179}
180
181// TestEditorSlideHTMLServesTheThumbnailViewport: ?vp=thumb is the strip's
182// rendering and is the desktop one — a thumbnail is a desktop slide
183// scaled by CSS, never a third rendering.
184func TestEditorSlideHTMLServesTheThumbnailViewport(t *testing.T) {
185 a := newApp(t)
186 cookies := signedIn(t, a, allowedAddress)
187 path := sampleDeck(t, a, cookies)
188
189 page := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String()
190 m := regexp.MustCompile(`data-slide="([a-z2-7]{12})"`).FindStringSubmatch(page)
191 if m == nil {
192 t.Fatalf("no current slide id on the editor page:\n%s", page)
193 }
194 thumb := mustGet(t, a, path+"/slides/"+m[1]+".html?vp=thumb", http.StatusOK, cookies).Body.String()
195 if !strings.HasPrefix(thumb, "<section slides-slide") {
196 t.Errorf("the thumbnail is not a bare section:\n%s", thumb)
197 }
198 if strings.Contains(thumb, `data-vp="phone"`) {
199 t.Errorf("the thumbnail rendered as a phone:\n%s", thumb)
200 }
201}
202
203// TestEditorInspectorMarkupIsFormsWithLabels: every control in the
204// inspector is a real form control with a name — a <label for> that
205// names its id, or a wrapping <label> (the switch's shape) — whether or
206// not the bundle has it on screen. The scan is a walk of the tags in
207// [slides-inspector]: a control inside a <label> passes, a control with
208// an id that some <label for> names passes, anything else fails by
209// name. It also checks the three sections are forms and the eleven
210// block types are in the add menu, the way a keyboard reaches them.
211func TestEditorInspectorMarkupIsFormsWithLabels(t *testing.T) {
212 a := newApp(t)
213 cookies := signedIn(t, a, allowedAddress)
214 path := sampleDeck(t, a, cookies)
215 page := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String()
216
217 start := strings.Index(page, "<details slides-inspector")
218 end := strings.Index(page, "<dialog slides-link-dialog")
219 if start < 0 || end < start {
220 t.Fatalf("no inspector on the page:\n%s", page)
221 }
222 inspector := page[start:end]
223
224 labelFor := map[string]bool{}
225 for _, m := range regexp.MustCompile(`<label[^>]*\sfor="([^"]+)"`).FindAllStringSubmatch(inspector, -1) {
226 labelFor[m[1]] = true
227 }
228 tag := regexp.MustCompile(`<(/?)(label|input|select|textarea)\b([^>]*)>`)
229 idAttr := regexp.MustCompile(`\sid="([^"]+)"`)
230 nameAttr := regexp.MustCompile(`\sname="([^"]+)"`)
231 depth, controls := 0, 0
232 for _, m := range tag.FindAllStringSubmatch(inspector, -1) {
233 closing, name, attrs := m[1] == "/", m[2], m[3]
234 if name == "label" {
235 if closing {
236 depth--
237 } else {
238 depth++
239 }
240 continue
241 }
242 if closing {
243 continue
244 }
245 controls++
246 if depth > 0 {
247 continue
248 }
249 id := idAttr.FindStringSubmatch(attrs)
250 if id == nil {
251 t.Errorf("an inspector <%s> has no label and no id: <%s%s>", name, name, attrs)
252 continue
253 }
254 if !labelFor[id[1]] {
255 t.Errorf("no <label for=%q> names the inspector's <%s%s>", id[1], name, attrs)
256 }
257 if n := nameAttr.FindStringSubmatch(attrs); n == nil {
258 t.Errorf("the inspector's %s has no name", id[1])
259 }
260 }
261 if controls < 30 {
262 t.Errorf("the inspector holds %d controls, want the deck, slide and block sections' worth", controls)
263 }
264 for _, section := range []string{"deck", "slide", "block"} {
265 if !strings.Contains(inspector, `data-section="`+section+`"`) {
266 t.Errorf("the inspector has no form for the %s section", section)
267 }
268 }
269 if n := strings.Count(inspector, "<form "); n != 3 {
270 t.Errorf("the inspector holds %d forms, want 3", n)
271 }
272 for _, kind := range []string{"heading", "text", "list", "quote", "image", "columns", "code", "shape", "table", "divider", "spacer"} {
273 if !strings.Contains(inspector, `data-add-block="`+kind+`"`) {
274 t.Errorf("the add menu lists no %s", kind)
275 }
276 }
277 // The strip's menu, the switcher and the canvas overlay carry their
278 // words from the catalog too.
279 for _, want := range []string{
280 `data-slide-command="move-up"`, `name="slides-view" value="phone"`, `data-text-phone-override="Phone override"`,
281 `data-type-heading="Heading"`, `<template slides-strip-item-template>`,
282 } {
283 if !strings.Contains(page, want) {
284 t.Errorf("the editor page lacks %s:\n%s", want, page)
285 }
286 }
287}
288
289// TestEditorSlideHTMLAnswers304ToItsOwnETag: the stage sends the tag
290// back on its next fetch, and an unchanged slide costs the server no
291// document open. A different seq is a different tag.
292func TestEditorSlideHTMLAnswers304ToItsOwnETag(t *testing.T) {
293 a := newApp(t)
294 cookies := signedIn(t, a, allowedAddress)
295 path := sampleDeck(t, a, cookies)
296 page := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String()
297 m := regexp.MustCompile(`data-slide="([a-z2-7]{12})"`).FindStringSubmatch(page)
298 if m == nil {
299 t.Fatalf("no current slide id on the editor page:\n%s", page)
300 }
301 target := path + "/slides/" + m[1] + ".html?vp=desktop"
302 first := mustGet(t, a, target, http.StatusOK, cookies)
303 etag := first.Header().Get("ETag")
304 if etag == "" {
305 t.Fatalf("the slide HTML carries no ETag")
306 }
307
308 req := httptest.NewRequest(http.MethodGet, target, nil)
309 for _, c := range cookies {
310 req.AddCookie(c)
311 }
312 req.Header.Set("If-None-Match", etag)
313 rec := httptest.NewRecorder()
314 a.ServeHTTP(rec, req)
315 if rec.Code != http.StatusNotModified {
316 t.Errorf("GET with the slide's own ETag: status %d, want 304", rec.Code)
317 }
318 if rec.Body.Len() != 0 {
319 t.Errorf("a 304 carried a body:\n%s", rec.Body)
320 }
321 // The phone rendering of the same slide at the same seq is other
322 // bytes, and says so.
323 phone := mustGet(t, a, path+"/slides/"+m[1]+".html?vp=phone", http.StatusOK, cookies)
324 if phone.Header().Get("ETag") == etag {
325 t.Errorf("the phone rendering shares the desktop's ETag %s", etag)
326 }
327}
328
329// TestEditorOfAnEmptyDeckHasAStage: the stage and the notes pane are on
330// the page whether or not there is a slide, because New slide loads
331// the first slide INTO the stage; the empty state sits inside it until
332// then (task 3 review round 1, finding 1).
333func TestEditorOfAnEmptyDeckHasAStage(t *testing.T) {
334 a := newApp(t)
335 cookies := signedIn(t, a, allowedAddress)
336 path := createDeck(t, a, cookies, "Blank")
337 sqid := strings.TrimPrefix(path, "/d/")
338 token := mintAgent(t, a, cookies, "claude")
339 before := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String()
340 m := regexp.MustCompile(`data-slide="([a-z2-7]{12})"`).FindStringSubmatch(before)
341 if m == nil {
342 t.Fatalf("a new deck's editor names no current slide:\n%s", before)
343 }
344 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
345 `{"ops":[{"op":"remove_slide","id":"`+m[1]+`"}]}`, http.StatusOK)
346
347 page := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String()
348 stage := regexp.MustCompile(`(?s)<div slides-stage data-vp="desktop">(.*?)</div>\s*<div slides-stage-phone`).FindStringSubmatch(page)
349 if stage == nil {
350 t.Fatalf("an empty deck's editor has no [slides-stage]:\n%s", page)
351 }
352 if !strings.Contains(stage[1], "No slides yet") {
353 t.Errorf("the empty state is not inside the stage:\n%s", stage[1])
354 }
355 if !strings.Contains(page, "<div slides-notes>") {
356 t.Errorf("an empty deck's editor has no notes element:\n%s", page)
357 }
358}
359