| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "net/http" |
| 9 | "slices" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/chromedp/cdproto/accessibility" |
| 16 | "github.com/chromedp/cdproto/browser" |
| 17 | "github.com/chromedp/cdproto/cdp" |
| 18 | "github.com/chromedp/cdproto/emulation" |
| 19 | cdppage "github.com/chromedp/cdproto/page" |
| 20 | "github.com/chromedp/chromedp" |
| 21 | |
| 22 | "amadan.net/rastrillo/fiesta" |
| 23 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 24 | ) |
| 25 | |
| 26 | // The snippet's Copy button, driven in a browser. What can go wrong is |
| 27 | // a button that does nothing, a message nobody hears, and a message |
| 28 | // that is out of date: so the button stays hidden where copy.js cannot |
| 29 | // work, a failure selects the code, every message goes through a live |
| 30 | // region emptied first, and only the latest click's write may speak. |
| 31 | |
| 32 | // snippetMarkup is the spec's snippet, attribute spelling, with the |
| 33 | // optional strings on the button as the gallery writes them. |
| 34 | const snippetMarkup = `<div rst-snippet> |
| 35 | <code dir="ltr" translate="no"><link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css"></code> |
| 36 | <button rst-btn="sm" type="button" rst-snippet-copy hidden data-rst-copy-done="Copied" data-rst-copy-failed="Copy failed. Select the code and copy it yourself.">Copy</button> |
| 37 | <span rst-snippet-status role="status"></span> |
| 38 | </div>` |
| 39 | |
| 40 | // classSnippet is the class spelling with no strings on the button, |
| 41 | // so what it says comes from the English written in copy.js. |
| 42 | const classSnippet = `<div class="rst-snippet"> |
| 43 | <code dir="ltr" translate="no"><link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css"></code> |
| 44 | <button rst-btn="sm" type="button" class="rst-snippet__copy" hidden>Copy</button> |
| 45 | <span class="rst-snippet__status" role="status"></span> |
| 46 | </div>` |
| 47 | |
| 48 | // tableSnippet has no strings on the button, so a page's table speaks; |
| 49 | // done, when set, is the button's own done string, which wins. |
| 50 | func tableSnippet(done string) string { |
| 51 | attr := "" |
| 52 | if done != "" { |
| 53 | attr = ` data-rst-copy-done="` + done + `"` |
| 54 | } |
| 55 | return `<div rst-snippet> |
| 56 | <code dir="ltr" translate="no">fiesta.css</code> |
| 57 | <button rst-btn="sm" type="button" rst-snippet-copy hidden` + attr + `>Copy</button> |
| 58 | <span rst-snippet-status role="status"></span> |
| 59 | </div>` |
| 60 | } |
| 61 | |
| 62 | // snippetDoc is sizingDoc's page in lang, with body and then the |
| 63 | // scripts tags: copy.js is opt-in, so sizingDoc does not load it. |
| 64 | func snippetDoc(lang, body, scripts string) string { |
| 65 | return strings.Replace(sizingDoc("snippet", body+scripts), `<html lang="en"`, `<html lang="`+lang+`"`, 1) |
| 66 | } |
| 67 | |
| 68 | const copyTag = `<script defer src="/copy.js"></script>` |
| 69 | |
| 70 | // copyPages are the snippet pages: the spec's markup at /, the class |
| 71 | // spelling at /class, and Irish with its table at /ga and /ga-done. |
| 72 | func copyPages() map[string]string { |
| 73 | ga := `<script src="/i18n/ga.js"></script>` + copyTag |
| 74 | return map[string]string{ |
| 75 | "/": snippetDoc("en", snippetMarkup, copyTag), |
| 76 | "/class": snippetDoc("en", classSnippet, copyTag), |
| 77 | "/ga": snippetDoc("ga", tableSnippet(""), ga), |
| 78 | "/ga-done": snippetDoc("ga", tableSnippet("Done"), ga), |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // copyRig serves copyPages with the tree's scripts, and i18n/<lang>.js |
| 83 | // from the release build, which is the only thing that writes them. |
| 84 | func copyRig(t *testing.T) *harness.Rig { |
| 85 | t.Helper() |
| 86 | dist := distScripts(t) |
| 87 | scripts := func(name string) ([]byte, bool) { |
| 88 | if strings.HasPrefix(name, "i18n/") { |
| 89 | return dist(name) |
| 90 | } |
| 91 | return fiesta.Script(name) |
| 92 | } |
| 93 | pages := copyPages() |
| 94 | return harness.New(t, func(string) http.Handler { return sizingMuxWith(t, pages, scripts) }) |
| 95 | } |
| 96 | |
| 97 | // copyWords is one language's copy-done and copy-failed, from its table. |
| 98 | func copyWords(t *testing.T, lang string) (done, failed string) { |
| 99 | t.Helper() |
| 100 | b, ok := fiesta.Strings(lang) |
| 101 | if !ok { |
| 102 | t.Fatalf("no table for %s", lang) |
| 103 | } |
| 104 | var table map[string]any |
| 105 | if err := json.Unmarshal(b, &table); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | done, _ = table["copy-done"].(string) |
| 109 | failed, _ = table["copy-failed"].(string) |
| 110 | if done == "" || failed == "" { |
| 111 | t.Fatalf("i18n/%s.json has copy-done %q and copy-failed %q; want both", lang, done, failed) |
| 112 | } |
| 113 | return done, failed |
| 114 | } |
| 115 | |
| 116 | // grantClipboard lets the origin read and write the clipboard without |
| 117 | // asking, and gives the page focus: the clipboard API refuses a |
| 118 | // document without focus, and a headless page has none unless told. |
| 119 | func grantClipboard(t *testing.T, ctx context.Context, origin string) { |
| 120 | t.Helper() |
| 121 | for _, name := range []string{"clipboard-read", "clipboard-write"} { |
| 122 | mustRun(t, ctx, browser.SetPermission(&browser.PermissionDescriptor{Name: name}, browser.PermissionSettingGranted).WithOrigin(origin)) |
| 123 | } |
| 124 | mustRun(t, ctx, emulation.SetFocusEmulationEnabled(true)) |
| 125 | } |
| 126 | |
| 127 | // beforeLoad runs js in every document this tab loads from now on, |
| 128 | // before any of the page's own scripts. |
| 129 | func beforeLoad(t *testing.T, ctx context.Context, js string) { |
| 130 | t.Helper() |
| 131 | mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| 132 | _, err := cdppage.AddScriptToEvaluateOnNewDocument(js).Do(ctx) |
| 133 | return err |
| 134 | })) |
| 135 | } |
| 136 | |
| 137 | // The first snippet's parts, in either spelling, as copy.js finds them. |
| 138 | const snippetParts = `const box = document.querySelector("[rst-snippet],.rst-snippet"), |
| 139 | b = box.querySelector("[rst-snippet-copy],.rst-snippet__copy"), |
| 140 | s = box.querySelector("[rst-snippet-status],.rst-snippet__status"), |
| 141 | code = box.querySelector("code");` |
| 142 | |
| 143 | type snippetReading struct { |
| 144 | Hidden, Shown bool |
| 145 | Label, Status, Code, Selected string |
| 146 | } |
| 147 | |
| 148 | func readSnippet(t *testing.T, ctx context.Context) snippetReading { |
| 149 | t.Helper() |
| 150 | var r snippetReading |
| 151 | at(t, ctx, `(() => { `+snippetParts+` |
| 152 | return JSON.stringify({Hidden: b.hidden, Shown: b.getClientRects().length > 0, Label: b.textContent.trim(), |
| 153 | Status: s.textContent, Code: code.textContent, Selected: getSelection().toString()}); })()`, &r) |
| 154 | return r |
| 155 | } |
| 156 | |
| 157 | // openSnippet loads path and fails at once if copy.js did not show the |
| 158 | // button: every later step clicks it, and a click on a hidden button |
| 159 | // would wait out the deadline instead of saying why. |
| 160 | func openSnippet(t *testing.T, ctx context.Context, origin, path string) snippetReading { |
| 161 | t.Helper() |
| 162 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(origin+path)) |
| 163 | r := readSnippet(t, ctx) |
| 164 | if r.Hidden || !r.Shown { |
| 165 | t.Fatalf("%s: the Copy button is hidden=%v shown=%v once the page has loaded; copy.js should show it where there is a clipboard", path, r.Hidden, r.Shown) |
| 166 | } |
| 167 | return r |
| 168 | } |
| 169 | |
| 170 | // copyLog is what the recorder saw. Status and Label are the text each |
| 171 | // write left, in order, Status opening with the text before the first |
| 172 | // click; Clicks are when each click reached the button, and After is |
| 173 | // the snippet straight after copy.js handled each click. Times are |
| 174 | // performance.now(), in ms. |
| 175 | type copyLog struct { |
| 176 | Status, Label []struct { |
| 177 | T float64 |
| 178 | Text string |
| 179 | } |
| 180 | Clicks []float64 |
| 181 | After []struct{ Status, Label string } |
| 182 | } |
| 183 | |
| 184 | // recordCopies installs the recorder. A write is one childList record, |
| 185 | // since copy.js sets textContent, and its text is the nodes it added: |
| 186 | // so two writes in one task are two entries, not their last state. |
| 187 | // The click listener on the document is added after copy.js's, so it |
| 188 | // runs after it. |
| 189 | func recordCopies(t *testing.T, ctx context.Context) { |
| 190 | t.Helper() |
| 191 | mustRun(t, ctx, chromedp.Evaluate(`(() => { `+snippetParts+` |
| 192 | const now = () => performance.now(), added = (r) => [...r.addedNodes].map((n) => n.textContent).join(""); |
| 193 | const log = window.copyLog = {Status: [{T: now(), Text: s.textContent}], Label: [], Clicks: [], After: []}; |
| 194 | new MutationObserver((rs) => rs.forEach((r) => log.Status.push({T: now(), Text: added(r)}))).observe(s, {childList: true}); |
| 195 | new MutationObserver((rs) => rs.forEach((r) => log.Label.push({T: now(), Text: added(r).trim()}))).observe(b, {childList: true}); |
| 196 | b.addEventListener("click", () => log.Clicks.push(now()), true); |
| 197 | document.addEventListener("click", (e) => { if (e.target === b) log.After.push({Status: s.textContent, Label: b.textContent.trim()}); }); |
| 198 | getSelection().removeAllRanges(); |
| 199 | return true; })()`, nil)) |
| 200 | } |
| 201 | |
| 202 | func readLog(t *testing.T, ctx context.Context) copyLog { |
| 203 | t.Helper() |
| 204 | var l copyLog |
| 205 | at(t, ctx, `JSON.stringify(window.copyLog)`, &l) |
| 206 | return l |
| 207 | } |
| 208 | |
| 209 | // statusTexts is the status's record as text alone. |
| 210 | func (l copyLog) statusTexts() []string { |
| 211 | var out []string |
| 212 | for _, w := range l.Status { |
| 213 | out = append(out, w.Text) |
| 214 | } |
| 215 | return out |
| 216 | } |
| 217 | |
| 218 | // spoken is every non-empty status write. |
| 219 | func (l copyLog) spoken() []string { |
| 220 | var out []string |
| 221 | for _, w := range l.Status { |
| 222 | if w.Text != "" { |
| 223 | out = append(out, w.Text) |
| 224 | } |
| 225 | } |
| 226 | return out |
| 227 | } |
| 228 | |
| 229 | // stubWrites replaces navigator.clipboard.writeText with one that |
| 230 | // settles its calls as steps says, in order (each {ms, ok}), and |
| 231 | // records in window.copyWrites when each settled. |
| 232 | func stubWrites(t *testing.T, ctx context.Context, steps string) { |
| 233 | t.Helper() |
| 234 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const steps = `+steps+`; window.copyWrites = []; |
| 235 | navigator.clipboard.writeText = (text) => { |
| 236 | const step = steps[copyWrites.length] || {ms: 0, ok: true}, rec = {Text: text, Settled: 0}; |
| 237 | copyWrites.push(rec); |
| 238 | return new Promise((ok, no) => setTimeout(() => { rec.Settled = performance.now(); step.ok ? ok() : no(new DOMException("refused", "NotAllowedError")); }, step.ms)); |
| 239 | }; |
| 240 | return true; })()`, nil)) |
| 241 | } |
| 242 | |
| 243 | // clickTwice clicks the button in the page, then again gap ms later: |
| 244 | // in the page, so the gap is the page's own clock and not a round trip |
| 245 | // through CDP that a loaded machine stretches past copy.js's 100 ms. |
| 246 | func clickTwice(t *testing.T, ctx context.Context, gap int) { |
| 247 | t.Helper() |
| 248 | mustRun(t, ctx, chromedp.Evaluate(`(() => { `+snippetParts+` b.click(); setTimeout(() => b.click(), `+strconv.Itoa(gap)+`); return true; })()`, nil)) |
| 249 | } |
| 250 | |
| 251 | // soonTrue polls a page expression for up to five seconds and reports |
| 252 | // whether it came true, for a drive that names what never happened |
| 253 | // rather than the expression it waited on. |
| 254 | func soonTrue(ctx context.Context, expr string) bool { |
| 255 | for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { |
| 256 | var yes bool |
| 257 | if err := chromedp.Run(ctx, chromedp.Evaluate(expr, &yes)); err == nil && yes { |
| 258 | return true |
| 259 | } |
| 260 | } |
| 261 | return false |
| 262 | } |
| 263 | |
| 264 | // driveCopy is the journey a granted clipboard takes, on the page at |
| 265 | // path, whose snippet is in English: the button is shown, a click puts |
| 266 | // the code's text on the clipboard, says Copied on the label at once |
| 267 | // and in the status no sooner than 100 ms after an emptied status, and |
| 268 | // two seconds on the label is back. A second click straight after the |
| 269 | // first's message does the same, and its label holds for its own two |
| 270 | // seconds, not what was left of the first's. |
| 271 | func driveCopy(t *testing.T, ctx context.Context, origin, path string) { |
| 272 | t.Helper() |
| 273 | done, _ := copyWords(t, "en") |
| 274 | grantClipboard(t, ctx, origin) |
| 275 | first := openSnippet(t, ctx, origin, path) |
| 276 | if first.Label == "" || first.Label == done { |
| 277 | t.Fatalf("%s: the button reads %q before any click; want its own label", path, first.Label) |
| 278 | } |
| 279 | recordCopies(t, ctx) |
| 280 | button := "[rst-snippet-copy],.rst-snippet__copy" |
| 281 | for i := 0; i < 2; i++ { |
| 282 | mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.writeText("")`, nil, awaitPromise), |
| 283 | chromedp.Click(button, chromedp.ByQuery)) |
| 284 | if !soonTrue(ctx, `window.copyLog.Status.length > 1 && window.copyLog.Status[window.copyLog.Status.length - 1].Text !== ""`) { |
| 285 | t.Fatalf("%s, click %d: the status never spoke; want %q within a second or so", path, i+1, done) |
| 286 | } |
| 287 | var held string |
| 288 | mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.readText()`, &held, awaitPromise)) |
| 289 | r, l := readSnippet(t, ctx), readLog(t, ctx) |
| 290 | if held != r.Code { |
| 291 | t.Errorf("%s, click %d: the clipboard holds %q; want the code, %q", path, i+1, held, r.Code) |
| 292 | } |
| 293 | if r.Label != done || r.Status != done { |
| 294 | t.Errorf("%s, click %d: the label reads %q and the status %q; want %q in both", path, i+1, r.Label, r.Status, done) |
| 295 | } |
| 296 | if len(l.Clicks) != i+1 || len(l.After) != i+1 { |
| 297 | t.Fatalf("%s, click %d: the recorder saw %d clicks; the click did not land", path, i+1, len(l.Clicks)) |
| 298 | } |
| 299 | if a := l.After[i]; a.Status != "" || a.Label != first.Label { |
| 300 | t.Errorf("%s, click %d: straight after the click the status says %q and the label %q; want it empty and %q", path, i+1, a.Status, a.Label, first.Label) |
| 301 | } |
| 302 | said := l.Status[len(l.Status)-1] |
| 303 | if wait := said.T - l.Clicks[i]; wait < 100 { |
| 304 | t.Errorf("%s, click %d: the status spoke %.0f ms after the click; want 100 ms or more, so an emptied region changes twice", path, i+1, wait) |
| 305 | } |
| 306 | } |
| 307 | settleUntil(t, ctx, `(() => { `+snippetParts+` return b.textContent.trim() === `+jsStr(first.Label)+`; })()`) |
| 308 | l := readLog(t, ctx) |
| 309 | back := l.Label[len(l.Label)-1] |
| 310 | if held := back.T - l.Clicks[1]; held < 2000 || held > 3000 { |
| 311 | t.Errorf("%s: the label came back %.0f ms after the second click; want two seconds, the second click's own", path, held) |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | // TestCopyCopiesAndSaysSo: the spec's snippet, a granted clipboard, |
| 316 | // driveCopy's journey. |
| 317 | func TestCopyCopiesAndSaysSo(t *testing.T) { |
| 318 | rig := copyRig(t) |
| 319 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 320 | defer cancel() |
| 321 | driveCopy(t, ctx, rig.Origin, "/") |
| 322 | rig.Screen("body", "the snippet after two copies") |
| 323 | } |
| 324 | |
| 325 | // TestAClassSpelledSnippetCopies: the same journey on the class |
| 326 | // spelling, whose button carries no strings, so Copied is copy.js's |
| 327 | // own English and has to match the English table. |
| 328 | func TestAClassSpelledSnippetCopies(t *testing.T) { |
| 329 | rig := copyRig(t) |
| 330 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 331 | defer cancel() |
| 332 | driveCopy(t, ctx, rig.Origin, "/class") |
| 333 | } |
| 334 | |
| 335 | // TestASuccessThenAFailureEndsOnTheFailure: a write that works, then |
| 336 | // 300 ms later one that fails. The first says Copied; the second puts |
| 337 | // the label back and keeps it, says it failed, and selects the code. |
| 338 | // Without the click restoring the label, Copied would stay on a button |
| 339 | // whose last copy failed. |
| 340 | func TestASuccessThenAFailureEndsOnTheFailure(t *testing.T) { |
| 341 | rig := copyRig(t) |
| 342 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 343 | defer cancel() |
| 344 | done, failed := copyWords(t, "en") |
| 345 | label := openSnippet(t, ctx, rig.Origin, "/").Label |
| 346 | stubWrites(t, ctx, `[{ms: 0, ok: true}, {ms: 0, ok: false}]`) |
| 347 | recordCopies(t, ctx) |
| 348 | clickTwice(t, ctx, 300) |
| 349 | settleUntil(t, ctx, `window.copyWrites.length === 2 && window.copyWrites[1].Settled > 0`) |
| 350 | if r := readSnippet(t, ctx); r.Label != label { |
| 351 | t.Errorf("straight after the failed copy settled, the label reads %q; want %q", r.Label, label) |
| 352 | } |
| 353 | mustRun(t, ctx, chromedp.Sleep(2500*time.Millisecond)) |
| 354 | r, l := readSnippet(t, ctx), readLog(t, ctx) |
| 355 | if r.Label != label { |
| 356 | t.Errorf("2.5 s after the failed copy the label reads %q; want %q", r.Label, label) |
| 357 | } |
| 358 | if want := []string{"", done, "", failed}; !slices.Equal(l.statusTexts(), want) { |
| 359 | t.Errorf("the status was written %q; want %q", l.statusTexts(), want) |
| 360 | } |
| 361 | if r.Selected != r.Code { |
| 362 | t.Errorf("after the failed copy the selection is %q; want the code, %q", r.Selected, r.Code) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | // TestWritesThatSettleOutOfOrderEndOnTheLast: two clicks 50 ms apart |
| 367 | // whose writes settle in the other order. The late first write is not |
| 368 | // the latest request, so whatever it says is dropped: a failure that |
| 369 | // settles late selects nothing and says nothing, and a success that |
| 370 | // settles late does not overwrite the failure that came after it. |
| 371 | func TestWritesThatSettleOutOfOrderEndOnTheLast(t *testing.T) { |
| 372 | rig := copyRig(t) |
| 373 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 374 | defer cancel() |
| 375 | done, failed := copyWords(t, "en") |
| 376 | for _, leg := range []struct { |
| 377 | name, steps string |
| 378 | status, said string |
| 379 | label, selected bool |
| 380 | }{ |
| 381 | {"the first works late, the second fails at once", `[{ms: 400, ok: true}, {ms: 0, ok: false}]`, failed, failed, false, true}, |
| 382 | {"the first fails late, the second works at once", `[{ms: 400, ok: false}, {ms: 0, ok: true}]`, done, done, true, false}, |
| 383 | } { |
| 384 | t.Run(leg.name, func(t *testing.T) { |
| 385 | label := openSnippet(t, ctx, rig.Origin, "/").Label |
| 386 | stubWrites(t, ctx, leg.steps) |
| 387 | recordCopies(t, ctx) |
| 388 | clickTwice(t, ctx, 50) |
| 389 | settleUntil(t, ctx, `window.copyWrites.length === 2 && window.copyWrites.every((w) => w.Settled > 0)`) |
| 390 | mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond)) |
| 391 | r, l := readSnippet(t, ctx), readLog(t, ctx) |
| 392 | want := label |
| 393 | if leg.label { |
| 394 | want = done |
| 395 | } |
| 396 | if r.Status != leg.status || r.Label != want { |
| 397 | t.Errorf("after both settled the status says %q and the label %q; want %q and %q", r.Status, r.Label, leg.status, want) |
| 398 | } |
| 399 | if got := l.spoken(); !slices.Equal(got, []string{leg.said}) { |
| 400 | t.Errorf("the status spoke %q; want only the second request's %q", got, leg.said) |
| 401 | } |
| 402 | if leg.selected && r.Selected != r.Code { |
| 403 | t.Errorf("the selection is %q; want the code, %q", r.Selected, r.Code) |
| 404 | } |
| 405 | if !leg.selected && r.Selected != "" { |
| 406 | t.Errorf("the selection is %q; a failure that is not the latest request should select nothing", r.Selected) |
| 407 | } |
| 408 | }) |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | // TestTheStatusIsALiveRegionBeforeItSpeaks: the status is in the |
| 413 | // accessibility tree as a status before it has said anything, since a |
| 414 | // region that appears with its first message may not be announced. A |
| 415 | // region written the text it already holds has not changed, so every |
| 416 | // message follows an emptied status; and a message is only ever the |
| 417 | // latest request's, so clicks closer together than the 100 ms delay, |
| 418 | // or a click while an earlier write is pending, speak once. |
| 419 | func TestTheStatusIsALiveRegionBeforeItSpeaks(t *testing.T) { |
| 420 | rig := copyRig(t) |
| 421 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 422 | defer cancel() |
| 423 | done, failed := copyWords(t, "en") |
| 424 | grantClipboard(t, ctx, rig.Origin) |
| 425 | |
| 426 | t.Run("in the tree before any click", func(t *testing.T) { |
| 427 | openSnippet(t, ctx, rig.Origin, "/") |
| 428 | var nodes []*cdp.Node |
| 429 | mustRun(t, ctx, chromedp.Nodes("[rst-snippet-status]", &nodes, chromedp.ByQuery)) |
| 430 | // Read loosely: cdproto's Node refuses an ignored reason it has |
| 431 | // no constant for (Chromium's "uninteresting"), and this needs |
| 432 | // only the role, the ignored flag and the DOM node. |
| 433 | var tree struct { |
| 434 | Nodes []struct { |
| 435 | Ignored bool `json:"ignored"` |
| 436 | Role *struct { |
| 437 | Value any `json:"value"` |
| 438 | } `json:"role"` |
| 439 | BackendDOMNodeID cdp.BackendNodeID `json:"backendDOMNodeId"` |
| 440 | } `json:"nodes"` |
| 441 | } |
| 442 | mustRun(t, ctx, accessibility.Enable(), chromedp.ActionFunc(func(ctx context.Context) error { |
| 443 | return cdp.Execute(ctx, accessibility.CommandGetFullAXTree, accessibility.GetFullAXTree(), &tree) |
| 444 | })) |
| 445 | found := false |
| 446 | for _, n := range tree.Nodes { |
| 447 | if n.BackendDOMNodeID == nodes[0].BackendNodeID { |
| 448 | found = !n.Ignored && n.Role != nil && n.Role.Value == "status" |
| 449 | } |
| 450 | } |
| 451 | if len(tree.Nodes) == 0 { |
| 452 | t.Fatal("the accessibility tree came back empty; the read is broken, not the status") |
| 453 | } |
| 454 | if !found { |
| 455 | t.Error("before any click the snippet's status is not a status node in the accessibility tree") |
| 456 | } |
| 457 | }) |
| 458 | |
| 459 | t.Run("two clicks a second apart", func(t *testing.T) { |
| 460 | openSnippet(t, ctx, rig.Origin, "/") |
| 461 | recordCopies(t, ctx) |
| 462 | clickTwice(t, ctx, 1000) |
| 463 | settleUntil(t, ctx, `window.copyLog.Status.length >= 4`) |
| 464 | mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond)) |
| 465 | if got, want := readLog(t, ctx).statusTexts(), []string{"", done, "", done}; !slices.Equal(got, want) { |
| 466 | t.Errorf("the status was written %q; want %q, two changes the screen reader can hear", got, want) |
| 467 | } |
| 468 | }) |
| 469 | |
| 470 | t.Run("two clicks 50 ms apart", func(t *testing.T) { |
| 471 | openSnippet(t, ctx, rig.Origin, "/") |
| 472 | recordCopies(t, ctx) |
| 473 | clickTwice(t, ctx, 50) |
| 474 | settleUntil(t, ctx, `window.copyLog.Status.some((w) => w.Text !== "")`) |
| 475 | mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) |
| 476 | l := readLog(t, ctx) |
| 477 | if got, want := l.statusTexts(), []string{"", done}; !slices.Equal(got, want) { |
| 478 | t.Errorf("the status was written %q; want %q, one announcement for the latest click", got, want) |
| 479 | } |
| 480 | if len(l.After) != 2 || l.After[1].Status != "" { |
| 481 | t.Errorf("straight after the second click the status read %+v; want it empty", l.After) |
| 482 | } |
| 483 | if said := l.Status[len(l.Status)-1]; len(l.Clicks) == 2 && said.T-l.Clicks[1] < 100 { |
| 484 | t.Errorf("the status spoke %.0f ms after the second click; want 100 ms or more", said.T-l.Clicks[1]) |
| 485 | } |
| 486 | }) |
| 487 | |
| 488 | t.Run("a click while the first write is pending", func(t *testing.T) { |
| 489 | label := openSnippet(t, ctx, rig.Origin, "/").Label |
| 490 | stubWrites(t, ctx, `[{ms: 0, ok: true}, {ms: 500, ok: false}]`) |
| 491 | recordCopies(t, ctx) |
| 492 | clickTwice(t, ctx, 50) |
| 493 | settleUntil(t, ctx, `window.copyWrites.length === 2 && window.copyWrites[1].Settled > 0 && window.copyLog.Status.some((w) => w.Text !== "")`) |
| 494 | // Past the first click's two-second reset, so a reset that |
| 495 | // escaped the request number would show. |
| 496 | mustRun(t, ctx, chromedp.Sleep(2000*time.Millisecond)) |
| 497 | l := readLog(t, ctx) |
| 498 | var settled float64 |
| 499 | at(t, ctx, `JSON.stringify(window.copyWrites[1].Settled)`, &settled) |
| 500 | if got := l.spoken(); !slices.Equal(got, []string{failed}) { |
| 501 | t.Errorf("the status spoke %q; want only %q, the pending request's", got, failed) |
| 502 | } |
| 503 | for _, w := range l.Status { |
| 504 | if w.Text != "" && w.T < settled { |
| 505 | t.Errorf("the status said %q at %.0f ms, before the pending write settled at %.0f ms", w.Text, w.T, settled) |
| 506 | } |
| 507 | } |
| 508 | if len(l.Clicks) != 2 { |
| 509 | t.Fatalf("the recorder saw %d clicks; want 2", len(l.Clicks)) |
| 510 | } |
| 511 | for _, w := range l.Label { |
| 512 | if w.T >= l.Clicks[1] && w.Text != label { |
| 513 | t.Errorf("after the second click the label was written %q at %.0f ms; want %q throughout", w.Text, w.T-l.Clicks[1], label) |
| 514 | } |
| 515 | } |
| 516 | if r := readSnippet(t, ctx); r.Label != label { |
| 517 | t.Errorf("the label reads %q; want %q", r.Label, label) |
| 518 | } |
| 519 | }) |
| 520 | } |
| 521 | |
| 522 | // TestACopyThatFailsSelectsTheCode: a clipboard that refuses, by a |
| 523 | // rejected promise and then by a throw. Either way the code is |
| 524 | // selected, so it can be copied by hand, the status says so, and the |
| 525 | // label never claims a copy. |
| 526 | func TestACopyThatFailsSelectsTheCode(t *testing.T) { |
| 527 | rig := copyRig(t) |
| 528 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 529 | defer cancel() |
| 530 | _, failed := copyWords(t, "en") |
| 531 | for _, leg := range []struct{ name, init string }{ |
| 532 | {"rejected", `navigator.clipboard.writeText = () => Promise.reject(new DOMException("Write permission denied.", "NotAllowedError"));`}, |
| 533 | {"thrown", `navigator.clipboard.writeText = () => { throw new DOMException("Write permission denied.", "NotAllowedError"); };`}, |
| 534 | } { |
| 535 | t.Run(leg.name, func(t *testing.T) { |
| 536 | beforeLoad(t, ctx, leg.init) |
| 537 | label := openSnippet(t, ctx, rig.Origin, "/").Label |
| 538 | recordCopies(t, ctx) |
| 539 | mustRun(t, ctx, chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery)) |
| 540 | settleUntil(t, ctx, `window.copyLog.Status.some((w) => w.Text !== "")`) |
| 541 | r, l := readSnippet(t, ctx), readLog(t, ctx) |
| 542 | if r.Selected != r.Code { |
| 543 | t.Errorf("after a %s copy the selection is %q; want the code, %q", leg.name, r.Selected, r.Code) |
| 544 | } |
| 545 | if r.Status != failed { |
| 546 | t.Errorf("after a %s copy the status says %q; want %q", leg.name, r.Status, failed) |
| 547 | } |
| 548 | for _, w := range l.Label { |
| 549 | if w.Text != label { |
| 550 | t.Errorf("after a %s copy the label was written %q; want %q throughout", leg.name, w.Text, label) |
| 551 | } |
| 552 | } |
| 553 | }) |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | // TestCopyStaysHiddenWithoutAClipboard: with no clipboard API copy.js |
| 558 | // runs (its guard is set) and leaves the button hidden; with scripts |
| 559 | // off the markup's own hidden keeps it so. Either way no button that |
| 560 | // does nothing. |
| 561 | func TestCopyStaysHiddenWithoutAClipboard(t *testing.T) { |
| 562 | rig := copyRig(t) |
| 563 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 564 | defer cancel() |
| 565 | hidden := func(t *testing.T, where string) { |
| 566 | t.Helper() |
| 567 | if r := readSnippet(t, ctx); !r.Hidden || r.Shown { |
| 568 | t.Errorf("%s the Copy button is hidden=%v shown=%v; want it hidden", where, r.Hidden, r.Shown) |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | t.Run("no clipboard API", func(t *testing.T) { |
| 573 | beforeLoad(t, ctx, `Object.defineProperty(navigator, "clipboard", { value: undefined });`) |
| 574 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/")) |
| 575 | var ran bool |
| 576 | mustRun(t, ctx, chromedp.Evaluate(`window.fiestaCopy === true`, &ran)) |
| 577 | if !ran { |
| 578 | t.Error("copy.js did not run: window.fiestaCopy is not set, so this leg proves nothing about it") |
| 579 | } |
| 580 | hidden(t, "with no clipboard API") |
| 581 | }) |
| 582 | |
| 583 | t.Run("scripts off", func(t *testing.T) { |
| 584 | mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true), chromedp.Navigate(rig.Origin+"/")) |
| 585 | hidden(t, "with scripts off") |
| 586 | mustRun(t, ctx, emulation.SetScriptExecutionDisabled(false)) |
| 587 | }) |
| 588 | } |
| 589 | |
| 590 | // TestCopyReadsTheTable: an Irish page with Irish strings loaded and |
| 591 | // none on the button says the table's copy-done; the button's own |
| 592 | // data-rst-copy-done wins over the table. |
| 593 | func TestCopyReadsTheTable(t *testing.T) { |
| 594 | rig := copyRig(t) |
| 595 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 596 | defer cancel() |
| 597 | done, _ := copyWords(t, "ga") |
| 598 | grantClipboard(t, ctx, rig.Origin) |
| 599 | for _, leg := range []struct{ path, want string }{{"/ga", done}, {"/ga-done", "Done"}} { |
| 600 | openSnippet(t, ctx, rig.Origin, leg.path) |
| 601 | recordCopies(t, ctx) |
| 602 | mustRun(t, ctx, chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery)) |
| 603 | settleUntil(t, ctx, `window.copyLog.Status.some((w) => w.Text !== "")`) |
| 604 | if r := readSnippet(t, ctx); r.Status != leg.want || r.Label != leg.want { |
| 605 | t.Errorf("%s: the status says %q and the label %q; want %q in both", leg.path, r.Status, r.Label, leg.want) |
| 606 | } |
| 607 | } |
| 608 | } |
| 609 | |