| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 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. |
| 15 | var 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. |
| 23 | func 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. |
| 117 | func 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. |
| 152 | func 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. |
| 184 | func 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. |
| 211 | func 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. |
| 292 | func 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). |
| 333 | func 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 | |