rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package check
4
5import (
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.
34const 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.
50func 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.
70func 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
78func 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.
148func 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