oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3// The browser drive: the whole app in a real Chromium, wired exactly
4// as cmd/slides/main.go wires it — loud on any console error, failed
5// request, 4xx/5xx, or JS value that leaked to the screen unrendered.
6// Not part of the plain suite:
7//
8// go test -tags browser ./...
9//
10// It needs a Chromium: on PATH, via RASTRILLO_CHROME, or in a
11// Playwright cache. A skip is not a pass — with no browser this fails,
12// unless RASTRILLO_BROWSER_OPTIONAL is set, which makes the skip a
13// deliberate visible choice rather than an accident.
14package slidestest
15
16import (
17 "bytes"
18 "context"
19 "fmt"
20 "html"
21 "io"
22 "log/slog"
23 "net/http"
24 "net/http/cookiejar"
25 "net/url"
26 "path/filepath"
27 "strings"
28 "testing"
29 "time"
30
31 "amadan.net/rastrillo/rastrillo"
32 "amadan.net/rastrillo/rastrillo/blobs"
33 "amadan.net/rastrillo/rastrillo/db"
34 "amadan.net/rastrillo/rastrillo/harness"
35 "github.com/chromedp/cdproto/input"
36 "github.com/chromedp/cdproto/runtime"
37 "github.com/chromedp/chromedp"
38 "github.com/chromedp/chromedp/kb"
39
40 slides "amadan.net/oficina/slides/internal/slides"
41)
42
43// newRig boots the app under the rig, wired as main.go wires it, and
44// returns the rig with the mailer the app sends through — the seam a
45// browser sign-in needs, because the magic link only exists in mail.
46func newRig(t *testing.T) (*harness.Rig, *fakeMailer) {
47 return newRigWith(t, nil)
48}
49
50// newRigWithStore is newRig with the object store development binds — a
51// directory beside the database — so the image upload has somewhere to
52// put bytes. Nothing else about the app differs.
53func newRigWithStore(t *testing.T) (*harness.Rig, *fakeMailer) {
54 t.Helper()
55 store, err := blobs.Dir(t.TempDir())
56 if err != nil {
57 t.Fatalf("blobs.Dir: %v", err)
58 }
59 return newRigWith(t, store)
60}
61
62func newRigWith(t *testing.T, store blobs.Store) (*harness.Rig, *fakeMailer) {
63 t.Helper()
64 mailer := &fakeMailer{}
65 rig := harness.New(t, func(origin string) http.Handler {
66 // The same wiring main.go does, origin included: the rig hands
67 // the app its localhost origin before serving — the same seam
68 // main.go fills from SLIDES_ORIGIN.
69 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
70 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
71 if err != nil {
72 t.Fatalf("db.Open: %v", err)
73 }
74 t.Cleanup(func() { d.Close() })
75 mux, a, err := slides.New(d, slides.Config{
76 Origin: origin,
77 InstanceKey: testInstanceKey,
78 Mailer: mailer,
79 AllowedAddresses: []string{allowedAddress},
80 Blobs: store,
81 Logger: logger,
82 })
83 if err != nil {
84 t.Fatalf("slides.New: %v", err)
85 }
86 t.Cleanup(func() { a.Close() })
87 mailer.flush = a.FlushMail
88 // Behind rastrillo's serving stack, built from the ServeOptions
89 // main hands rastrillo.Serve: its security headers, the locale
90 // middleware and Slides' CSP. Serving the bare mux would leave
91 // every drive here blind to the policy a browser is really told,
92 // and a style the policy refused is a console error the rig
93 // fails on.
94 h, closeFn, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{Logger: logger}, mux))
95 if err != nil {
96 t.Fatalf("rastrillo.Handler: %v", err)
97 }
98 t.Cleanup(func() { _ = closeFn() })
99 return h
100 })
101 return rig, mailer
102}
103
104// TestBrowserWalk is the minimal loud walk: the front door arrives with
105// nothing wrong anywhere the rig watches. It walks /signin rather than
106// "/" because "/" is behind the session gate now, and a redirect is not
107// a screen. Grow it with the app — each screen a flow reaches earns a
108// Screen() line, and an expected probe earns a rig.Allow.
109func TestBrowserWalk(t *testing.T) {
110 rig, _ := newRig(t)
111 rig.Run(chromedp.Navigate(rig.Origin + "/signin"))
112 rig.Screen("body", "signin")
113}
114
115// browserSignIn signs the browser in the way a person does: the form,
116// the mail, the link, and the confirm door's one Sign in button. Ends on
117// the deck list.
118func browserSignIn(t *testing.T, rig *harness.Rig, mailer *fakeMailer) {
119 t.Helper()
120 rig.Run(
121 chromedp.Navigate(rig.Origin+"/signin"),
122 chromedp.WaitVisible(`input[name="address"]`, chromedp.ByQuery),
123 chromedp.SendKeys(`input[name="address"]`, allowedAddress, chromedp.ByQuery),
124 chromedp.Submit(`input[name="address"]`, chromedp.ByQuery),
125 // POST /signin answers 303 to ?sent=1 (auth.go's one answer);
126 // the mail exists once the browser has landed there.
127 waitFor(`location.search === '?sent=1'`),
128 )
129 rig.Screen("body", "after sign-in post")
130 msgs := mailer.messages()
131 if len(msgs) == 0 {
132 t.Fatal("the sign-in form sent no mail")
133 }
134 link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body)
135 if link == "" {
136 t.Fatalf("no magic link in the mailed body:\n%s", msgs[len(msgs)-1].Body)
137 }
138 rig.Run(
139 chromedp.Navigate(link),
140 chromedp.WaitVisible(`form[action="/auth/verify"] button[type="submit"]`, chromedp.ByQuery),
141 )
142 rig.Screen("body", "confirm door")
143 rig.Run(
144 chromedp.Click(`form[action="/auth/verify"] button[type="submit"]`, chromedp.ByQuery),
145 waitFor(`location.pathname === '/'`),
146 chromedp.WaitVisible(`[rst-page]`, chromedp.ByQuery),
147 )
148 rig.Screen("[rst-page]", "signed in")
149}
150
151// waitFor polls a JS expression until it is true, bounded at 20 s, and
152// fails with the expression and the status line when it never is.
153func waitFor(expr string) chromedp.Action {
154 return chromedp.ActionFunc(func(ctx context.Context) error {
155 deadline := time.Now().Add(20 * time.Second)
156 for {
157 var ok bool
158 if err := chromedp.Evaluate(`!!(`+expr+`)`, &ok).Do(ctx); err != nil {
159 return err
160 }
161 if ok {
162 return nil
163 }
164 if time.Now().After(deadline) {
165 var status string
166 _ = chromedp.Evaluate(`document.querySelector('[estilo-save-status]')?.textContent ?? 'no status line'`, &status).Do(ctx)
167 return fmt.Errorf("never true within 20s: %s (status line: %q)", expr, status)
168 }
169 select {
170 case <-ctx.Done():
171 return ctx.Err()
172 case <-time.After(100 * time.Millisecond):
173 }
174 }
175 })
176}
177
178// TestBrowserEditsTheTitle is the editor's own drive (plan task 3):
179// open the sample deck's editor, type into the title heading, wait for
180// "Saved", reload, the text is still there, and the viewer shows it.
181// The rig is loud on any console error or failed request, so this is
182// also the check that the bundle loads and runs at all — a syntax
183// error in it takes the whole client down and no Go test would know.
184func TestBrowserEditsTheTitle(t *testing.T) {
185 rig, mailer := newRig(t)
186 browserSignIn(t, rig, mailer)
187
188 var deck string
189 rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck))
190 if !deckPath.MatchString(deck) {
191 t.Fatalf("the deck list links no deck: %q", deck)
192 }
193
194 const heading = `[slides-stage] [data-block="heading"]`
195 typed := " edited in a browser"
196 rig.Run(
197 chromedp.Navigate(rig.Origin+deck+"/edit"),
198 chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
199 // The bundle has bound the heading once it is contenteditable.
200 waitFor(`document.querySelector('`+heading+`')?.getAttribute('contenteditable') === 'true'`),
201 chromedp.Click(heading, chromedp.ByQuery),
202 chromedp.SendKeys(heading, string(kb.End)+typed, chromedp.ByQuery),
203 waitFor(`document.querySelector('`+heading+`').textContent.includes('`+typed+`')`),
204 waitFor(`document.querySelector('[estilo-save-status]').dataset.state === 'saved'`),
205 )
206 rig.Screen("[slides-editor]", "typed into the title")
207
208 rig.Run(
209 chromedp.Reload(),
210 chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
211 )
212 rig.Screen("[slides-editor]", "reloaded")
213 var after string
214 rig.Run(chromedp.Text(heading, &after, chromedp.ByQuery))
215 if !strings.Contains(after, sampleDeckTitle+typed) {
216 t.Errorf("after a reload the heading reads %q, want it to carry %q", after, sampleDeckTitle+typed)
217 }
218 var stripLabel string
219 rig.Run(chromedp.Text(`[slides-strip-item][data-current="true"] [slides-strip-label]`, &stripLabel, chromedp.ByQuery))
220 if !strings.Contains(stripLabel, typed) {
221 t.Errorf("the strip's label for the current slide reads %q, want the typed text", stripLabel)
222 }
223
224 rig.Run(chromedp.Navigate(rig.Origin + deck))
225 rig.Screen("[slides-viewer]", "viewer")
226 var shown string
227 rig.Run(chromedp.Text(`[slides-stage] [data-block="heading"]`, &shown, chromedp.ByQuery))
228 if !strings.Contains(shown, sampleDeckTitle+typed) {
229 t.Errorf("the viewer's heading reads %q, want it to carry %q", shown, sampleDeckTitle+typed)
230 }
231}
232
233// TestBrowserNewSlideAndUndo: New slide (the menu's item, Ctrl+Enter's
234// visible equivalent) makes a slide after the current one and the
235// stage moves to it once it is saved; Undo takes it back through the
236// same ops path, and the strip and the stage agree on what is shown.
237func TestBrowserNewSlideAndUndo(t *testing.T) {
238 rig, mailer := newRig(t)
239 browserSignIn(t, rig, mailer)
240 var deck string
241 rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck))
242
243 rig.Run(
244 chromedp.Navigate(rig.Origin+deck+"/edit"),
245 chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
246 waitFor(`document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'`),
247 )
248 var first string
249 rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &first))
250
251 // The menu's New slide, which is Ctrl+Enter's visible equivalent:
252 // open the menu, press the item.
253 rig.Run(
254 chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery),
255 chromedp.WaitVisible(`[data-command="new-slide"]`, chromedp.ByQuery),
256 chromedp.Click(`[data-command="new-slide"]`, chromedp.ByQuery),
257 waitFor(`document.querySelectorAll('[slides-strip-item]').length === 9`),
258 waitFor(`location.hash.length === 13 && location.hash !== '#`+first+`'`),
259 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === location.hash.slice(1)`),
260 waitFor(`document.querySelector('[estilo-save-status]').dataset.state === 'saved'`),
261 )
262 rig.Screen("[slides-editor]", "new slide")
263 var order int
264 rig.Run(chromedp.Evaluate(`Array.from(document.querySelectorAll('[slides-strip-item]')).map(li => li.dataset.slideId).indexOf(location.hash.slice(1))`, &order))
265 if order != 1 {
266 t.Errorf("the new slide sits at strip index %d, want 1 (right after the first)", order)
267 }
268 var made string
269 rig.Run(chromedp.Evaluate(`location.hash.slice(1)`, &made))
270
271 // Undo takes the slide away again, and the stage moves to one that
272 // still exists — the strip and the stage agree on which.
273 rig.Run(
274 chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery),
275 // A closed <details> still leaves its descendants in the DOM, so
276 // WaitVisible can report the Undo button before a person can reach
277 // it. Wait for the native menu state instead.
278 waitFor(`document.querySelector('[slides-menu]')?.open === true`),
279 chromedp.Click(`[data-command="undo"]`, chromedp.ByQuery),
280 waitFor(`document.querySelectorAll('[slides-strip-item]').length === 8`),
281 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id !== '`+made+`'`),
282 waitFor(`document.querySelector('[slides-strip-item][data-current="true"]')?.dataset.slideId === document.querySelector('[slides-stage] [slides-slide]')?.dataset.id`),
283 waitFor(`document.querySelector('[estilo-save-status]').dataset.state === 'saved'`),
284 )
285 rig.Screen("[slides-editor]", "undone")
286}
287
288// serverClient is a signed-in http.Client against the rig's real
289// server: the second writer in a collaboration drive. It signs in the
290// way a person does, through the same mailer the browser used.
291func serverClient(t *testing.T, rig *harness.Rig, mailer *fakeMailer) *http.Client {
292 t.Helper()
293 jar, err := cookiejar.New(nil)
294 if err != nil {
295 t.Fatalf("cookiejar: %v", err)
296 }
297 c := &http.Client{Jar: jar}
298 before := len(mailer.messages())
299 req, _ := http.NewRequest(http.MethodPost, rig.Origin+"/signin", strings.NewReader(url.Values{"address": {allowedAddress}}.Encode()))
300 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
301 req.Header.Set("Origin", rig.Origin)
302 req.Header.Set("Sec-Fetch-Site", "same-origin")
303 res, err := c.Do(req)
304 if err != nil {
305 t.Fatalf("POST /signin: %v", err)
306 }
307 res.Body.Close()
308 msgs := mailer.messages()
309 if len(msgs) != before+1 {
310 t.Fatalf("POST /signin sent %d mails, want 1", len(msgs)-before)
311 }
312 link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body)
313 // Opening the link is the confirm door and spends nothing; its Sign
314 // in button POSTs the token back with a browser's same-origin
315 // evidence, and that POST is what signs in.
316 u, err := url.Parse(link)
317 if err != nil {
318 t.Fatalf("parse the magic link: %v", err)
319 }
320 open, err := c.Get(link)
321 if err != nil {
322 t.Fatalf("GET the magic link: %v", err)
323 }
324 page, _ := io.ReadAll(open.Body)
325 open.Body.Close()
326 m := confirmForm.FindStringSubmatch(string(page))
327 if open.StatusCode != http.StatusOK || m == nil {
328 t.Fatalf("GET the magic link = %d, want the confirm door:\n%s", open.StatusCode, page)
329 }
330 req, _ = http.NewRequest(http.MethodPost, rig.Origin+html.UnescapeString(m[1]), strings.NewReader(url.Values{"token": {html.UnescapeString(m[2])}}.Encode()))
331 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
332 req.Header.Set("Origin", rig.Origin)
333 req.Header.Set("Sec-Fetch-Site", "same-origin")
334 verify, err := c.Do(req)
335 if err != nil {
336 t.Fatalf("POST %s: %v", u.Path, err)
337 }
338 verify.Body.Close()
339 if verify.StatusCode != http.StatusOK || verify.Request.URL.Path != "/" {
340 t.Fatalf("signing in ended at %d %s, want 200 on the deck list", verify.StatusCode, verify.Request.URL.Path)
341 }
342 return c
343}
344
345// TestBrowserSeesAnotherWriter: a second writer — the core's own bytes
346// posted over the wire, as another tab or an agent would — changes the
347// title while the editor is open, and the stage shows the new text
348// without a reload: the stream said so, the tab fetched it, and
349// y-prosemirror redrew the bound heading.
350func TestBrowserSeesAnotherWriter(t *testing.T) {
351 rig, mailer := newRig(t)
352 browserSignIn(t, rig, mailer)
353 var deck string
354 rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck))
355
356 const heading = `[slides-stage] [data-block="heading"]`
357 rig.Run(
358 chromedp.Navigate(rig.Origin+deck+"/edit"),
359 chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
360 waitFor(`document.querySelector('`+heading+`')?.getAttribute('contenteditable') === 'true'`),
361 )
362 var slide, block string
363 rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &slide))
364 rig.Run(chromedp.Evaluate(`document.querySelector('`+heading+`').dataset.id`, &block))
365
366 // The other writer: caught up from the server's log, then one op.
367 c := serverClient(t, rig, mailer)
368 res, err := c.Get(rig.Origin + deck + "/updates?since=0")
369 if err != nil {
370 t.Fatalf("GET the log: %v", err)
371 }
372 body, _ := io.ReadAll(res.Body)
373 res.Body.Close()
374 m := newMirror(t, strings.TrimPrefix(deck, "/d/"), frames(t, body))
375 update := m.ops(t, `[{"op":"set_text","slide":"`+slide+`","id":"`+block+`","markdown":"Rewritten elsewhere"}]`)
376 req, _ := http.NewRequest(http.MethodPost, rig.Origin+deck+"/updates", bytes.NewReader(update))
377 req.Header.Set("Content-Type", "application/octet-stream")
378 req.Header.Set("Origin", rig.Origin)
379 req.Header.Set("Sec-Fetch-Site", "same-origin")
380 post, err := c.Do(req)
381 if err != nil {
382 t.Fatalf("POST the update: %v", err)
383 }
384 post.Body.Close()
385 if post.StatusCode != http.StatusNoContent {
386 t.Fatalf("POST the update: status %d, want 204", post.StatusCode)
387 }
388
389 rig.Run(waitFor(`document.querySelector('` + heading + `').textContent === 'Rewritten elsewhere'`))
390 rig.Screen("[slides-editor]", "after a remote write")
391 // The remote write is not this tab's to save back: the status line
392 // never left "Saved".
393 var state string
394 rig.Run(chromedp.Evaluate(`document.querySelector('[estilo-save-status]').dataset.state`, &state))
395 if state != "saved" {
396 t.Errorf("after a remote write the status is %q, want saved", state)
397 }
398}
399
400// runOn drives a second tab the way rig.Run drives the rig's own:
401// plain chromedp actions, a t.Fatalf on failure. It has no screen to
402// report — chromedp's own error already names the action.
403func runOn(t *testing.T, ctx context.Context, actions ...chromedp.Action) {
404 t.Helper()
405 if err := chromedp.Run(ctx, actions...); err != nil {
406 t.Fatalf("second tab: %v", err)
407 }
408}
409
410// TestBrowserPresenceShowsEachOther (task 5, spec §3.3): two tabs on
411// the same deck — the same signed-in cookie, the same browser, but a
412// mount of the bundle each, a client id of its own each (presence.mjs's
413// newClientId, per tab rather than per person) and a real heartbeat
414// each. What this drives is the half node tests cannot reach:
415// presence.mjs's diff and presence-view.mjs's DOM rules are exercised
416// under a fake clock and a DOM stub (presence.test.mjs,
417// presence-view.test.mjs); this is the wiring between them and the
418// real thing on screen — the POST, the stream's `presence` event, and
419// paintItem/mark actually drawing.
420func TestBrowserPresenceShowsEachOther(t *testing.T) {
421 rig, mailer := newRig(t)
422 browserSignIn(t, rig, mailer)
423 var deck string
424 rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck))
425
426 const bound = `document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'`
427 rig.Run(
428 chromedp.Navigate(rig.Origin+deck+"/edit"),
429 chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
430 waitFor(bound),
431 )
432 var slide string
433 rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &slide))
434
435 // A second tab: the browser context the sign-in cookie already
436 // lives in, chromedp.NewContext's own page — a fresh mountEditor(),
437 // a fresh client id, a fresh heartbeat.
438 tab2, cancel := chromedp.NewContext(rig.Context())
439 defer cancel()
440 runOn(t, tab2,
441 chromedp.Navigate(rig.Origin+deck+"/edit"),
442 chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
443 waitFor(bound),
444 )
445
446 stripAvatar := `document.querySelector('[slides-strip-item][data-slide-id="` + slide + `"] [slides-presence-avatar]')`
447 // Tab one draws tab two's avatar on the strip item both are on —
448 // presence.start()'s immediate beat, POST /d/{sqid}/presence,
449 // live.go's presence stream event, and paintItem, all for real.
450 rig.Run(waitFor(stripAvatar + ` !== null`))
451 // And symmetrically: presence is mutual, not a one-way broadcast.
452 runOn(t, tab2, waitFor(stripAvatar+` !== null`))
453
454 var label string
455 rig.Run(chromedp.Evaluate(stripAvatar+`.getAttribute('aria-label')`, &label))
456 if label == "" {
457 t.Error("the strip avatar carries no accessible name — colour alone would fail WCAG 1.4.1")
458 }
459 rig.Screen(`[slides-strip]`, "an avatar for the other tab")
460
461 // Tab two selects the heading block; tab one draws an outline over
462 // it with a name chip.
463 runOn(t, tab2, chromedp.Click(`[slides-stage] [data-block="heading"]`, chromedp.ByQuery))
464 rig.Run(waitFor(`document.querySelector('[slides-presence-mark] [slides-presence-chip]')?.textContent.length > 0`))
465
466 // The mark sits directly over the block the other tab selected:
467 // same rect, checked against the block itself rather than trusting
468 // the JS that placed it.
469 var aligned bool
470 rig.Run(chromedp.Evaluate(`(() => {
471 const block = document.querySelector('[slides-stage] [data-block="heading"]')
472 const mark = document.querySelector('[slides-presence-mark]')
473 if (!block || !mark) return false
474 const b = block.getBoundingClientRect(), m = mark.getBoundingClientRect()
475 // Within a couple of px: the marks container is positioned off
476 // the stage's own border box (canvas.mjs's overlay reads its
477 // origin the same way, and the stage carries a 1px border), so
478 // exact equality is not the contract — visibly over the block is.
479 return Math.abs(b.left - m.left) <= 2 && Math.abs(b.top - m.top) <= 2
480 })()`, &aligned))
481 if !aligned {
482 t.Error("the presence mark is not drawn over the block the other tab selected")
483 }
484 rig.Screen(`[slides-stage]`, "a block outline for the other tab's selection")
485
486 // The arrival is announced once, politely — never a move, which
487 // would make the deck unusable with a screen reader.
488 var live string
489 rig.Run(chromedp.Evaluate(`document.querySelector('[slides-presence-live]').textContent`, &live))
490 if live == "" {
491 t.Error("presence never announced the second tab's arrival")
492 }
493}
494
495// setValue sets a form control's value the way a person would have
496// left it, then fires the change event the bundle listens for.
497func setValue(selector, value string) chromedp.Action {
498 return chromedp.Evaluate(`(() => {
499 const el = document.querySelector('`+selector+`')
500 el.value = '`+value+`'
501 el.dispatchEvent(new Event('change', { bubbles: true }))
502 return true
503 })()`, nil)
504}
505
506// dragBy presses the mouse at (x, y), moves it by (dx, dy) in a few
507// steps and releases: the pointer gesture canvas.mjs turns into one
508// set_block (or set_phone) on release.
509func dragBy(x, y, dx, dy float64) chromedp.Action {
510 return chromedp.ActionFunc(func(ctx context.Context) error {
511 if err := input.DispatchMouseEvent(input.MousePressed, x, y).WithButton(input.Left).WithClickCount(1).Do(ctx); err != nil {
512 return err
513 }
514 for i := 1; i <= 4; i++ {
515 f := float64(i) / 4
516 if err := input.DispatchMouseEvent(input.MouseMoved, x+dx*f, y+dy*f).WithButton(input.Left).Do(ctx); err != nil {
517 return err
518 }
519 }
520 return input.DispatchMouseEvent(input.MouseReleased, x+dx, y+dy).WithButton(input.Left).WithClickCount(1).Do(ctx)
521 })
522}
523
524// TestBrowserInspectorAndCanvas is task 4's drive: add a slide from the
525// inspector and set its template; on the canvas slide select a shape,
526// drag it, switch to the phone view and hide it there; reload and find
527// every one of those persisted — the slide, its template, the moved
528// box, the phone override and the chosen view.
529func TestBrowserInspectorAndCanvas(t *testing.T) {
530 rig, mailer := newRig(t)
531 browserSignIn(t, rig, mailer)
532 var deck string
533 rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck))
534
535 const bound = `document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'`
536 const saved = `document.querySelector('[estilo-save-status]').dataset.state === 'saved'`
537 rig.Run(
538 chromedp.Navigate(rig.Origin+deck+"/edit"),
539 chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
540 waitFor(bound),
541 )
542 var first string
543 rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &first))
544
545 // What the inspector hides has to be off the SCREEN, not merely
546 // marked hidden: the bundle toggles the hidden property and every
547 // author `display` in the stylesheets beats the UA's own
548 // [hidden]{display:none}. checkVisibility() is the assertion that
549 // notices — `.hidden` would have been true all along.
550 const blockForm = `document.querySelector('[slides-inspector-form][data-section="block"]')`
551 const confirm = `document.querySelector('[slides-confirm]')`
552 rig.Run(
553 waitFor(blockForm+`.checkVisibility() === false`),
554 waitFor(confirm+`.checkVisibility() === false`),
555 waitFor(`document.querySelector('[slides-inspector-empty]').checkVisibility() === true`),
556 )
557
558 // Add a slide from the Slide section, then set its template.
559 rig.Run(
560 chromedp.Click(`[data-command="slide-add-after"]`, chromedp.ByQuery),
561 waitFor(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length === 9`),
562 waitFor(`location.hash.length === 13 && location.hash !== '#`+first+`'`),
563 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === location.hash.slice(1)`),
564 waitFor(saved),
565 )
566 var added string
567 rig.Run(chromedp.Evaluate(`location.hash.slice(1)`, &added))
568 rig.Run(
569 setValue(`#rst-slide-template`, "section"),
570 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.template === 'section'`),
571 waitFor(saved),
572 )
573 rig.Screen("[slides-editor]", "added a section slide")
574
575 // Undo is a structural change too (its origin is the undo manager,
576 // not this tab's ops): the stage re-fetches after it. It takes back
577 // the template alone — the add and the template change were two
578 // commands, however quickly they followed each other — so the slide
579 // is still there. Redo puts the template back for the rest.
580 rig.Run(
581 chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery),
582 waitFor(`document.querySelector('[slides-menu]')?.open === true`),
583 chromedp.Click(`[data-command="undo"]`, chromedp.ByQuery),
584 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.template === 'title-body'`),
585 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === '`+added+`'`),
586 waitFor(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length === 9`),
587 waitFor(`document.querySelector('#rst-slide-template').value === 'title-body'`),
588 waitFor(saved),
589 chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery),
590 waitFor(`document.querySelector('[slides-menu]')?.open === true`),
591 chromedp.Click(`[data-command="redo"]`, chromedp.ByQuery),
592 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.template === 'section'`),
593 waitFor(saved),
594 )
595
596 // The canvas slide, found by the words under its thumbnail. It used
597 // to be found by looking for a canvas SECTION in the strip; a
598 // thumbnail is an <img> of an SVG now (thumbs.go), so there is no
599 // markup of the slide in the strip to interrogate — which is the
600 // point of the change, and the label is what a person would use to
601 // find it too.
602 var canvas string
603 rig.Run(chromedp.Evaluate(`Array.from(document.querySelectorAll('[slides-strip-item][data-slide-id]'))`+
604 `.find(li => li.querySelector('[slides-strip-label]')?.textContent.startsWith('Canvas:'))`+
605 `?.dataset.slideId ?? ''`, &canvas))
606 if canvas == "" {
607 t.Fatal("the sample deck has no canvas slide in the strip")
608 }
609 const rect = `[slides-stage] [data-block="shape"][data-kind="rect"]`
610 rig.Run(
611 chromedp.Navigate(rig.Origin+deck+"/edit#"+canvas),
612 chromedp.WaitVisible(rect, chromedp.ByQuery),
613 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === '`+canvas+`'`),
614 waitFor(bound),
615 )
616 var before string
617 rig.Run(chromedp.Evaluate(`document.querySelector('`+rect+`').style.left`, &before))
618
619 // Select the rectangle by clicking near its top-left (clear of the
620 // ellipse over its middle), then drag the overlay's body surface.
621 var box []float64
622 rig.Run(
623 chromedp.ScrollIntoView(rect, chromedp.ByQuery),
624 chromedp.Evaluate(`(() => { const r = document.querySelector('`+rect+`').getBoundingClientRect(); return [r.left, r.top, r.width, r.height] })()`, &box),
625 )
626 rig.Run(
627 chromedp.MouseClickXY(box[0]+30, box[1]+30),
628 waitFor(`document.querySelector('`+rect+`').getAttribute('aria-selected') === 'true'`),
629 waitFor(`!document.querySelector('[slides-canvas-overlay]').hidden`),
630 waitFor(`location.hash.split('/')[1] === document.querySelector('`+rect+`').dataset.id`),
631 waitFor(`!document.querySelector('[slides-inspector] form[data-section="block"]').hidden`),
632 )
633 rig.Screen("[slides-editor]", "selected the rectangle")
634 rig.Run(
635 dragBy(box[0]+30, box[1]+30, box[2]/2, 0),
636 waitFor(saved),
637 waitFor(`document.querySelector('`+rect+`').style.left !== '`+before+`'`),
638 waitFor(`document.querySelector('`+rect+`').getAttribute('aria-selected') === 'true'`),
639 )
640 var moved string
641 rig.Run(chromedp.Evaluate(`document.querySelector('`+rect+`').style.left`, &moved))
642 if moved == before || moved == "" {
643 t.Fatalf("after the drag the rectangle's left is %q, was %q", moved, before)
644 }
645 var shownX string
646 rig.Run(chromedp.Evaluate(`document.querySelector('#rst-block-x').value`, &shownX))
647 if shownX == "" || shownX == "8.0" {
648 t.Errorf("the inspector's Left field reads %q after the drag", shownX)
649 }
650 rig.Screen("[slides-editor]", "dragged the rectangle")
651
652 // The phone view: the stage re-renders at ?vp=phone; hiding the
653 // selected block there is a phone override, and the badge says so.
654 rig.Run(
655 chromedp.Click(`[slides-switcher] input[value="phone"]`, chromedp.ByQuery),
656 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.vp === 'phone'`),
657 waitFor(`document.querySelector('[slides-stage-pane]').dataset.view === 'phone'`),
658 waitFor(`document.querySelector('`+rect+`')?.getAttribute('aria-selected') === 'true'`),
659 chromedp.Click(`input[name="rst-block-phone-hidden"]`, chromedp.ByQuery),
660 waitFor(saved),
661 waitFor(`document.querySelector('`+rect+`')?.dataset.phoneHidden === 'true'`),
662 waitFor(`!document.querySelector('[slides-phone-badge]').hidden`),
663 )
664 rig.Screen("[slides-editor]", "hidden on a phone")
665
666 // Reload: the slide, its template, the box, the override and the
667 // view are all still there.
668 rig.Run(
669 chromedp.Reload(),
670 chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
671 waitFor(`document.querySelector('[slides-stage-pane]').dataset.view === 'phone'`),
672 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.vp === 'phone'`),
673 waitFor(`document.querySelector('`+rect+`')?.dataset.phoneHidden === 'true'`),
674 )
675 rig.Screen("[slides-editor]", "reloaded in the phone view")
676 var after string
677 rig.Run(chromedp.Evaluate(`document.querySelector('`+rect+`').style.left`, &after))
678 if after != moved {
679 t.Errorf("after a reload the rectangle's left is %q, want the dragged %q", after, moved)
680 }
681 // The added slide survived the reload as a section. Read off the
682 // server rather than out of the strip, which holds a picture of
683 // each slide now and not a copy of it: the slide's own HTML route
684 // is where its template is a fact.
685 var template string
686 rig.Run(chromedp.Evaluate(
687 `fetch('`+deck+`/slides/`+added+`.html', { credentials: 'same-origin' })`+
688 `.then(r => r.text()).then(t => t.match(/data-template="([a-z-]+)"/)?.[1] ?? '')`,
689 &template, func(p *runtime.EvaluateParams) *runtime.EvaluateParams {
690 return p.WithAwaitPromise(true)
691 }))
692 if template != "section" {
693 t.Errorf("after a reload the added slide's template is %q, want section", template)
694 }
695 // And the strip shows a picture of it, from the route that draws
696 // one.
697 var thumb string
698 rig.Run(chromedp.Evaluate(`document.querySelector('[slides-strip-item][data-slide-id="`+added+`"] [slides-thumb-img]')?.getAttribute('src') ?? ''`, &thumb))
699 if !strings.Contains(thumb, "/slides/"+added+".svg") {
700 t.Errorf("the added slide's thumbnail is %q, want the SVG route", thumb)
701 }
702 var n int
703 rig.Run(chromedp.Evaluate(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length`, &n))
704 if n != 9 {
705 t.Errorf("after a reload the strip lists %d slides, want 9", n)
706 }
707 // Back to the desktop, so the stored view does not follow the next drive.
708 rig.Run(
709 chromedp.Click(`[slides-switcher] input[value="desktop"]`, chromedp.ByQuery),
710 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.vp !== 'phone'`),
711 )
712}
713
714// TestBrowserDeleteToEmptyThenNewSlide (task 3 review, finding 1): the
715// stage exists on an empty deck, so deleting a deck's only slide from
716// the Slide section (ask, then confirm) leaves an editor New slide can
717// fill — and does.
718func TestBrowserDeleteToEmptyThenNewSlide(t *testing.T) {
719 rig, mailer := newRig(t)
720 browserSignIn(t, rig, mailer)
721 rig.Run(
722 chromedp.Navigate(rig.Origin+"/decks/new"),
723 chromedp.WaitVisible(`input[name="title"]`, chromedp.ByQuery),
724 chromedp.SendKeys(`input[name="title"]`, "Empty me", chromedp.ByQuery),
725 chromedp.Submit(`input[name="title"]`, chromedp.ByQuery),
726 waitFor(`/^\/d\/[^/]+$/.test(location.pathname)`),
727 )
728 var deck string
729 rig.Run(chromedp.Evaluate(`location.pathname`, &deck))
730
731 const saved = `document.querySelector('[estilo-save-status]').dataset.state === 'saved'`
732 rig.Run(
733 chromedp.Navigate(rig.Origin+deck+"/edit"),
734 chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
735 waitFor(`document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'`),
736 waitFor(`document.querySelector('[slides-confirm]').checkVisibility() === false`),
737 chromedp.Click(`[data-command="slide-delete-ask"]`, chromedp.ByQuery),
738 // Rendered visibility, not the property: the confirmation is
739 // only a confirmation if it was not on the screen until asked
740 // for, and `.hidden` cannot tell those apart (see the
741 // checkVisibility assertions in the inspector drive).
742 waitFor(`document.querySelector('[slides-confirm]').checkVisibility()`),
743 chromedp.Click(`[slides-confirm] [data-command="slide-delete"]`, chromedp.ByQuery),
744 waitFor(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length === 0`),
745 waitFor(`document.querySelector('[slides-stage] [slides-slide]') === null`),
746 waitFor(saved),
747 )
748 rig.Screen("[slides-editor]", "emptied the deck")
749
750 rig.Run(
751 chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery),
752 chromedp.WaitVisible(`[data-command="new-slide"]`, chromedp.ByQuery),
753 chromedp.Click(`[data-command="new-slide"]`, chromedp.ByQuery),
754 waitFor(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length === 1`),
755 waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === location.hash.slice(1)`),
756 waitFor(`document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'`),
757 waitFor(saved),
758 )
759 rig.Screen("[slides-editor]", "new slide on an empty deck")
760}
761