| 1 | //go:build browser |
| 2 | |
| 3 | // The presenting screens with their script on. The Go tests read the |
| 4 | // markup; only a browser runs static/present.js against it, and the |
| 5 | // failure this file was written for (polish audit B3, 2026-09-23) was |
| 6 | // the script and the markup disagreeing: the presenter view and the |
| 7 | // remote carried no data-slide, so the page believed it was on slide |
| 8 | // 0, the stream's first event said slide 1, and the page reloaded — |
| 9 | // and did the same on every load after, about fifty times a second. |
| 10 | package slidestest |
| 11 | |
| 12 | import ( |
| 13 | "fmt" |
| 14 | "regexp" |
| 15 | "sync/atomic" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/chromedp/cdproto/emulation" |
| 20 | "github.com/chromedp/cdproto/page" |
| 21 | "github.com/chromedp/chromedp" |
| 22 | "github.com/chromedp/chromedp/kb" |
| 23 | ) |
| 24 | |
| 25 | // TestBrowserProjectorFitsTheViewport keeps the projector a stage rather |
| 26 | // than a long document: its slide is bounded by the viewport and its |
| 27 | // controls are overlaid where the presenter can still reach them. |
| 28 | func TestBrowserProjectorFitsTheViewport(t *testing.T) { |
| 29 | rig, mailer := newRig(t) |
| 30 | browserSignIn(t, rig, mailer) |
| 31 | |
| 32 | var deck string |
| 33 | rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) |
| 34 | if !deckPath.MatchString(deck) { |
| 35 | t.Fatalf("the deck list links no deck: %q", deck) |
| 36 | } |
| 37 | |
| 38 | var path string |
| 39 | rig.Run( |
| 40 | chromedp.Navigate(rig.Origin+deck), |
| 41 | chromedp.Click(`form[action$="/present"] button`, chromedp.ByQuery), |
| 42 | waitFor(`location.pathname.endsWith('/notes')`), |
| 43 | chromedp.Evaluate(`location.pathname + location.search`, &path), |
| 44 | ) |
| 45 | m := notesLocation.FindStringSubmatch(path) |
| 46 | if m == nil { |
| 47 | t.Fatalf("Present landed on %q, want /p/{session}/notes?t={token}", path) |
| 48 | } |
| 49 | |
| 50 | for _, viewport := range []struct{ width, height int }{{1920, 1080}, {1280, 720}} { |
| 51 | t.Run(fmt.Sprintf("%dx%d", viewport.width, viewport.height), func(t *testing.T) { |
| 52 | var geometry struct { |
| 53 | ScrollHeight float64 `json:"scrollHeight"` |
| 54 | Viewport float64 `json:"viewport"` |
| 55 | ScrollY float64 `json:"scrollY"` |
| 56 | Top float64 `json:"top"` |
| 57 | Bottom float64 `json:"bottom"` |
| 58 | } |
| 59 | rig.Run( |
| 60 | emulation.SetDeviceMetricsOverride(int64(viewport.width), int64(viewport.height), 1, false), |
| 61 | chromedp.Navigate(rig.Origin+m[1]+"?t="+m[2]), |
| 62 | chromedp.WaitVisible(`[slides-bar]`, chromedp.ByQuery), |
| 63 | chromedp.Evaluate(`(() => { |
| 64 | window.scrollTo(0, 1); |
| 65 | const controls = document.querySelector('[slides-bar]').getBoundingClientRect(); |
| 66 | return { |
| 67 | scrollHeight: document.documentElement.scrollHeight, |
| 68 | viewport: window.innerHeight, |
| 69 | scrollY: window.scrollY, |
| 70 | top: controls.top, |
| 71 | bottom: controls.bottom, |
| 72 | }; |
| 73 | })()`, &geometry), |
| 74 | ) |
| 75 | if geometry.ScrollHeight > geometry.Viewport || geometry.ScrollY != 0 { |
| 76 | t.Errorf("projector scrolls: document %.0fpx, viewport %.0fpx, scrollY %.0f", geometry.ScrollHeight, geometry.Viewport, geometry.ScrollY) |
| 77 | } |
| 78 | if geometry.Top < 0 || geometry.Bottom > geometry.Viewport { |
| 79 | t.Errorf("controls are outside the viewport: top %.0fpx, bottom %.0fpx, viewport %.0fpx", geometry.Top, geometry.Bottom, geometry.Viewport) |
| 80 | } |
| 81 | }) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | var notesLocation = regexp.MustCompile(`^(/p/[23456789abcdefghjkmnpqrstuvwxyz]{10})/notes\?t=([A-Za-z0-9_-]{43})$`) |
| 86 | |
| 87 | // TestBrowserPresentingScreensHoldStill: the presenter view and the |
| 88 | // phone remote each load once and then stay put while nothing |
| 89 | // changes, and each reloads exactly once when the slide does move — |
| 90 | // which is how those two pages follow a talk (present.js reloads them |
| 91 | // rather than re-rendering two slides and the notes itself). |
| 92 | func TestBrowserPresentingScreensHoldStill(t *testing.T) { |
| 93 | rig, mailer := newRig(t) |
| 94 | browserSignIn(t, rig, mailer) |
| 95 | |
| 96 | // Every top-level navigation the tab makes, reloads included. |
| 97 | var loads atomic.Int64 |
| 98 | chromedp.ListenTarget(rig.Context(), func(ev any) { |
| 99 | if e, ok := ev.(*page.EventFrameNavigated); ok && e.Frame.ParentID == "" { |
| 100 | loads.Add(1) |
| 101 | } |
| 102 | }) |
| 103 | |
| 104 | var deck string |
| 105 | rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) |
| 106 | if !deckPath.MatchString(deck) { |
| 107 | t.Fatalf("the deck list links no deck: %q", deck) |
| 108 | } |
| 109 | |
| 110 | var path string |
| 111 | rig.Run( |
| 112 | chromedp.Navigate(rig.Origin+deck), |
| 113 | chromedp.Click(`form[action$="/present"] button`, chromedp.ByQuery), |
| 114 | waitFor(`location.pathname.endsWith('/notes')`), |
| 115 | chromedp.Evaluate(`location.pathname + location.search`, &path), |
| 116 | ) |
| 117 | m := notesLocation.FindStringSubmatch(path) |
| 118 | if m == nil { |
| 119 | t.Fatalf("Present landed on %q, want /p/{session}/notes?t={token}", path) |
| 120 | } |
| 121 | session, token := m[1], m[2] |
| 122 | |
| 123 | // holdsStill waits for the page to settle and the stream to have |
| 124 | // said its piece (one event on connect), then counts loads over a |
| 125 | // window long enough for the broken page to have reloaded hundreds |
| 126 | // of times. |
| 127 | holdsStill := func(name string) { |
| 128 | t.Helper() |
| 129 | rig.Run(chromedp.Sleep(1500 * time.Millisecond)) |
| 130 | before := loads.Load() |
| 131 | rig.Run(chromedp.Sleep(3 * time.Second)) |
| 132 | if n := loads.Load() - before; n != 0 { |
| 133 | t.Errorf("the %s loaded %d more times in 3s with nothing changing, want 0", name, n) |
| 134 | } |
| 135 | // And it holds still because it knows where it is, not only |
| 136 | // because present.js refuses to reload twice for one slide: |
| 137 | // the script's root names the slide the counter shows. |
| 138 | var marked, counter string |
| 139 | rig.Run( |
| 140 | chromedp.Evaluate(`document.querySelector('[data-session]')?.getAttribute('data-slide') ?? 'missing'`, &marked), |
| 141 | chromedp.Evaluate(`(document.querySelector('[slides-counter]')?.textContent ?? '').trim().split(' ')[0]`, &counter), |
| 142 | ) |
| 143 | if marked != counter { |
| 144 | t.Errorf("the %s's script root says data-slide=%q; the counter shows slide %q", name, marked, counter) |
| 145 | } |
| 146 | } |
| 147 | // movesOnce presses the right arrow — present.js posts `next` — and |
| 148 | // the page must reload onto the new slide once, and only once. |
| 149 | movesOnce := func(name, counter string) { |
| 150 | t.Helper() |
| 151 | before := loads.Load() |
| 152 | rig.Run( |
| 153 | chromedp.KeyEvent(kb.ArrowRight), |
| 154 | waitFor(`document.querySelector('[slides-counter]')?.textContent.trim().startsWith('`+counter+` /')`), |
| 155 | chromedp.Sleep(3*time.Second), |
| 156 | ) |
| 157 | if n := loads.Load() - before; n != 1 { |
| 158 | t.Errorf("the %s loaded %d times after one slide change, want exactly 1", name, n) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | holdsStill("presenter view") |
| 163 | rig.Screen("body", "presenter view") |
| 164 | movesOnce("presenter view", "2") |
| 165 | |
| 166 | rig.Run(chromedp.Navigate(rig.Origin + session + "/remote?t=" + token)) |
| 167 | holdsStill("remote") |
| 168 | rig.Screen("body", "remote") |
| 169 | movesOnce("remote", "3") |
| 170 | } |
| 171 | |