//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
}