//go:build browser
package check
import (
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/chromedp/cdproto/accessibility"
"github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/emulation"
cdppage "github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"amadan.net/rastrillo/fiesta"
"amadan.net/rastrillo/fiesta/internal/harness"
)
// The snippet's Copy button, driven in a browser. What can go wrong is
// a button that does nothing, a message nobody hears, and a message
// that is out of date: so the button stays hidden where copy.js cannot
// work, a failure selects the code, every message goes through a live
// region emptied first, and only the latest click's write may speak.
// snippetMarkup is the spec's snippet, attribute spelling, with the
// optional strings on the button as the gallery writes them.
const snippetMarkup = `
`
// classSnippet is the class spelling with no strings on the button,
// so what it says comes from the English written in copy.js.
const classSnippet = `
`
// tableSnippet has no strings on the button, so a page's table speaks;
// done, when set, is the button's own done string, which wins.
func tableSnippet(done string) string {
attr := ""
if done != "" {
attr = ` data-rst-copy-done="` + done + `"`
}
return `
fiesta.css
`
}
// snippetDoc is sizingDoc's page in lang, with body and then the
// scripts tags: copy.js is opt-in, so sizingDoc does not load it.
func snippetDoc(lang, body, scripts string) string {
return strings.Replace(sizingDoc("snippet", body+scripts), ``
// copyPages are the snippet pages: the spec's markup at /, the class
// spelling at /class, and Irish with its table at /ga and /ga-done.
func copyPages() map[string]string {
ga := `` + copyTag
return map[string]string{
"/": snippetDoc("en", snippetMarkup, copyTag),
"/class": snippetDoc("en", classSnippet, copyTag),
"/ga": snippetDoc("ga", tableSnippet(""), ga),
"/ga-done": snippetDoc("ga", tableSnippet("Done"), ga),
}
}
// copyRig serves copyPages with the tree's scripts, and i18n/.js
// from the release build, which is the only thing that writes them.
func copyRig(t *testing.T) *harness.Rig {
t.Helper()
dist := distScripts(t)
scripts := func(name string) ([]byte, bool) {
if strings.HasPrefix(name, "i18n/") {
return dist(name)
}
return fiesta.Script(name)
}
pages := copyPages()
return harness.New(t, func(string) http.Handler { return sizingMuxWith(t, pages, scripts) })
}
// copyWords is one language's copy-done and copy-failed, from its table.
func copyWords(t *testing.T, lang string) (done, failed string) {
t.Helper()
b, ok := fiesta.Strings(lang)
if !ok {
t.Fatalf("no table for %s", lang)
}
var table map[string]any
if err := json.Unmarshal(b, &table); err != nil {
t.Fatal(err)
}
done, _ = table["copy-done"].(string)
failed, _ = table["copy-failed"].(string)
if done == "" || failed == "" {
t.Fatalf("i18n/%s.json has copy-done %q and copy-failed %q; want both", lang, done, failed)
}
return done, failed
}
// grantClipboard lets the origin read and write the clipboard without
// asking, and gives the page focus: the clipboard API refuses a
// document without focus, and a headless page has none unless told.
func grantClipboard(t *testing.T, ctx context.Context, origin string) {
t.Helper()
for _, name := range []string{"clipboard-read", "clipboard-write"} {
mustRun(t, ctx, browser.SetPermission(&browser.PermissionDescriptor{Name: name}, browser.PermissionSettingGranted).WithOrigin(origin))
}
mustRun(t, ctx, emulation.SetFocusEmulationEnabled(true))
}
// beforeLoad runs js in every document this tab loads from now on,
// before any of the page's own scripts.
func beforeLoad(t *testing.T, ctx context.Context, js string) {
t.Helper()
mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error {
_, err := cdppage.AddScriptToEvaluateOnNewDocument(js).Do(ctx)
return err
}))
}
// The first snippet's parts, in either spelling, as copy.js finds them.
const snippetParts = `const box = document.querySelector("[rst-snippet],.rst-snippet"),
b = box.querySelector("[rst-snippet-copy],.rst-snippet__copy"),
s = box.querySelector("[rst-snippet-status],.rst-snippet__status"),
code = box.querySelector("code");`
type snippetReading struct {
Hidden, Shown bool
Label, Status, Code, Selected string
}
func readSnippet(t *testing.T, ctx context.Context) snippetReading {
t.Helper()
var r snippetReading
at(t, ctx, `(() => { `+snippetParts+`
return JSON.stringify({Hidden: b.hidden, Shown: b.getClientRects().length > 0, Label: b.textContent.trim(),
Status: s.textContent, Code: code.textContent, Selected: getSelection().toString()}); })()`, &r)
return r
}
// openSnippet loads path and fails at once if copy.js did not show the
// button: every later step clicks it, and a click on a hidden button
// would wait out the deadline instead of saying why.
func openSnippet(t *testing.T, ctx context.Context, origin, path string) snippetReading {
t.Helper()
mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(origin+path))
r := readSnippet(t, ctx)
if r.Hidden || !r.Shown {
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)
}
return r
}
// copyLog is what the recorder saw. Status and Label are the text each
// write left, in order, Status opening with the text before the first
// click; Clicks are when each click reached the button, and After is
// the snippet straight after copy.js handled each click. Times are
// performance.now(), in ms.
type copyLog struct {
Status, Label []struct {
T float64
Text string
}
Clicks []float64
After []struct{ Status, Label string }
}
// recordCopies installs the recorder. A write is one childList record,
// since copy.js sets textContent, and its text is the nodes it added:
// so two writes in one task are two entries, not their last state.
// The click listener on the document is added after copy.js's, so it
// runs after it.
func recordCopies(t *testing.T, ctx context.Context) {
t.Helper()
mustRun(t, ctx, chromedp.Evaluate(`(() => { `+snippetParts+`
const now = () => performance.now(), added = (r) => [...r.addedNodes].map((n) => n.textContent).join("");
const log = window.copyLog = {Status: [{T: now(), Text: s.textContent}], Label: [], Clicks: [], After: []};
new MutationObserver((rs) => rs.forEach((r) => log.Status.push({T: now(), Text: added(r)}))).observe(s, {childList: true});
new MutationObserver((rs) => rs.forEach((r) => log.Label.push({T: now(), Text: added(r).trim()}))).observe(b, {childList: true});
b.addEventListener("click", () => log.Clicks.push(now()), true);
document.addEventListener("click", (e) => { if (e.target === b) log.After.push({Status: s.textContent, Label: b.textContent.trim()}); });
getSelection().removeAllRanges();
return true; })()`, nil))
}
func readLog(t *testing.T, ctx context.Context) copyLog {
t.Helper()
var l copyLog
at(t, ctx, `JSON.stringify(window.copyLog)`, &l)
return l
}
// statusTexts is the status's record as text alone.
func (l copyLog) statusTexts() []string {
var out []string
for _, w := range l.Status {
out = append(out, w.Text)
}
return out
}
// spoken is every non-empty status write.
func (l copyLog) spoken() []string {
var out []string
for _, w := range l.Status {
if w.Text != "" {
out = append(out, w.Text)
}
}
return out
}
// stubWrites replaces navigator.clipboard.writeText with one that
// settles its calls as steps says, in order (each {ms, ok}), and
// records in window.copyWrites when each settled.
func stubWrites(t *testing.T, ctx context.Context, steps string) {
t.Helper()
mustRun(t, ctx, chromedp.Evaluate(`(() => { const steps = `+steps+`; window.copyWrites = [];
navigator.clipboard.writeText = (text) => {
const step = steps[copyWrites.length] || {ms: 0, ok: true}, rec = {Text: text, Settled: 0};
copyWrites.push(rec);
return new Promise((ok, no) => setTimeout(() => { rec.Settled = performance.now(); step.ok ? ok() : no(new DOMException("refused", "NotAllowedError")); }, step.ms));
};
return true; })()`, nil))
}
// clickTwice clicks the button in the page, then again gap ms later:
// in the page, so the gap is the page's own clock and not a round trip
// through CDP that a loaded machine stretches past copy.js's 100 ms.
func clickTwice(t *testing.T, ctx context.Context, gap int) {
t.Helper()
mustRun(t, ctx, chromedp.Evaluate(`(() => { `+snippetParts+` b.click(); setTimeout(() => b.click(), `+strconv.Itoa(gap)+`); return true; })()`, nil))
}
// soonTrue polls a page expression for up to five seconds and reports
// whether it came true, for a drive that names what never happened
// rather than the expression it waited on.
func soonTrue(ctx context.Context, expr string) bool {
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
var yes bool
if err := chromedp.Run(ctx, chromedp.Evaluate(expr, &yes)); err == nil && yes {
return true
}
}
return false
}
// driveCopy is the journey a granted clipboard takes, on the page at
// path, whose snippet is in English: the button is shown, a click puts
// the code's text on the clipboard, says Copied on the label at once
// and in the status no sooner than 100 ms after an emptied status, and
// two seconds on the label is back. A second click straight after the
// first's message does the same, and its label holds for its own two
// seconds, not what was left of the first's.
func driveCopy(t *testing.T, ctx context.Context, origin, path string) {
t.Helper()
done, _ := copyWords(t, "en")
grantClipboard(t, ctx, origin)
first := openSnippet(t, ctx, origin, path)
if first.Label == "" || first.Label == done {
t.Fatalf("%s: the button reads %q before any click; want its own label", path, first.Label)
}
recordCopies(t, ctx)
button := "[rst-snippet-copy],.rst-snippet__copy"
for i := 0; i < 2; i++ {
mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.writeText("")`, nil, awaitPromise),
chromedp.Click(button, chromedp.ByQuery))
if !soonTrue(ctx, `window.copyLog.Status.length > 1 && window.copyLog.Status[window.copyLog.Status.length - 1].Text !== ""`) {
t.Fatalf("%s, click %d: the status never spoke; want %q within a second or so", path, i+1, done)
}
var held string
mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.readText()`, &held, awaitPromise))
r, l := readSnippet(t, ctx), readLog(t, ctx)
if held != r.Code {
t.Errorf("%s, click %d: the clipboard holds %q; want the code, %q", path, i+1, held, r.Code)
}
if r.Label != done || r.Status != done {
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)
}
if len(l.Clicks) != i+1 || len(l.After) != i+1 {
t.Fatalf("%s, click %d: the recorder saw %d clicks; the click did not land", path, i+1, len(l.Clicks))
}
if a := l.After[i]; a.Status != "" || a.Label != first.Label {
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)
}
said := l.Status[len(l.Status)-1]
if wait := said.T - l.Clicks[i]; wait < 100 {
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)
}
}
settleUntil(t, ctx, `(() => { `+snippetParts+` return b.textContent.trim() === `+jsStr(first.Label)+`; })()`)
l := readLog(t, ctx)
back := l.Label[len(l.Label)-1]
if held := back.T - l.Clicks[1]; held < 2000 || held > 3000 {
t.Errorf("%s: the label came back %.0f ms after the second click; want two seconds, the second click's own", path, held)
}
}
// TestCopyCopiesAndSaysSo: the spec's snippet, a granted clipboard,
// driveCopy's journey.
func TestCopyCopiesAndSaysSo(t *testing.T) {
rig := copyRig(t)
ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
defer cancel()
driveCopy(t, ctx, rig.Origin, "/")
rig.Screen("body", "the snippet after two copies")
}
// TestAClassSpelledSnippetCopies: the same journey on the class
// spelling, whose button carries no strings, so Copied is copy.js's
// own English and has to match the English table.
func TestAClassSpelledSnippetCopies(t *testing.T) {
rig := copyRig(t)
ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
defer cancel()
driveCopy(t, ctx, rig.Origin, "/class")
}
// TestASuccessThenAFailureEndsOnTheFailure: a write that works, then
// 300 ms later one that fails. The first says Copied; the second puts
// the label back and keeps it, says it failed, and selects the code.
// Without the click restoring the label, Copied would stay on a button
// whose last copy failed.
func TestASuccessThenAFailureEndsOnTheFailure(t *testing.T) {
rig := copyRig(t)
ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
defer cancel()
done, failed := copyWords(t, "en")
label := openSnippet(t, ctx, rig.Origin, "/").Label
stubWrites(t, ctx, `[{ms: 0, ok: true}, {ms: 0, ok: false}]`)
recordCopies(t, ctx)
clickTwice(t, ctx, 300)
settleUntil(t, ctx, `window.copyWrites.length === 2 && window.copyWrites[1].Settled > 0`)
if r := readSnippet(t, ctx); r.Label != label {
t.Errorf("straight after the failed copy settled, the label reads %q; want %q", r.Label, label)
}
mustRun(t, ctx, chromedp.Sleep(2500*time.Millisecond))
r, l := readSnippet(t, ctx), readLog(t, ctx)
if r.Label != label {
t.Errorf("2.5 s after the failed copy the label reads %q; want %q", r.Label, label)
}
if want := []string{"", done, "", failed}; !slices.Equal(l.statusTexts(), want) {
t.Errorf("the status was written %q; want %q", l.statusTexts(), want)
}
if r.Selected != r.Code {
t.Errorf("after the failed copy the selection is %q; want the code, %q", r.Selected, r.Code)
}
}
// TestWritesThatSettleOutOfOrderEndOnTheLast: two clicks 50 ms apart
// whose writes settle in the other order. The late first write is not
// the latest request, so whatever it says is dropped: a failure that
// settles late selects nothing and says nothing, and a success that
// settles late does not overwrite the failure that came after it.
func TestWritesThatSettleOutOfOrderEndOnTheLast(t *testing.T) {
rig := copyRig(t)
ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
defer cancel()
done, failed := copyWords(t, "en")
for _, leg := range []struct {
name, steps string
status, said string
label, selected bool
}{
{"the first works late, the second fails at once", `[{ms: 400, ok: true}, {ms: 0, ok: false}]`, failed, failed, false, true},
{"the first fails late, the second works at once", `[{ms: 400, ok: false}, {ms: 0, ok: true}]`, done, done, true, false},
} {
t.Run(leg.name, func(t *testing.T) {
label := openSnippet(t, ctx, rig.Origin, "/").Label
stubWrites(t, ctx, leg.steps)
recordCopies(t, ctx)
clickTwice(t, ctx, 50)
settleUntil(t, ctx, `window.copyWrites.length === 2 && window.copyWrites.every((w) => w.Settled > 0)`)
mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond))
r, l := readSnippet(t, ctx), readLog(t, ctx)
want := label
if leg.label {
want = done
}
if r.Status != leg.status || r.Label != want {
t.Errorf("after both settled the status says %q and the label %q; want %q and %q", r.Status, r.Label, leg.status, want)
}
if got := l.spoken(); !slices.Equal(got, []string{leg.said}) {
t.Errorf("the status spoke %q; want only the second request's %q", got, leg.said)
}
if leg.selected && r.Selected != r.Code {
t.Errorf("the selection is %q; want the code, %q", r.Selected, r.Code)
}
if !leg.selected && r.Selected != "" {
t.Errorf("the selection is %q; a failure that is not the latest request should select nothing", r.Selected)
}
})
}
}
// TestTheStatusIsALiveRegionBeforeItSpeaks: the status is in the
// accessibility tree as a status before it has said anything, since a
// region that appears with its first message may not be announced. A
// region written the text it already holds has not changed, so every
// message follows an emptied status; and a message is only ever the
// latest request's, so clicks closer together than the 100 ms delay,
// or a click while an earlier write is pending, speak once.
func TestTheStatusIsALiveRegionBeforeItSpeaks(t *testing.T) {
rig := copyRig(t)
ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
defer cancel()
done, failed := copyWords(t, "en")
grantClipboard(t, ctx, rig.Origin)
t.Run("in the tree before any click", func(t *testing.T) {
openSnippet(t, ctx, rig.Origin, "/")
var nodes []*cdp.Node
mustRun(t, ctx, chromedp.Nodes("[rst-snippet-status]", &nodes, chromedp.ByQuery))
// Read loosely: cdproto's Node refuses an ignored reason it has
// no constant for (Chromium's "uninteresting"), and this needs
// only the role, the ignored flag and the DOM node.
var tree struct {
Nodes []struct {
Ignored bool `json:"ignored"`
Role *struct {
Value any `json:"value"`
} `json:"role"`
BackendDOMNodeID cdp.BackendNodeID `json:"backendDOMNodeId"`
} `json:"nodes"`
}
mustRun(t, ctx, accessibility.Enable(), chromedp.ActionFunc(func(ctx context.Context) error {
return cdp.Execute(ctx, accessibility.CommandGetFullAXTree, accessibility.GetFullAXTree(), &tree)
}))
found := false
for _, n := range tree.Nodes {
if n.BackendDOMNodeID == nodes[0].BackendNodeID {
found = !n.Ignored && n.Role != nil && n.Role.Value == "status"
}
}
if len(tree.Nodes) == 0 {
t.Fatal("the accessibility tree came back empty; the read is broken, not the status")
}
if !found {
t.Error("before any click the snippet's status is not a status node in the accessibility tree")
}
})
t.Run("two clicks a second apart", func(t *testing.T) {
openSnippet(t, ctx, rig.Origin, "/")
recordCopies(t, ctx)
clickTwice(t, ctx, 1000)
settleUntil(t, ctx, `window.copyLog.Status.length >= 4`)
mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond))
if got, want := readLog(t, ctx).statusTexts(), []string{"", done, "", done}; !slices.Equal(got, want) {
t.Errorf("the status was written %q; want %q, two changes the screen reader can hear", got, want)
}
})
t.Run("two clicks 50 ms apart", func(t *testing.T) {
openSnippet(t, ctx, rig.Origin, "/")
recordCopies(t, ctx)
clickTwice(t, ctx, 50)
settleUntil(t, ctx, `window.copyLog.Status.some((w) => w.Text !== "")`)
mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond))
l := readLog(t, ctx)
if got, want := l.statusTexts(), []string{"", done}; !slices.Equal(got, want) {
t.Errorf("the status was written %q; want %q, one announcement for the latest click", got, want)
}
if len(l.After) != 2 || l.After[1].Status != "" {
t.Errorf("straight after the second click the status read %+v; want it empty", l.After)
}
if said := l.Status[len(l.Status)-1]; len(l.Clicks) == 2 && said.T-l.Clicks[1] < 100 {
t.Errorf("the status spoke %.0f ms after the second click; want 100 ms or more", said.T-l.Clicks[1])
}
})
t.Run("a click while the first write is pending", func(t *testing.T) {
label := openSnippet(t, ctx, rig.Origin, "/").Label
stubWrites(t, ctx, `[{ms: 0, ok: true}, {ms: 500, ok: false}]`)
recordCopies(t, ctx)
clickTwice(t, ctx, 50)
settleUntil(t, ctx, `window.copyWrites.length === 2 && window.copyWrites[1].Settled > 0 && window.copyLog.Status.some((w) => w.Text !== "")`)
// Past the first click's two-second reset, so a reset that
// escaped the request number would show.
mustRun(t, ctx, chromedp.Sleep(2000*time.Millisecond))
l := readLog(t, ctx)
var settled float64
at(t, ctx, `JSON.stringify(window.copyWrites[1].Settled)`, &settled)
if got := l.spoken(); !slices.Equal(got, []string{failed}) {
t.Errorf("the status spoke %q; want only %q, the pending request's", got, failed)
}
for _, w := range l.Status {
if w.Text != "" && w.T < settled {
t.Errorf("the status said %q at %.0f ms, before the pending write settled at %.0f ms", w.Text, w.T, settled)
}
}
if len(l.Clicks) != 2 {
t.Fatalf("the recorder saw %d clicks; want 2", len(l.Clicks))
}
for _, w := range l.Label {
if w.T >= l.Clicks[1] && w.Text != label {
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)
}
}
if r := readSnippet(t, ctx); r.Label != label {
t.Errorf("the label reads %q; want %q", r.Label, label)
}
})
}
// TestACopyThatFailsSelectsTheCode: a clipboard that refuses, by a
// rejected promise and then by a throw. Either way the code is
// selected, so it can be copied by hand, the status says so, and the
// label never claims a copy.
func TestACopyThatFailsSelectsTheCode(t *testing.T) {
rig := copyRig(t)
ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
defer cancel()
_, failed := copyWords(t, "en")
for _, leg := range []struct{ name, init string }{
{"rejected", `navigator.clipboard.writeText = () => Promise.reject(new DOMException("Write permission denied.", "NotAllowedError"));`},
{"thrown", `navigator.clipboard.writeText = () => { throw new DOMException("Write permission denied.", "NotAllowedError"); };`},
} {
t.Run(leg.name, func(t *testing.T) {
beforeLoad(t, ctx, leg.init)
label := openSnippet(t, ctx, rig.Origin, "/").Label
recordCopies(t, ctx)
mustRun(t, ctx, chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery))
settleUntil(t, ctx, `window.copyLog.Status.some((w) => w.Text !== "")`)
r, l := readSnippet(t, ctx), readLog(t, ctx)
if r.Selected != r.Code {
t.Errorf("after a %s copy the selection is %q; want the code, %q", leg.name, r.Selected, r.Code)
}
if r.Status != failed {
t.Errorf("after a %s copy the status says %q; want %q", leg.name, r.Status, failed)
}
for _, w := range l.Label {
if w.Text != label {
t.Errorf("after a %s copy the label was written %q; want %q throughout", leg.name, w.Text, label)
}
}
})
}
}
// TestCopyStaysHiddenWithoutAClipboard: with no clipboard API copy.js
// runs (its guard is set) and leaves the button hidden; with scripts
// off the markup's own hidden keeps it so. Either way no button that
// does nothing.
func TestCopyStaysHiddenWithoutAClipboard(t *testing.T) {
rig := copyRig(t)
ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
defer cancel()
hidden := func(t *testing.T, where string) {
t.Helper()
if r := readSnippet(t, ctx); !r.Hidden || r.Shown {
t.Errorf("%s the Copy button is hidden=%v shown=%v; want it hidden", where, r.Hidden, r.Shown)
}
}
t.Run("no clipboard API", func(t *testing.T) {
beforeLoad(t, ctx, `Object.defineProperty(navigator, "clipboard", { value: undefined });`)
mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"))
var ran bool
mustRun(t, ctx, chromedp.Evaluate(`window.fiestaCopy === true`, &ran))
if !ran {
t.Error("copy.js did not run: window.fiestaCopy is not set, so this leg proves nothing about it")
}
hidden(t, "with no clipboard API")
})
t.Run("scripts off", func(t *testing.T) {
mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true), chromedp.Navigate(rig.Origin+"/"))
hidden(t, "with scripts off")
mustRun(t, ctx, emulation.SetScriptExecutionDisabled(false))
})
}
// TestCopyReadsTheTable: an Irish page with Irish strings loaded and
// none on the button says the table's copy-done; the button's own
// data-rst-copy-done wins over the table.
func TestCopyReadsTheTable(t *testing.T) {
rig := copyRig(t)
ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
defer cancel()
done, _ := copyWords(t, "ga")
grantClipboard(t, ctx, rig.Origin)
for _, leg := range []struct{ path, want string }{{"/ga", done}, {"/ga-done", "Done"}} {
openSnippet(t, ctx, rig.Origin, leg.path)
recordCopies(t, ctx)
mustRun(t, ctx, chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery))
settleUntil(t, ctx, `window.copyLog.Status.some((w) => w.Text !== "")`)
if r := readSnippet(t, ctx); r.Status != leg.want || r.Label != leg.want {
t.Errorf("%s: the status says %q and the label %q; want %q in both", leg.path, r.Status, r.Label, leg.want)
}
}
}