//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 = `
<link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css">
` // 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 = `
<link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css">
` // 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) } } }