| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "net/http" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/chromedp/cdproto/network" |
| 14 | "github.com/chromedp/chromedp" |
| 15 | |
| 16 | "amadan.net/rastrillo/fiesta" |
| 17 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 18 | ) |
| 19 | |
| 20 | // A server that swaps forms in place marks them data-live-form, and its |
| 21 | // own script decides on each submit whether to send the form itself or |
| 22 | // leave it to the browser. 1.2.0's busy.js (Rastrillo 8548e455) arms |
| 23 | // such a form only once that script has let it go: one the script |
| 24 | // claimed is the script's to show busy, and arming it as well would hand |
| 25 | // it back a tick after the script cancelled the submit, wiping the |
| 26 | // script's busy state mid-request. One left to the browser keeps the |
| 27 | // busy rule, whichever script loaded first. |
| 28 | |
| 29 | // liveStub stands in for the server's script, as Rastrillo's live.js |
| 30 | // behaves: a capture listener marks the submit of a form it would send |
| 31 | // (e.liveForm), and a later listener cancels it and shows its own busy |
| 32 | // state, with no spinner, so the state is told apart from busy.js's. A |
| 33 | // form whose data-live-form is "native" is left to the browser. |
| 34 | const liveStub = `(function () { |
| 35 | "use strict"; |
| 36 | document.addEventListener("submit", function (e) { |
| 37 | if (e.target.getAttribute("data-live-form") === "claimed") |
| 38 | e.liveForm = true; |
| 39 | }, true); |
| 40 | document.addEventListener("submit", function (e) { |
| 41 | if (!e.liveForm || e.defaultPrevented) return; |
| 42 | e.preventDefault(); |
| 43 | e.target.setAttribute("aria-busy", "true"); |
| 44 | if (e.submitter) e.submitter.setAttribute("aria-busy", "true"); |
| 45 | }); |
| 46 | })();` |
| 47 | |
| 48 | // liveFormPage is the two forms under busy.js and the stub, loaded in |
| 49 | // the order given. |
| 50 | func liveFormPage(scripts ...string) string { |
| 51 | var head strings.Builder |
| 52 | for _, name := range scripts { |
| 53 | head.WriteString(`<script defer src="/` + name + `"></script>`) |
| 54 | } |
| 55 | return `<!doctype html><html lang="en" dir="ltr"><head>` + |
| 56 | `<meta charset="utf-8"><title>live forms</title>` + |
| 57 | `<link rel="stylesheet" href="/tokens.css">` + |
| 58 | `<link rel="stylesheet" href="/theme.css">` + head.String() + |
| 59 | `</head><body>` + |
| 60 | `<form method="post" action="/claimed" data-live-form="claimed">` + |
| 61 | `<button rst-btn id="claimed">Save</button></form>` + |
| 62 | `<form method="post" action="/native" data-live-form="native">` + |
| 63 | `<input type="hidden" name="form" value="native">` + |
| 64 | `<button rst-btn id="native">Save</button></form>` + |
| 65 | `</body></html>` |
| 66 | } |
| 67 | |
| 68 | // liveFormState is a button's busy state: its aria-busy and whether |
| 69 | // busy.js's spinner is in it. |
| 70 | func liveFormState(id string) string { |
| 71 | return `(() => { const button = document.getElementById(` + |
| 72 | jsStr(id) + `);` + |
| 73 | ` return "busy:" + (button.getAttribute("aria-busy") || "-") +` + |
| 74 | ` " spin:" + ` + |
| 75 | `(button.querySelector("[rst-spin]") ? "yes" : "no"); })()` |
| 76 | } |
| 77 | |
| 78 | func TestBusyLeavesALiveFormItsScriptClaimed(t *testing.T) { |
| 79 | pages := map[string]string{ |
| 80 | "/busy-first": liveFormPage("busy.js", "live-stub.js"), |
| 81 | "/stub-first": liveFormPage("live-stub.js", "busy.js"), |
| 82 | } |
| 83 | scripts := func(name string) ([]byte, bool) { |
| 84 | if name == "live-stub.js" { |
| 85 | return []byte(liveStub), true |
| 86 | } |
| 87 | return fiesta.Script(name) |
| 88 | } |
| 89 | payloads := make(chan string, 8) |
| 90 | rig := harness.New(t, func(string) http.Handler { |
| 91 | mux := sizingMuxWith(t, pages, scripts) |
| 92 | received(mux, "/claimed", "", payloads, http.StatusNoContent) |
| 93 | received(mux, "/native", "", payloads, http.StatusNoContent) |
| 94 | return mux |
| 95 | }) |
| 96 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 97 | defer cancel() |
| 98 | |
| 99 | for _, order := range []string{"/busy-first", "/stub-first"} { |
| 100 | // Claimed: the stub's busy state is still there after busy.js's |
| 101 | // hand-back tick would have run, with no spinner of busy.js's, |
| 102 | // and nothing reached the server. |
| 103 | var claimed string |
| 104 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+order), |
| 105 | chromedp.WaitVisible("#claimed", chromedp.ByQuery), |
| 106 | chromedp.Click("#claimed", chromedp.ByQuery), |
| 107 | chromedp.Sleep(300*time.Millisecond), |
| 108 | chromedp.Evaluate(liveFormState("claimed"), &claimed)) |
| 109 | if want := "busy:true spin:no"; claimed != want { |
| 110 | t.Errorf("%s: a form the live script claimed reads %q after the "+ |
| 111 | "submit, want %q: busy.js armed it and handed it back, "+ |
| 112 | "wiping the script's busy state", order, claimed, want) |
| 113 | } |
| 114 | tookNothing(t, payloads, order+", the claimed form") |
| 115 | |
| 116 | // Native: busy.js arms it, late, and the browser sends it. |
| 117 | var native string |
| 118 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+order), |
| 119 | chromedp.WaitVisible("#native", chromedp.ByQuery)) |
| 120 | answered := requestFinished(ctx, http.MethodPost, rig.Origin+"/native") |
| 121 | mustRun(t, ctx, chromedp.Click("#native", chromedp.ByQuery), |
| 122 | chromedp.Sleep(150*time.Millisecond), |
| 123 | chromedp.Evaluate(liveFormState("native"), &native)) |
| 124 | if want := "busy:true spin:yes"; native != want { |
| 125 | t.Errorf("%s: a form the live script left to the browser reads %q "+ |
| 126 | "after the submit, want %q: busy.js did not arm it", |
| 127 | order, native, want) |
| 128 | } |
| 129 | got := took(t, payloads, order+", the native form") |
| 130 | if got != "form=native" { |
| 131 | t.Errorf("%s: the native form sent %q, want form=native", order, |
| 132 | got) |
| 133 | } |
| 134 | // The server has the form before the browser has its 204, and |
| 135 | // a navigation started before the answer is aborted by it. |
| 136 | select { |
| 137 | case <-answered: |
| 138 | case <-time.After(20 * time.Second): |
| 139 | t.Fatalf("%s: the browser never finished the native form's "+ |
| 140 | "request", order) |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // requestFinished is closed once the browser has finished, or given |
| 146 | // up on, the first request with this method and URL sent after the |
| 147 | // call. |
| 148 | func requestFinished(ctx context.Context, method, |
| 149 | url string) <-chan struct{} { |
| 150 | done := make(chan struct{}) |
| 151 | var once sync.Once |
| 152 | var requestID network.RequestID |
| 153 | var lock sync.Mutex |
| 154 | chromedp.ListenTarget(ctx, func(anyEvent any) { |
| 155 | lock.Lock() |
| 156 | defer lock.Unlock() |
| 157 | switch event := anyEvent.(type) { |
| 158 | case *network.EventRequestWillBeSent: |
| 159 | if requestID == "" && event.Request.Method == method && |
| 160 | event.Request.URL == url { |
| 161 | requestID = event.RequestID |
| 162 | } |
| 163 | case *network.EventLoadingFinished: |
| 164 | if requestID != "" && event.RequestID == requestID { |
| 165 | once.Do(func() { close(done) }) |
| 166 | } |
| 167 | case *network.EventLoadingFailed: |
| 168 | if requestID != "" && event.RequestID == requestID { |
| 169 | once.Do(func() { close(done) }) |
| 170 | } |
| 171 | } |
| 172 | }) |
| 173 | return done |
| 174 | } |
| 175 | |