//go:build browser // The presenting screens with their script on. The Go tests read the // markup; only a browser runs static/present.js against it, and the // failure this file was written for (polish audit B3, 2026-09-23) was // the script and the markup disagreeing: the presenter view and the // remote carried no data-slide, so the page believed it was on slide // 0, the stream's first event said slide 1, and the page reloaded — // and did the same on every load after, about fifty times a second. package slidestest import ( "fmt" "regexp" "sync/atomic" "testing" "time" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" ) // TestBrowserProjectorFitsTheViewport keeps the projector a stage rather // than a long document: its slide is bounded by the viewport and its // controls are overlaid where the presenter can still reach them. func TestBrowserProjectorFitsTheViewport(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) } var path string rig.Run( chromedp.Navigate(rig.Origin+deck), chromedp.Click(`form[action$="/present"] button`, chromedp.ByQuery), waitFor(`location.pathname.endsWith('/notes')`), chromedp.Evaluate(`location.pathname + location.search`, &path), ) m := notesLocation.FindStringSubmatch(path) if m == nil { t.Fatalf("Present landed on %q, want /p/{session}/notes?t={token}", path) } for _, viewport := range []struct{ width, height int }{{1920, 1080}, {1280, 720}} { t.Run(fmt.Sprintf("%dx%d", viewport.width, viewport.height), func(t *testing.T) { var geometry struct { ScrollHeight float64 `json:"scrollHeight"` Viewport float64 `json:"viewport"` ScrollY float64 `json:"scrollY"` Top float64 `json:"top"` Bottom float64 `json:"bottom"` } rig.Run( emulation.SetDeviceMetricsOverride(int64(viewport.width), int64(viewport.height), 1, false), chromedp.Navigate(rig.Origin+m[1]+"?t="+m[2]), chromedp.WaitVisible(`[slides-bar]`, chromedp.ByQuery), chromedp.Evaluate(`(() => { window.scrollTo(0, 1); const controls = document.querySelector('[slides-bar]').getBoundingClientRect(); return { scrollHeight: document.documentElement.scrollHeight, viewport: window.innerHeight, scrollY: window.scrollY, top: controls.top, bottom: controls.bottom, }; })()`, &geometry), ) if geometry.ScrollHeight > geometry.Viewport || geometry.ScrollY != 0 { t.Errorf("projector scrolls: document %.0fpx, viewport %.0fpx, scrollY %.0f", geometry.ScrollHeight, geometry.Viewport, geometry.ScrollY) } if geometry.Top < 0 || geometry.Bottom > geometry.Viewport { t.Errorf("controls are outside the viewport: top %.0fpx, bottom %.0fpx, viewport %.0fpx", geometry.Top, geometry.Bottom, geometry.Viewport) } }) } } var notesLocation = regexp.MustCompile(`^(/p/[23456789abcdefghjkmnpqrstuvwxyz]{10})/notes\?t=([A-Za-z0-9_-]{43})$`) // TestBrowserPresentingScreensHoldStill: the presenter view and the // phone remote each load once and then stay put while nothing // changes, and each reloads exactly once when the slide does move — // which is how those two pages follow a talk (present.js reloads them // rather than re-rendering two slides and the notes itself). func TestBrowserPresentingScreensHoldStill(t *testing.T) { rig, mailer := newRig(t) browserSignIn(t, rig, mailer) // Every top-level navigation the tab makes, reloads included. var loads atomic.Int64 chromedp.ListenTarget(rig.Context(), func(ev any) { if e, ok := ev.(*page.EventFrameNavigated); ok && e.Frame.ParentID == "" { loads.Add(1) } }) 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) } var path string rig.Run( chromedp.Navigate(rig.Origin+deck), chromedp.Click(`form[action$="/present"] button`, chromedp.ByQuery), waitFor(`location.pathname.endsWith('/notes')`), chromedp.Evaluate(`location.pathname + location.search`, &path), ) m := notesLocation.FindStringSubmatch(path) if m == nil { t.Fatalf("Present landed on %q, want /p/{session}/notes?t={token}", path) } session, token := m[1], m[2] // holdsStill waits for the page to settle and the stream to have // said its piece (one event on connect), then counts loads over a // window long enough for the broken page to have reloaded hundreds // of times. holdsStill := func(name string) { t.Helper() rig.Run(chromedp.Sleep(1500 * time.Millisecond)) before := loads.Load() rig.Run(chromedp.Sleep(3 * time.Second)) if n := loads.Load() - before; n != 0 { t.Errorf("the %s loaded %d more times in 3s with nothing changing, want 0", name, n) } // And it holds still because it knows where it is, not only // because present.js refuses to reload twice for one slide: // the script's root names the slide the counter shows. var marked, counter string rig.Run( chromedp.Evaluate(`document.querySelector('[data-session]')?.getAttribute('data-slide') ?? 'missing'`, &marked), chromedp.Evaluate(`(document.querySelector('[slides-counter]')?.textContent ?? '').trim().split(' ')[0]`, &counter), ) if marked != counter { t.Errorf("the %s's script root says data-slide=%q; the counter shows slide %q", name, marked, counter) } } // movesOnce presses the right arrow — present.js posts `next` — and // the page must reload onto the new slide once, and only once. movesOnce := func(name, counter string) { t.Helper() before := loads.Load() rig.Run( chromedp.KeyEvent(kb.ArrowRight), waitFor(`document.querySelector('[slides-counter]')?.textContent.trim().startsWith('`+counter+` /')`), chromedp.Sleep(3*time.Second), ) if n := loads.Load() - before; n != 1 { t.Errorf("the %s loaded %d times after one slide change, want exactly 1", name, n) } } holdsStill("presenter view") rig.Screen("body", "presenter view") movesOnce("presenter view", "2") rig.Run(chromedp.Navigate(rig.Origin + session + "/remote?t=" + token)) holdsStill("remote") rig.Screen("body", "remote") movesOnce("remote", "3") }