//go:build browser package check import ( "context" "net/http" "strings" "sync" "testing" "time" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/harness" ) // A server that swaps forms in place marks them data-live-form, and its // own script decides on each submit whether to send the form itself or // leave it to the browser. 1.2.0's busy.js (Rastrillo 8548e455) arms // such a form only once that script has let it go: one the script // claimed is the script's to show busy, and arming it as well would hand // it back a tick after the script cancelled the submit, wiping the // script's busy state mid-request. One left to the browser keeps the // busy rule, whichever script loaded first. // liveStub stands in for the server's script, as Rastrillo's live.js // behaves: a capture listener marks the submit of a form it would send // (e.liveForm), and a later listener cancels it and shows its own busy // state, with no spinner, so the state is told apart from busy.js's. A // form whose data-live-form is "native" is left to the browser. const liveStub = `(function () { "use strict"; document.addEventListener("submit", function (e) { if (e.target.getAttribute("data-live-form") === "claimed") e.liveForm = true; }, true); document.addEventListener("submit", function (e) { if (!e.liveForm || e.defaultPrevented) return; e.preventDefault(); e.target.setAttribute("aria-busy", "true"); if (e.submitter) e.submitter.setAttribute("aria-busy", "true"); }); })();` // liveFormPage is the two forms under busy.js and the stub, loaded in // the order given. func liveFormPage(scripts ...string) string { var head strings.Builder for _, name := range scripts { head.WriteString(``) } return `` + `live forms` + `` + `` + head.String() + `` + `
` + `
` + `
` + `` + `
` + `` } // liveFormState is a button's busy state: its aria-busy and whether // busy.js's spinner is in it. func liveFormState(id string) string { return `(() => { const button = document.getElementById(` + jsStr(id) + `);` + ` return "busy:" + (button.getAttribute("aria-busy") || "-") +` + ` " spin:" + ` + `(button.querySelector("[rst-spin]") ? "yes" : "no"); })()` } func TestBusyLeavesALiveFormItsScriptClaimed(t *testing.T) { pages := map[string]string{ "/busy-first": liveFormPage("busy.js", "live-stub.js"), "/stub-first": liveFormPage("live-stub.js", "busy.js"), } scripts := func(name string) ([]byte, bool) { if name == "live-stub.js" { return []byte(liveStub), true } return fiesta.Script(name) } payloads := make(chan string, 8) rig := harness.New(t, func(string) http.Handler { mux := sizingMuxWith(t, pages, scripts) received(mux, "/claimed", "", payloads, http.StatusNoContent) received(mux, "/native", "", payloads, http.StatusNoContent) return mux }) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() for _, order := range []string{"/busy-first", "/stub-first"} { // Claimed: the stub's busy state is still there after busy.js's // hand-back tick would have run, with no spinner of busy.js's, // and nothing reached the server. var claimed string mustRun(t, ctx, chromedp.Navigate(rig.Origin+order), chromedp.WaitVisible("#claimed", chromedp.ByQuery), chromedp.Click("#claimed", chromedp.ByQuery), chromedp.Sleep(300*time.Millisecond), chromedp.Evaluate(liveFormState("claimed"), &claimed)) if want := "busy:true spin:no"; claimed != want { t.Errorf("%s: a form the live script claimed reads %q after the "+ "submit, want %q: busy.js armed it and handed it back, "+ "wiping the script's busy state", order, claimed, want) } tookNothing(t, payloads, order+", the claimed form") // Native: busy.js arms it, late, and the browser sends it. var native string mustRun(t, ctx, chromedp.Navigate(rig.Origin+order), chromedp.WaitVisible("#native", chromedp.ByQuery)) answered := requestFinished(ctx, http.MethodPost, rig.Origin+"/native") mustRun(t, ctx, chromedp.Click("#native", chromedp.ByQuery), chromedp.Sleep(150*time.Millisecond), chromedp.Evaluate(liveFormState("native"), &native)) if want := "busy:true spin:yes"; native != want { t.Errorf("%s: a form the live script left to the browser reads %q "+ "after the submit, want %q: busy.js did not arm it", order, native, want) } got := took(t, payloads, order+", the native form") if got != "form=native" { t.Errorf("%s: the native form sent %q, want form=native", order, got) } // The server has the form before the browser has its 204, and // a navigation started before the answer is aborted by it. select { case <-answered: case <-time.After(20 * time.Second): t.Fatalf("%s: the browser never finished the native form's "+ "request", order) } } } // requestFinished is closed once the browser has finished, or given // up on, the first request with this method and URL sent after the // call. func requestFinished(ctx context.Context, method, url string) <-chan struct{} { done := make(chan struct{}) var once sync.Once var requestID network.RequestID var lock sync.Mutex chromedp.ListenTarget(ctx, func(anyEvent any) { lock.Lock() defer lock.Unlock() switch event := anyEvent.(type) { case *network.EventRequestWillBeSent: if requestID == "" && event.Request.Method == method && event.Request.URL == url { requestID = event.RequestID } case *network.EventLoadingFinished: if requestID != "" && event.RequestID == requestID { once.Do(func() { close(done) }) } case *network.EventLoadingFailed: if requestID != "" && event.RequestID == requestID { once.Do(func() { close(done) }) } } }) return done }