rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package check
4
5import (
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.
34const snippetMarkup = `<div rst-snippet>
35 <code dir="ltr" translate="no">&lt;link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css"&gt;</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.
42const classSnippet = `<div class="rst-snippet">
43 <code dir="ltr" translate="no">&lt;link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css"&gt;</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.
50func 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.
64func snippetDoc(lang, body, scripts string) string {
65 return strings.Replace(sizingDoc("snippet", body+scripts), `<html lang="en"`, `<html lang="`+lang+`"`, 1)
66}
67
68const 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.
72func 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.
84func 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.
98func 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.
119func 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.
129func 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.
138const 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
143type snippetReading struct {
144 Hidden, Shown bool
145 Label, Status, Code, Selected string
146}
147
148func 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.
160func 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.
175type 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.
189func 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
202func 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.
210func (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.
219func (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.
232func 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.
246func 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.
254func 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.
271func 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.
317func 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.
328func 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.
340func 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.
371func 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.
419func 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.
526func 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.
561func 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.
593func 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