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 "testing"
10 "time"
11
12 "github.com/chromedp/cdproto/input"
13 "github.com/chromedp/chromedp"
14
15 "amadan.net/rastrillo/fiesta/internal/harness"
16)
17
18// speculationRulesPath is where the site below answers with its
19// speculation rules, and what the Speculation-Rules header on every
20// response names.
21const speculationRulesPath = "/_speculation-rules"
22
23// speculationRules is the ruleset rastrillo's server sends for the
24// shells, as the drive below needs it: document rules scoped by a
25// selector, in both markup spellings, to shell navigation and the back
26// control inside a sidebar or console root that names its view. Only
27// the layouts that ship the phone index write a view; a layout copied
28// before them has a bare root and gets no prerender at all. Fiesta
29// serves nothing, so the drive serves the rules itself, by the header
30// rather than inline: a header ruleset is not a script, so an app's
31// CSP never stops it loading, which is how rastrillo delivers it.
32const speculationRules = `{"prerender":[{"source":"document","where":{"selector_matches":":is([rst-shell-sidebar~=page],[rst-shell-sidebar~=index],[rst-shell-console~=page],[rst-shell-console~=index],.rst-shell-sidebar--page,.rst-shell-sidebar--index,.rst-shell-console--page,.rst-shell-console--index) :is([rst-shell-nav],[rst-shell-back],.rst-shell__nav,.rst-shell__back) a[href]"},"eagerness":"moderate"}]}`
33
34// prerenderSite is the sidebar app rastrillo's drives served through
35// its handler, read from the seeded pages: the index, three
36// sections, a plain link in the index's content, a stage page with a
37// link on it, and a page in a sidebar layout copied before the phone
38// index; every response carries the Speculation-Rules header, and the
39// rules are answered with the content type browsers accept for a
40// ruleset (a static file would go out as application/json, which they
41// refuse). Every document request is reported with its Sec-Purpose,
42// which is how a prerender is seen (headless Chromium fetches the page
43// for it; enabling CDP's Preload domain would switch prerendering off,
44// measured).
45func prerenderSite(t *testing.T, hits chan<- string) func(string) http.Handler {
46 return func(string) http.Handler {
47 pages := map[string]string{}
48 for path, kase := range map[string]string{
49 "/": "index", "/invoices": "invoices", "/orders": "orders", "/team": "team",
50 "/plain": "plain", "/signin": "signin", "/legacy": "legacy",
51 } {
52 pages[path] = fixture(t, "prerender_browser_test.go", "prerenderSite", kase)
53 }
54 mux := shellAssets(t, pages)
55 mux.HandleFunc("GET "+speculationRulesPath, func(w http.ResponseWriter, _ *http.Request) {
56 w.Header().Set("Content-Type", "application/speculationrules+json")
57 w.Header().Set("Cache-Control", "public, max-age=86400")
58 w.Write([]byte(speculationRules))
59 })
60 recorded := recordDocuments(mux, hits)
61 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62 // On every response rather than sniffed for HTML: browsers
63 // act on it for documents and ignore it elsewhere. A
64 // structured-field list of one string, which is the
65 // header's form.
66 w.Header().Set("Speculation-Rules", `"`+speculationRulesPath+`"`)
67 recorded.ServeHTTP(w, r)
68 })
69 }
70}
71
72// recordDocuments reports every request for a page (not an asset) with
73// its Sec-Purpose.
74func recordDocuments(next *http.ServeMux, hits chan<- string) *http.ServeMux {
75 out := http.NewServeMux()
76 out.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
77 if !strings.Contains(r.URL.Path, ".") {
78 select {
79 case hits <- r.URL.Path + " " + r.Header.Get("Sec-Purpose"):
80 default:
81 }
82 }
83 next.ServeHTTP(w, r)
84 })
85 return out
86}
87
88// waitFor reports the first hit for path within d, or "".
89func waitFor(c chan string, path string, d time.Duration) string {
90 deadline := time.After(d)
91 for {
92 select {
93 case h := <-c:
94 if strings.HasPrefix(h, path+" ") {
95 return h
96 }
97 case <-deadline:
98 return ""
99 }
100 }
101}
102
103func centre(t *testing.T, ctx context.Context, sel string) (float64, float64) {
104 p := probe(t, ctx, sel, 0.5, 0.5, 0, 0)
105 return p.X, p.Y
106}
107
108// TestAPrefetchedIndexStillReturnsFocus: headless Chromium does not
109// activate a prerender (it delivers the page from the prefetch,
110// measured: deliveryType "navigational-prefetch"), so this is the
111// nearest a headless drive gets to the prerendered-index leg: a deep
112// link's back control, pressed long enough for the rules to fetch the
113// index, lands on an index delivered from the speculation, with focus
114// on the section it left. The true activation leg stays on the by-hand
115// list. The hover and pointer-down legs, and the control with the
116// rules switched off, are the server's behaviour and stay with
117// rastrillo's drive.
118func TestAPrefetchedIndexStillReturnsFocus(t *testing.T) {
119 hits := make(chan string, 64)
120 rig := harness.New(t, prerenderSite(t, hits), harness.WithCoarsePointer())
121 ctx, done, thrown := tab(t, rig, "")
122 defer done()
123 visit(t, ctx, rig.Origin+"/invoices")
124 settleUntil(t, ctx, `!!document.querySelector("[rst-shell-back] a")`)
125 x, y := centre(t, ctx, "[rst-shell-back] a")
126 mustRun(t, ctx, chromedp.MouseEvent(input.MousePressed, x, y, chromedp.ButtonLeft, chromedp.ClickCount(1)))
127 if h := waitFor(hits, "/", 4*time.Second); !strings.Contains(h, "prerender") {
128 t.Fatalf("pressing the back control fetched no speculation of the index (got %q); the leg has not arisen", h)
129 }
130 mustRun(t, ctx, chromedp.MouseEvent(input.MouseReleased, x, y, chromedp.ButtonLeft, chromedp.ClickCount(1)))
131 settleUntil(t, ctx, `location.pathname === "/"`)
132 var delivery string
133 mustRun(t, ctx, chromedp.Evaluate(`performance.getEntriesByType("navigation")[0].deliveryType`, &delivery))
134 if delivery != "navigational-prefetch" {
135 t.Fatalf("the index was delivered as %q, not from the speculation; the leg has not arisen", delivery)
136 }
137 settleUntil(t, ctx, `document.activeElement.id === "nav-invoices"`)
138 // scriptErrors, not a raw length check: the back-control navigation
139 // is the same shape as TestThePhoneIndexWorksWithScripts's, which
140 // headless Chromium itself skips the incoming transition for on
141 // roughly one navigation in five (see scriptErrors's comment) —
142 // an unfiltered check would flake on exactly that.
143 errs, _ := scriptErrors(t, *thrown)
144 if len(errs) > 0 {
145 t.Errorf("uncaught: %v", errs)
146 }
147}
148