//go:build browser // The browser drive: the whole app in a real Chromium, wired exactly // as cmd/slides/main.go wires it — loud on any console error, failed // request, 4xx/5xx, or JS value that leaked to the screen unrendered. // Not part of the plain suite: // // go test -tags browser ./... // // It needs a Chromium: on PATH, via RASTRILLO_CHROME, or in a // Playwright cache. A skip is not a pass — with no browser this fails, // unless RASTRILLO_BROWSER_OPTIONAL is set, which makes the skip a // deliberate visible choice rather than an accident. package slidestest import ( "bytes" "context" "fmt" "html" "io" "log/slog" "net/http" "net/http/cookiejar" "net/url" "path/filepath" "strings" "testing" "time" "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/blobs" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/harness" "github.com/chromedp/cdproto/input" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" slides "amadan.net/oficina/slides/internal/slides" ) // newRig boots the app under the rig, wired as main.go wires it, and // returns the rig with the mailer the app sends through — the seam a // browser sign-in needs, because the magic link only exists in mail. func newRig(t *testing.T) (*harness.Rig, *fakeMailer) { return newRigWith(t, nil) } // newRigWithStore is newRig with the object store development binds — a // directory beside the database — so the image upload has somewhere to // put bytes. Nothing else about the app differs. func newRigWithStore(t *testing.T) (*harness.Rig, *fakeMailer) { t.Helper() store, err := blobs.Dir(t.TempDir()) if err != nil { t.Fatalf("blobs.Dir: %v", err) } return newRigWith(t, store) } func newRigWith(t *testing.T, store blobs.Store) (*harness.Rig, *fakeMailer) { t.Helper() mailer := &fakeMailer{} rig := harness.New(t, func(origin string) http.Handler { // The same wiring main.go does, origin included: the rig hands // the app its localhost origin before serving — the same seam // main.go fills from SLIDES_ORIGIN. logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, a, err := slides.New(d, slides.Config{ Origin: origin, InstanceKey: testInstanceKey, Mailer: mailer, AllowedAddresses: []string{allowedAddress}, Blobs: store, Logger: logger, }) if err != nil { t.Fatalf("slides.New: %v", err) } t.Cleanup(func() { a.Close() }) mailer.flush = a.FlushMail // Behind rastrillo's serving stack, built from the ServeOptions // main hands rastrillo.Serve: its security headers, the locale // middleware and Slides' CSP. Serving the bare mux would leave // every drive here blind to the policy a browser is really told, // and a style the policy refused is a console error the rig // fails on. h, closeFn, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{Logger: logger}, mux)) if err != nil { t.Fatalf("rastrillo.Handler: %v", err) } t.Cleanup(func() { _ = closeFn() }) return h }) return rig, mailer } // TestBrowserWalk is the minimal loud walk: the front door arrives with // nothing wrong anywhere the rig watches. It walks /signin rather than // "/" because "/" is behind the session gate now, and a redirect is not // a screen. Grow it with the app — each screen a flow reaches earns a // Screen() line, and an expected probe earns a rig.Allow. func TestBrowserWalk(t *testing.T) { rig, _ := newRig(t) rig.Run(chromedp.Navigate(rig.Origin + "/signin")) rig.Screen("body", "signin") } // browserSignIn signs the browser in the way a person does: the form, // the mail, the link, and the confirm door's one Sign in button. Ends on // the deck list. func browserSignIn(t *testing.T, rig *harness.Rig, mailer *fakeMailer) { t.Helper() rig.Run( chromedp.Navigate(rig.Origin+"/signin"), chromedp.WaitVisible(`input[name="address"]`, chromedp.ByQuery), chromedp.SendKeys(`input[name="address"]`, allowedAddress, chromedp.ByQuery), chromedp.Submit(`input[name="address"]`, chromedp.ByQuery), // POST /signin answers 303 to ?sent=1 (auth.go's one answer); // the mail exists once the browser has landed there. waitFor(`location.search === '?sent=1'`), ) rig.Screen("body", "after sign-in post") msgs := mailer.messages() if len(msgs) == 0 { t.Fatal("the sign-in form sent no mail") } link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body) if link == "" { t.Fatalf("no magic link in the mailed body:\n%s", msgs[len(msgs)-1].Body) } rig.Run( chromedp.Navigate(link), chromedp.WaitVisible(`form[action="/auth/verify"] button[type="submit"]`, chromedp.ByQuery), ) rig.Screen("body", "confirm door") rig.Run( chromedp.Click(`form[action="/auth/verify"] button[type="submit"]`, chromedp.ByQuery), waitFor(`location.pathname === '/'`), chromedp.WaitVisible(`[rst-page]`, chromedp.ByQuery), ) rig.Screen("[rst-page]", "signed in") } // waitFor polls a JS expression until it is true, bounded at 20 s, and // fails with the expression and the status line when it never is. func waitFor(expr string) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { deadline := time.Now().Add(20 * time.Second) for { var ok bool if err := chromedp.Evaluate(`!!(`+expr+`)`, &ok).Do(ctx); err != nil { return err } if ok { return nil } if time.Now().After(deadline) { var status string _ = chromedp.Evaluate(`document.querySelector('[estilo-save-status]')?.textContent ?? 'no status line'`, &status).Do(ctx) return fmt.Errorf("never true within 20s: %s (status line: %q)", expr, status) } select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): } } }) } // TestBrowserEditsTheTitle is the editor's own drive (plan task 3): // open the sample deck's editor, type into the title heading, wait for // "Saved", reload, the text is still there, and the viewer shows it. // The rig is loud on any console error or failed request, so this is // also the check that the bundle loads and runs at all — a syntax // error in it takes the whole client down and no Go test would know. func TestBrowserEditsTheTitle(t *testing.T) { rig, mailer := newRig(t) browserSignIn(t, rig, mailer) var deck string rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) if !deckPath.MatchString(deck) { t.Fatalf("the deck list links no deck: %q", deck) } const heading = `[slides-stage] [data-block="heading"]` typed := " edited in a browser" rig.Run( chromedp.Navigate(rig.Origin+deck+"/edit"), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), // The bundle has bound the heading once it is contenteditable. waitFor(`document.querySelector('`+heading+`')?.getAttribute('contenteditable') === 'true'`), chromedp.Click(heading, chromedp.ByQuery), chromedp.SendKeys(heading, string(kb.End)+typed, chromedp.ByQuery), waitFor(`document.querySelector('`+heading+`').textContent.includes('`+typed+`')`), waitFor(`document.querySelector('[estilo-save-status]').dataset.state === 'saved'`), ) rig.Screen("[slides-editor]", "typed into the title") rig.Run( chromedp.Reload(), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), ) rig.Screen("[slides-editor]", "reloaded") var after string rig.Run(chromedp.Text(heading, &after, chromedp.ByQuery)) if !strings.Contains(after, sampleDeckTitle+typed) { t.Errorf("after a reload the heading reads %q, want it to carry %q", after, sampleDeckTitle+typed) } var stripLabel string rig.Run(chromedp.Text(`[slides-strip-item][data-current="true"] [slides-strip-label]`, &stripLabel, chromedp.ByQuery)) if !strings.Contains(stripLabel, typed) { t.Errorf("the strip's label for the current slide reads %q, want the typed text", stripLabel) } rig.Run(chromedp.Navigate(rig.Origin + deck)) rig.Screen("[slides-viewer]", "viewer") var shown string rig.Run(chromedp.Text(`[slides-stage] [data-block="heading"]`, &shown, chromedp.ByQuery)) if !strings.Contains(shown, sampleDeckTitle+typed) { t.Errorf("the viewer's heading reads %q, want it to carry %q", shown, sampleDeckTitle+typed) } } // TestBrowserNewSlideAndUndo: New slide (the menu's item, Ctrl+Enter's // visible equivalent) makes a slide after the current one and the // stage moves to it once it is saved; Undo takes it back through the // same ops path, and the strip and the stage agree on what is shown. func TestBrowserNewSlideAndUndo(t *testing.T) { rig, mailer := newRig(t) browserSignIn(t, rig, mailer) var deck string rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) rig.Run( chromedp.Navigate(rig.Origin+deck+"/edit"), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), waitFor(`document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'`), ) var first string rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &first)) // The menu's New slide, which is Ctrl+Enter's visible equivalent: // open the menu, press the item. rig.Run( chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery), chromedp.WaitVisible(`[data-command="new-slide"]`, chromedp.ByQuery), chromedp.Click(`[data-command="new-slide"]`, chromedp.ByQuery), waitFor(`document.querySelectorAll('[slides-strip-item]').length === 9`), waitFor(`location.hash.length === 13 && location.hash !== '#`+first+`'`), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === location.hash.slice(1)`), waitFor(`document.querySelector('[estilo-save-status]').dataset.state === 'saved'`), ) rig.Screen("[slides-editor]", "new slide") var order int rig.Run(chromedp.Evaluate(`Array.from(document.querySelectorAll('[slides-strip-item]')).map(li => li.dataset.slideId).indexOf(location.hash.slice(1))`, &order)) if order != 1 { t.Errorf("the new slide sits at strip index %d, want 1 (right after the first)", order) } var made string rig.Run(chromedp.Evaluate(`location.hash.slice(1)`, &made)) // Undo takes the slide away again, and the stage moves to one that // still exists — the strip and the stage agree on which. rig.Run( chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery), // A closed
still leaves its descendants in the DOM, so // WaitVisible can report the Undo button before a person can reach // it. Wait for the native menu state instead. waitFor(`document.querySelector('[slides-menu]')?.open === true`), chromedp.Click(`[data-command="undo"]`, chromedp.ByQuery), waitFor(`document.querySelectorAll('[slides-strip-item]').length === 8`), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id !== '`+made+`'`), waitFor(`document.querySelector('[slides-strip-item][data-current="true"]')?.dataset.slideId === document.querySelector('[slides-stage] [slides-slide]')?.dataset.id`), waitFor(`document.querySelector('[estilo-save-status]').dataset.state === 'saved'`), ) rig.Screen("[slides-editor]", "undone") } // serverClient is a signed-in http.Client against the rig's real // server: the second writer in a collaboration drive. It signs in the // way a person does, through the same mailer the browser used. func serverClient(t *testing.T, rig *harness.Rig, mailer *fakeMailer) *http.Client { t.Helper() jar, err := cookiejar.New(nil) if err != nil { t.Fatalf("cookiejar: %v", err) } c := &http.Client{Jar: jar} before := len(mailer.messages()) req, _ := http.NewRequest(http.MethodPost, rig.Origin+"/signin", strings.NewReader(url.Values{"address": {allowedAddress}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", rig.Origin) req.Header.Set("Sec-Fetch-Site", "same-origin") res, err := c.Do(req) if err != nil { t.Fatalf("POST /signin: %v", err) } res.Body.Close() msgs := mailer.messages() if len(msgs) != before+1 { t.Fatalf("POST /signin sent %d mails, want 1", len(msgs)-before) } link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body) // Opening the link is the confirm door and spends nothing; its Sign // in button POSTs the token back with a browser's same-origin // evidence, and that POST is what signs in. u, err := url.Parse(link) if err != nil { t.Fatalf("parse the magic link: %v", err) } open, err := c.Get(link) if err != nil { t.Fatalf("GET the magic link: %v", err) } page, _ := io.ReadAll(open.Body) open.Body.Close() m := confirmForm.FindStringSubmatch(string(page)) if open.StatusCode != http.StatusOK || m == nil { t.Fatalf("GET the magic link = %d, want the confirm door:\n%s", open.StatusCode, page) } req, _ = http.NewRequest(http.MethodPost, rig.Origin+html.UnescapeString(m[1]), strings.NewReader(url.Values{"token": {html.UnescapeString(m[2])}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", rig.Origin) req.Header.Set("Sec-Fetch-Site", "same-origin") verify, err := c.Do(req) if err != nil { t.Fatalf("POST %s: %v", u.Path, err) } verify.Body.Close() if verify.StatusCode != http.StatusOK || verify.Request.URL.Path != "/" { t.Fatalf("signing in ended at %d %s, want 200 on the deck list", verify.StatusCode, verify.Request.URL.Path) } return c } // TestBrowserSeesAnotherWriter: a second writer — the core's own bytes // posted over the wire, as another tab or an agent would — changes the // title while the editor is open, and the stage shows the new text // without a reload: the stream said so, the tab fetched it, and // y-prosemirror redrew the bound heading. func TestBrowserSeesAnotherWriter(t *testing.T) { rig, mailer := newRig(t) browserSignIn(t, rig, mailer) var deck string rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) const heading = `[slides-stage] [data-block="heading"]` rig.Run( chromedp.Navigate(rig.Origin+deck+"/edit"), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), waitFor(`document.querySelector('`+heading+`')?.getAttribute('contenteditable') === 'true'`), ) var slide, block string rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &slide)) rig.Run(chromedp.Evaluate(`document.querySelector('`+heading+`').dataset.id`, &block)) // The other writer: caught up from the server's log, then one op. c := serverClient(t, rig, mailer) res, err := c.Get(rig.Origin + deck + "/updates?since=0") if err != nil { t.Fatalf("GET the log: %v", err) } body, _ := io.ReadAll(res.Body) res.Body.Close() m := newMirror(t, strings.TrimPrefix(deck, "/d/"), frames(t, body)) update := m.ops(t, `[{"op":"set_text","slide":"`+slide+`","id":"`+block+`","markdown":"Rewritten elsewhere"}]`) req, _ := http.NewRequest(http.MethodPost, rig.Origin+deck+"/updates", bytes.NewReader(update)) req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Origin", rig.Origin) req.Header.Set("Sec-Fetch-Site", "same-origin") post, err := c.Do(req) if err != nil { t.Fatalf("POST the update: %v", err) } post.Body.Close() if post.StatusCode != http.StatusNoContent { t.Fatalf("POST the update: status %d, want 204", post.StatusCode) } rig.Run(waitFor(`document.querySelector('` + heading + `').textContent === 'Rewritten elsewhere'`)) rig.Screen("[slides-editor]", "after a remote write") // The remote write is not this tab's to save back: the status line // never left "Saved". var state string rig.Run(chromedp.Evaluate(`document.querySelector('[estilo-save-status]').dataset.state`, &state)) if state != "saved" { t.Errorf("after a remote write the status is %q, want saved", state) } } // runOn drives a second tab the way rig.Run drives the rig's own: // plain chromedp actions, a t.Fatalf on failure. It has no screen to // report — chromedp's own error already names the action. func runOn(t *testing.T, ctx context.Context, actions ...chromedp.Action) { t.Helper() if err := chromedp.Run(ctx, actions...); err != nil { t.Fatalf("second tab: %v", err) } } // TestBrowserPresenceShowsEachOther (task 5, spec §3.3): two tabs on // the same deck — the same signed-in cookie, the same browser, but a // mount of the bundle each, a client id of its own each (presence.mjs's // newClientId, per tab rather than per person) and a real heartbeat // each. What this drives is the half node tests cannot reach: // presence.mjs's diff and presence-view.mjs's DOM rules are exercised // under a fake clock and a DOM stub (presence.test.mjs, // presence-view.test.mjs); this is the wiring between them and the // real thing on screen — the POST, the stream's `presence` event, and // paintItem/mark actually drawing. func TestBrowserPresenceShowsEachOther(t *testing.T) { rig, mailer := newRig(t) browserSignIn(t, rig, mailer) var deck string rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) const bound = `document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'` rig.Run( chromedp.Navigate(rig.Origin+deck+"/edit"), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), waitFor(bound), ) var slide string rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &slide)) // A second tab: the browser context the sign-in cookie already // lives in, chromedp.NewContext's own page — a fresh mountEditor(), // a fresh client id, a fresh heartbeat. tab2, cancel := chromedp.NewContext(rig.Context()) defer cancel() runOn(t, tab2, chromedp.Navigate(rig.Origin+deck+"/edit"), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), waitFor(bound), ) stripAvatar := `document.querySelector('[slides-strip-item][data-slide-id="` + slide + `"] [slides-presence-avatar]')` // Tab one draws tab two's avatar on the strip item both are on — // presence.start()'s immediate beat, POST /d/{sqid}/presence, // live.go's presence stream event, and paintItem, all for real. rig.Run(waitFor(stripAvatar + ` !== null`)) // And symmetrically: presence is mutual, not a one-way broadcast. runOn(t, tab2, waitFor(stripAvatar+` !== null`)) var label string rig.Run(chromedp.Evaluate(stripAvatar+`.getAttribute('aria-label')`, &label)) if label == "" { t.Error("the strip avatar carries no accessible name — colour alone would fail WCAG 1.4.1") } rig.Screen(`[slides-strip]`, "an avatar for the other tab") // Tab two selects the heading block; tab one draws an outline over // it with a name chip. runOn(t, tab2, chromedp.Click(`[slides-stage] [data-block="heading"]`, chromedp.ByQuery)) rig.Run(waitFor(`document.querySelector('[slides-presence-mark] [slides-presence-chip]')?.textContent.length > 0`)) // The mark sits directly over the block the other tab selected: // same rect, checked against the block itself rather than trusting // the JS that placed it. var aligned bool rig.Run(chromedp.Evaluate(`(() => { const block = document.querySelector('[slides-stage] [data-block="heading"]') const mark = document.querySelector('[slides-presence-mark]') if (!block || !mark) return false const b = block.getBoundingClientRect(), m = mark.getBoundingClientRect() // Within a couple of px: the marks container is positioned off // the stage's own border box (canvas.mjs's overlay reads its // origin the same way, and the stage carries a 1px border), so // exact equality is not the contract — visibly over the block is. return Math.abs(b.left - m.left) <= 2 && Math.abs(b.top - m.top) <= 2 })()`, &aligned)) if !aligned { t.Error("the presence mark is not drawn over the block the other tab selected") } rig.Screen(`[slides-stage]`, "a block outline for the other tab's selection") // The arrival is announced once, politely — never a move, which // would make the deck unusable with a screen reader. var live string rig.Run(chromedp.Evaluate(`document.querySelector('[slides-presence-live]').textContent`, &live)) if live == "" { t.Error("presence never announced the second tab's arrival") } } // setValue sets a form control's value the way a person would have // left it, then fires the change event the bundle listens for. func setValue(selector, value string) chromedp.Action { return chromedp.Evaluate(`(() => { const el = document.querySelector('`+selector+`') el.value = '`+value+`' el.dispatchEvent(new Event('change', { bubbles: true })) return true })()`, nil) } // dragBy presses the mouse at (x, y), moves it by (dx, dy) in a few // steps and releases: the pointer gesture canvas.mjs turns into one // set_block (or set_phone) on release. func dragBy(x, y, dx, dy float64) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { if err := input.DispatchMouseEvent(input.MousePressed, x, y).WithButton(input.Left).WithClickCount(1).Do(ctx); err != nil { return err } for i := 1; i <= 4; i++ { f := float64(i) / 4 if err := input.DispatchMouseEvent(input.MouseMoved, x+dx*f, y+dy*f).WithButton(input.Left).Do(ctx); err != nil { return err } } return input.DispatchMouseEvent(input.MouseReleased, x+dx, y+dy).WithButton(input.Left).WithClickCount(1).Do(ctx) }) } // TestBrowserInspectorAndCanvas is task 4's drive: add a slide from the // inspector and set its template; on the canvas slide select a shape, // drag it, switch to the phone view and hide it there; reload and find // every one of those persisted — the slide, its template, the moved // box, the phone override and the chosen view. func TestBrowserInspectorAndCanvas(t *testing.T) { rig, mailer := newRig(t) browserSignIn(t, rig, mailer) var deck string rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) const bound = `document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'` const saved = `document.querySelector('[estilo-save-status]').dataset.state === 'saved'` rig.Run( chromedp.Navigate(rig.Origin+deck+"/edit"), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), waitFor(bound), ) var first string rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &first)) // What the inspector hides has to be off the SCREEN, not merely // marked hidden: the bundle toggles the hidden property and every // author `display` in the stylesheets beats the UA's own // [hidden]{display:none}. checkVisibility() is the assertion that // notices — `.hidden` would have been true all along. const blockForm = `document.querySelector('[slides-inspector-form][data-section="block"]')` const confirm = `document.querySelector('[slides-confirm]')` rig.Run( waitFor(blockForm+`.checkVisibility() === false`), waitFor(confirm+`.checkVisibility() === false`), waitFor(`document.querySelector('[slides-inspector-empty]').checkVisibility() === true`), ) // Add a slide from the Slide section, then set its template. rig.Run( chromedp.Click(`[data-command="slide-add-after"]`, chromedp.ByQuery), waitFor(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length === 9`), waitFor(`location.hash.length === 13 && location.hash !== '#`+first+`'`), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === location.hash.slice(1)`), waitFor(saved), ) var added string rig.Run(chromedp.Evaluate(`location.hash.slice(1)`, &added)) rig.Run( setValue(`#rst-slide-template`, "section"), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.template === 'section'`), waitFor(saved), ) rig.Screen("[slides-editor]", "added a section slide") // Undo is a structural change too (its origin is the undo manager, // not this tab's ops): the stage re-fetches after it. It takes back // the template alone — the add and the template change were two // commands, however quickly they followed each other — so the slide // is still there. Redo puts the template back for the rest. rig.Run( chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery), waitFor(`document.querySelector('[slides-menu]')?.open === true`), chromedp.Click(`[data-command="undo"]`, chromedp.ByQuery), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.template === 'title-body'`), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === '`+added+`'`), waitFor(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length === 9`), waitFor(`document.querySelector('#rst-slide-template').value === 'title-body'`), waitFor(saved), chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery), waitFor(`document.querySelector('[slides-menu]')?.open === true`), chromedp.Click(`[data-command="redo"]`, chromedp.ByQuery), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.template === 'section'`), waitFor(saved), ) // The canvas slide, found by the words under its thumbnail. It used // to be found by looking for a canvas SECTION in the strip; a // thumbnail is an of an SVG now (thumbs.go), so there is no // markup of the slide in the strip to interrogate — which is the // point of the change, and the label is what a person would use to // find it too. var canvas string rig.Run(chromedp.Evaluate(`Array.from(document.querySelectorAll('[slides-strip-item][data-slide-id]'))`+ `.find(li => li.querySelector('[slides-strip-label]')?.textContent.startsWith('Canvas:'))`+ `?.dataset.slideId ?? ''`, &canvas)) if canvas == "" { t.Fatal("the sample deck has no canvas slide in the strip") } const rect = `[slides-stage] [data-block="shape"][data-kind="rect"]` rig.Run( chromedp.Navigate(rig.Origin+deck+"/edit#"+canvas), chromedp.WaitVisible(rect, chromedp.ByQuery), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === '`+canvas+`'`), waitFor(bound), ) var before string rig.Run(chromedp.Evaluate(`document.querySelector('`+rect+`').style.left`, &before)) // Select the rectangle by clicking near its top-left (clear of the // ellipse over its middle), then drag the overlay's body surface. var box []float64 rig.Run( chromedp.ScrollIntoView(rect, chromedp.ByQuery), chromedp.Evaluate(`(() => { const r = document.querySelector('`+rect+`').getBoundingClientRect(); return [r.left, r.top, r.width, r.height] })()`, &box), ) rig.Run( chromedp.MouseClickXY(box[0]+30, box[1]+30), waitFor(`document.querySelector('`+rect+`').getAttribute('aria-selected') === 'true'`), waitFor(`!document.querySelector('[slides-canvas-overlay]').hidden`), waitFor(`location.hash.split('/')[1] === document.querySelector('`+rect+`').dataset.id`), waitFor(`!document.querySelector('[slides-inspector] form[data-section="block"]').hidden`), ) rig.Screen("[slides-editor]", "selected the rectangle") rig.Run( dragBy(box[0]+30, box[1]+30, box[2]/2, 0), waitFor(saved), waitFor(`document.querySelector('`+rect+`').style.left !== '`+before+`'`), waitFor(`document.querySelector('`+rect+`').getAttribute('aria-selected') === 'true'`), ) var moved string rig.Run(chromedp.Evaluate(`document.querySelector('`+rect+`').style.left`, &moved)) if moved == before || moved == "" { t.Fatalf("after the drag the rectangle's left is %q, was %q", moved, before) } var shownX string rig.Run(chromedp.Evaluate(`document.querySelector('#rst-block-x').value`, &shownX)) if shownX == "" || shownX == "8.0" { t.Errorf("the inspector's Left field reads %q after the drag", shownX) } rig.Screen("[slides-editor]", "dragged the rectangle") // The phone view: the stage re-renders at ?vp=phone; hiding the // selected block there is a phone override, and the badge says so. rig.Run( chromedp.Click(`[slides-switcher] input[value="phone"]`, chromedp.ByQuery), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.vp === 'phone'`), waitFor(`document.querySelector('[slides-stage-pane]').dataset.view === 'phone'`), waitFor(`document.querySelector('`+rect+`')?.getAttribute('aria-selected') === 'true'`), chromedp.Click(`input[name="rst-block-phone-hidden"]`, chromedp.ByQuery), waitFor(saved), waitFor(`document.querySelector('`+rect+`')?.dataset.phoneHidden === 'true'`), waitFor(`!document.querySelector('[slides-phone-badge]').hidden`), ) rig.Screen("[slides-editor]", "hidden on a phone") // Reload: the slide, its template, the box, the override and the // view are all still there. rig.Run( chromedp.Reload(), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), waitFor(`document.querySelector('[slides-stage-pane]').dataset.view === 'phone'`), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.vp === 'phone'`), waitFor(`document.querySelector('`+rect+`')?.dataset.phoneHidden === 'true'`), ) rig.Screen("[slides-editor]", "reloaded in the phone view") var after string rig.Run(chromedp.Evaluate(`document.querySelector('`+rect+`').style.left`, &after)) if after != moved { t.Errorf("after a reload the rectangle's left is %q, want the dragged %q", after, moved) } // The added slide survived the reload as a section. Read off the // server rather than out of the strip, which holds a picture of // each slide now and not a copy of it: the slide's own HTML route // is where its template is a fact. var template string rig.Run(chromedp.Evaluate( `fetch('`+deck+`/slides/`+added+`.html', { credentials: 'same-origin' })`+ `.then(r => r.text()).then(t => t.match(/data-template="([a-z-]+)"/)?.[1] ?? '')`, &template, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })) if template != "section" { t.Errorf("after a reload the added slide's template is %q, want section", template) } // And the strip shows a picture of it, from the route that draws // one. var thumb string rig.Run(chromedp.Evaluate(`document.querySelector('[slides-strip-item][data-slide-id="`+added+`"] [slides-thumb-img]')?.getAttribute('src') ?? ''`, &thumb)) if !strings.Contains(thumb, "/slides/"+added+".svg") { t.Errorf("the added slide's thumbnail is %q, want the SVG route", thumb) } var n int rig.Run(chromedp.Evaluate(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length`, &n)) if n != 9 { t.Errorf("after a reload the strip lists %d slides, want 9", n) } // Back to the desktop, so the stored view does not follow the next drive. rig.Run( chromedp.Click(`[slides-switcher] input[value="desktop"]`, chromedp.ByQuery), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.vp !== 'phone'`), ) } // TestBrowserDeleteToEmptyThenNewSlide (task 3 review, finding 1): the // stage exists on an empty deck, so deleting a deck's only slide from // the Slide section (ask, then confirm) leaves an editor New slide can // fill — and does. func TestBrowserDeleteToEmptyThenNewSlide(t *testing.T) { rig, mailer := newRig(t) browserSignIn(t, rig, mailer) rig.Run( chromedp.Navigate(rig.Origin+"/decks/new"), chromedp.WaitVisible(`input[name="title"]`, chromedp.ByQuery), chromedp.SendKeys(`input[name="title"]`, "Empty me", chromedp.ByQuery), chromedp.Submit(`input[name="title"]`, chromedp.ByQuery), waitFor(`/^\/d\/[^/]+$/.test(location.pathname)`), ) var deck string rig.Run(chromedp.Evaluate(`location.pathname`, &deck)) const saved = `document.querySelector('[estilo-save-status]').dataset.state === 'saved'` rig.Run( chromedp.Navigate(rig.Origin+deck+"/edit"), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), waitFor(`document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'`), waitFor(`document.querySelector('[slides-confirm]').checkVisibility() === false`), chromedp.Click(`[data-command="slide-delete-ask"]`, chromedp.ByQuery), // Rendered visibility, not the property: the confirmation is // only a confirmation if it was not on the screen until asked // for, and `.hidden` cannot tell those apart (see the // checkVisibility assertions in the inspector drive). waitFor(`document.querySelector('[slides-confirm]').checkVisibility()`), chromedp.Click(`[slides-confirm] [data-command="slide-delete"]`, chromedp.ByQuery), waitFor(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length === 0`), waitFor(`document.querySelector('[slides-stage] [slides-slide]') === null`), waitFor(saved), ) rig.Screen("[slides-editor]", "emptied the deck") rig.Run( chromedp.Click(`[slides-menu] > summary`, chromedp.ByQuery), chromedp.WaitVisible(`[data-command="new-slide"]`, chromedp.ByQuery), chromedp.Click(`[data-command="new-slide"]`, chromedp.ByQuery), waitFor(`document.querySelectorAll('[slides-strip-item][data-slide-id]').length === 1`), waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.dataset.id === location.hash.slice(1)`), waitFor(`document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'`), waitFor(saved), ) rig.Screen("[slides-editor]", "new slide on an empty deck") }