| 1 | //go:build browser |
| 2 | |
| 3 | // A modal dialog owns the keyboard. |
| 4 | package slidestest |
| 5 | |
| 6 | import ( |
| 7 | "fmt" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/chromedp/cdproto/input" |
| 13 | "github.com/chromedp/chromedp" |
| 14 | "github.com/chromedp/chromedp/kb" |
| 15 | ) |
| 16 | |
| 17 | // modKey presses key with a modifier held, the way a person does: |
| 18 | // keydown and keyup through the browser's own input pipeline, so the |
| 19 | // event starts at the focused element and travels the page. |
| 20 | func modKey(key string, mods input.Modifier) chromedp.Action { |
| 21 | return chromedp.KeyEvent(key, chromedp.KeyModifiers(mods)) |
| 22 | } |
| 23 | |
| 24 | // TestBrowserModalOwnsTheKeyboard (Sheets' #39 finding 1, checked here): |
| 25 | // with the link dialog open, the editor's document-level shortcuts — |
| 26 | // undo, redo, duplicate slide, new slide — do nothing. Before the fix |
| 27 | // Ctrl+D duplicated the slide behind the dialog and Ctrl+Z undid an |
| 28 | // edit nobody could see. |
| 29 | func TestBrowserModalOwnsTheKeyboard(t *testing.T) { |
| 30 | rig, _, _ := openEditor(t) |
| 31 | const heading = `[slides-stage] [data-block="heading"]` |
| 32 | const saved = `document.querySelector('[estilo-save-status]')?.dataset.state === 'saved'` |
| 33 | |
| 34 | // Something to undo, saved. |
| 35 | rig.Run( |
| 36 | chromedp.Click(heading, chromedp.ByQuery), |
| 37 | chromedp.SendKeys(heading, string(kb.End)+" undoable", chromedp.ByQuery), |
| 38 | waitFor(`document.querySelector('`+heading+`').textContent.includes(' undoable')`), |
| 39 | chromedp.Sleep(600*time.Millisecond), |
| 40 | waitFor(saved), |
| 41 | ) |
| 42 | var slides int |
| 43 | var title string |
| 44 | rig.Run( |
| 45 | chromedp.Evaluate(`document.querySelectorAll('[slides-strip-item]').length`, &slides), |
| 46 | chromedp.Evaluate(`document.querySelector('`+heading+`').textContent`, &title), |
| 47 | ) |
| 48 | |
| 49 | // Count every update the tab sends from here on. |
| 50 | rig.Run(chromedp.Evaluate(`(() => { |
| 51 | window.__updatePosts = 0 |
| 52 | const f = window.fetch |
| 53 | window.fetch = (u, o) => { |
| 54 | if (String(u).endsWith('/updates') && o && o.method === 'POST') window.__updatePosts++ |
| 55 | return f(u, o) |
| 56 | } |
| 57 | const b = navigator.sendBeacon.bind(navigator) |
| 58 | navigator.sendBeacon = (u, d) => { if (String(u).endsWith('/updates')) window.__updatePosts++; return b(u, d) } |
| 59 | })()`, nil)) |
| 60 | |
| 61 | // Open the link dialog the way a person does: Mod+K in the heading. |
| 62 | var mac bool |
| 63 | rig.Run(chromedp.Evaluate(`/Mac|iP(hone|[oa]d)/.test(navigator.platform)`, &mac)) |
| 64 | mod := input.ModifierCtrl |
| 65 | if mac { |
| 66 | mod = input.ModifierMeta |
| 67 | } |
| 68 | rig.Run( |
| 69 | chromedp.Focus(heading, chromedp.ByQuery), |
| 70 | modKey("k", mod), |
| 71 | waitFor(`document.querySelector('[slides-link-dialog]').open`), |
| 72 | waitFor(`document.activeElement?.closest('[slides-link-dialog]') !== null`), |
| 73 | ) |
| 74 | rig.Screen(`body`, "the link dialog is open") |
| 75 | |
| 76 | for _, m := range []input.Modifier{input.ModifierCtrl, input.ModifierMeta} { |
| 77 | rig.Run( |
| 78 | modKey("z", m), |
| 79 | modKey("z", m|input.ModifierShift), |
| 80 | modKey("y", m), |
| 81 | modKey("d", m), |
| 82 | ) |
| 83 | } |
| 84 | // Typing in the dialog's own field still works: stopping the |
| 85 | // event's propagation is not preventing its default. |
| 86 | rig.Run(chromedp.SendKeys(`[slides-link-dialog] input[name="href"]`, "https://example.com", chromedp.ByQuery)) |
| 87 | rig.Run(chromedp.Sleep(1500 * time.Millisecond)) |
| 88 | |
| 89 | var posts, slidesAfter int |
| 90 | var titleAfter, field string |
| 91 | var open bool |
| 92 | rig.Run( |
| 93 | chromedp.Evaluate(`window.__updatePosts`, &posts), |
| 94 | chromedp.Evaluate(`document.querySelectorAll('[slides-strip-item]').length`, &slidesAfter), |
| 95 | chromedp.Evaluate(`document.querySelector('`+heading+`').textContent`, &titleAfter), |
| 96 | chromedp.Evaluate(`document.querySelector('[slides-link-dialog]').open`, &open), |
| 97 | chromedp.Evaluate(`document.querySelector('[slides-link-dialog] input[name="href"]').value`, &field), |
| 98 | ) |
| 99 | if posts != 0 { |
| 100 | t.Errorf("with the link dialog open, shortcuts sent %d update(s) behind it, want 0", posts) |
| 101 | } |
| 102 | if slidesAfter != slides { |
| 103 | t.Errorf("with the link dialog open, the strip went from %d slides to %d", slides, slidesAfter) |
| 104 | } |
| 105 | if titleAfter != title { |
| 106 | t.Errorf("with the link dialog open, the heading went from %q to %q", title, titleAfter) |
| 107 | } |
| 108 | if !open { |
| 109 | t.Error("a shortcut closed the link dialog") |
| 110 | } |
| 111 | if !strings.Contains(field, "example.com") { |
| 112 | t.Errorf("typing in the dialog's field did not reach it: %q", field) |
| 113 | } |
| 114 | |
| 115 | // Escape still closes it, and the shortcuts are live again after. |
| 116 | rig.Run( |
| 117 | chromedp.KeyEvent(kb.Escape), |
| 118 | waitFor(`!document.querySelector('[slides-link-dialog]').open`), |
| 119 | chromedp.Evaluate(`document.activeElement.blur()`, nil), |
| 120 | modKey("d", input.ModifierCtrl), |
| 121 | waitFor(fmt.Sprintf(`document.querySelectorAll('[slides-strip-item]').length === %d`, slides+1)), |
| 122 | ) |
| 123 | } |
| 124 | |