//go:build browser // The editor's round-2 polish, held in a real Chromium: the strip does // not draw over its own words, a reload is not somebody else arriving, // and a modal dialog owns the keyboard. Each of these was a defect a // person could see (polish audit, slides.md), and none of them is // visible to a markup test — they are geometry, a sessionStorage and a // key event's route through the page. package slidestest import ( "fmt" "testing" "time" "amadan.net/rastrillo/rastrillo/harness" "github.com/chromedp/cdproto/input" "github.com/chromedp/chromedp" ) const editorBound = `document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'` // openEditor signs in and opens the sample deck's editor, bound. func openEditor(t *testing.T) (*harness.Rig, *fakeMailer, string) { t.Helper() 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) } rig.Run( chromedp.Navigate(rig.Origin+deck+"/edit"), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), waitFor(editorBound), ) return rig, mailer, deck } // stripGeometry measures every strip item: its label, its avatars and // its menu trigger, and reports each overlap, each trigger under the // 24px target minimum, and each avatar box outside its thumbnail. An // empty answer is the pass. Items outside the strip's scrollport are // measured too — getBoundingClientRect does not care. const stripGeometry = `(() => { const r = el => el && el.getBoundingClientRect() const hit = (a, b) => a && b && a.width > 0 && b.width > 0 && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom const inside = (a, b) => a.left >= b.left - 0.5 && a.right <= b.right + 0.5 && a.top >= b.top - 0.5 && a.bottom <= b.bottom + 0.5 const out = [] let avatars = 0 for (const li of document.querySelectorAll('[slides-strip-item]')) { const id = li.dataset.slideId const label = r(li.querySelector('[slides-strip-label]')) const thumb = r(li.querySelector('[slides-thumb]')) const av = r(li.querySelector('[slides-presence-avatars]')) const menu = r(li.querySelector('[slides-strip-menu] > summary')) if (av) avatars++ if (hit(label, av)) out.push(id + ': the label is under the avatars') if (hit(label, menu)) out.push(id + ': the label is under the menu trigger') if (av && !inside(av, thumb)) out.push(id + ': the avatars are outside the thumbnail') if (menu && (menu.width < 24 || menu.height < 24)) out.push(id + ': the menu trigger is ' + menu.width + 'x' + menu.height + ', under 24x24') if (menu && (menu.width > 32 || menu.height > 32)) out.push(id + ': the menu trigger is ' + menu.width + 'x' + menu.height + ', not a compact icon button') if (menu && !inside(menu, thumb)) out.push(id + ': the menu trigger is outside the thumbnail') } if (avatars === 0) out.push('no strip item carries an avatar — nothing was measured') return out.join('\n') })()` // TestBrowserStripDoesNotOverlapItself (polish audit, "PA RRENT // SLIDE"): with another tab's avatar on the current slide, the item's // label is clear of the avatars and of the menu trigger, the trigger is // a compact 24px-or-more target, and the words "Current slide" are for // a screen reader only — at 1440, where the strip is a column, and at // 800, where it is a row. func TestBrowserStripDoesNotOverlapItself(t *testing.T) { rig, _, deck := openEditor(t) var slide string rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &slide)) // A real second tab, so the avatar is presence-view.mjs's own and // sits wherever the bundle puts it. tab2, cancel := chromedp.NewContext(rig.Context()) defer cancel() runOn(t, tab2, chromedp.Navigate(rig.Origin+deck+"/edit"), chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), waitFor(editorBound), ) rig.Run(waitFor(`document.querySelector('[slides-strip-item][data-slide-id="` + slide + `"] [slides-presence-avatar]') !== null`)) for _, width := range []int64{1440, 800} { rig.Run( chromedp.EmulateViewport(width, 900), // Park the pointer away from the strip: hover is one of the // states that shows a menu trigger, and this is the resting // look being measured. chromedp.MouseEvent(input.MouseMoved, float64(width)-5, 895), chromedp.Sleep(300*time.Millisecond), ) var problems string rig.Run(chromedp.Evaluate(stripGeometry, &problems)) if problems != "" { t.Errorf("at %dpx the strip overlaps itself:\n%s", width, problems) } // The resting menu trigger of an item that is neither current // nor hovered nor focused is not drawn over its thumbnail. var restingOpacity string rig.Run(chromedp.Evaluate(`getComputedStyle(document.querySelector('[slides-strip-item]:not([data-current="true"]) [slides-strip-menu] > summary')).opacity`, &restingOpacity)) if restingOpacity != "0" { t.Errorf("at %dpx a resting item's menu trigger has opacity %s, want 0 (shown on hover, focus or current only)", width, restingOpacity) } var currentOpacity string rig.Run(chromedp.Evaluate(`getComputedStyle(document.querySelector('[slides-strip-item][data-current="true"] [slides-strip-menu] > summary')).opacity`, ¤tOpacity)) if currentOpacity != "1" { t.Errorf("at %dpx the current item's menu trigger has opacity %s, want 1", width, currentOpacity) } // "Current slide" is in the accessible name and not on screen. var current struct { Text string `json:"text"` Width float64 `json:"width"` Height float64 `json:"height"` Upper string `json:"upper"` } rig.Run(chromedp.Evaluate(`(() => { const el = document.querySelector('[slides-strip-item][data-current="true"] [slides-strip-current]') const b = el.getBoundingClientRect() return { text: el.textContent, width: b.width, height: b.height, upper: getComputedStyle(el).textTransform } })()`, ¤t)) if current.Text == "" { t.Errorf("at %dpx the current item lost its 'Current slide' text — a screen reader still needs it", width) } if current.Width > 1 || current.Height > 1 { t.Errorf("at %dpx 'Current slide' is visible (%gx%g), want it for screen readers only", width, current.Width, current.Height) } if current.Upper == "uppercase" { t.Errorf("at %dpx 'Current slide' is still uppercase", width) } // Hover and keyboard focus each show a resting item's trigger. var at struct { X float64 `json:"x"` Y float64 `json:"y"` } rig.Run(chromedp.Evaluate(`(() => { const li = document.querySelector('[slides-strip-item]:not([data-current="true"])') li.scrollIntoView({ block: 'nearest', inline: 'nearest' }) const b = li.getBoundingClientRect() return { x: b.left + b.width / 2, y: b.top + b.height / 2 } })()`, &at)) var hoverOpacity string rig.Run( chromedp.MouseEvent(input.MouseMoved, at.X, at.Y), chromedp.Sleep(300*time.Millisecond), chromedp.Evaluate(`[...document.querySelectorAll('[slides-strip-item]:not([data-current="true"])')].filter(li => li.matches(':hover')).map(li => getComputedStyle(li.querySelector('[slides-strip-menu] > summary')).opacity).join(',')`, &hoverOpacity), ) if hoverOpacity != "1" { t.Errorf("at %dpx a hovered item's menu trigger has opacity %q, want 1", width, hoverOpacity) } var focusOpacity string rig.Run( chromedp.MouseEvent(input.MouseMoved, float64(width)-5, 895), chromedp.Evaluate(`document.querySelectorAll('[slides-strip-item]:not([data-current="true"]) [slides-strip-link]')[1].focus()`, nil), chromedp.Sleep(300*time.Millisecond), chromedp.Evaluate(`getComputedStyle(document.activeElement.closest('[slides-strip-item]').querySelector('[slides-strip-menu] > summary')).opacity`, &focusOpacity), ) if focusOpacity != "1" { t.Errorf("at %dpx a focused item's menu trigger has opacity %q, want 1", width, focusOpacity) } rig.Screen(`[slides-strip-nav]`, fmt.Sprintf("the strip at %dpx", width)) } }