//go:build browser package check import ( "context" "net/http" "strings" "testing" "time" "github.com/chromedp/cdproto/input" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta/internal/harness" ) // speculationRulesPath is where the site below answers with its // speculation rules, and what the Speculation-Rules header on every // response names. const speculationRulesPath = "/_speculation-rules" // speculationRules is the ruleset rastrillo's server sends for the // shells, as the drive below needs it: document rules scoped by a // selector, in both markup spellings, to shell navigation and the back // control inside a sidebar or console root that names its view. Only // the layouts that ship the phone index write a view; a layout copied // before them has a bare root and gets no prerender at all. Fiesta // serves nothing, so the drive serves the rules itself, by the header // rather than inline: a header ruleset is not a script, so an app's // CSP never stops it loading, which is how rastrillo delivers it. const 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"}]}` // prerenderSite is the sidebar app rastrillo's drives served through // its handler, read from the seeded pages: the index, three // sections, a plain link in the index's content, a stage page with a // link on it, and a page in a sidebar layout copied before the phone // index; every response carries the Speculation-Rules header, and the // rules are answered with the content type browsers accept for a // ruleset (a static file would go out as application/json, which they // refuse). Every document request is reported with its Sec-Purpose, // which is how a prerender is seen (headless Chromium fetches the page // for it; enabling CDP's Preload domain would switch prerendering off, // measured). func prerenderSite(t *testing.T, hits chan<- string) func(string) http.Handler { return func(string) http.Handler { pages := map[string]string{} for path, kase := range map[string]string{ "/": "index", "/invoices": "invoices", "/orders": "orders", "/team": "team", "/plain": "plain", "/signin": "signin", "/legacy": "legacy", } { pages[path] = fixture(t, "prerender_browser_test.go", "prerenderSite", kase) } mux := shellAssets(t, pages) mux.HandleFunc("GET "+speculationRulesPath, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/speculationrules+json") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write([]byte(speculationRules)) }) recorded := recordDocuments(mux, hits) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // On every response rather than sniffed for HTML: browsers // act on it for documents and ignore it elsewhere. A // structured-field list of one string, which is the // header's form. w.Header().Set("Speculation-Rules", `"`+speculationRulesPath+`"`) recorded.ServeHTTP(w, r) }) } } // recordDocuments reports every request for a page (not an asset) with // its Sec-Purpose. func recordDocuments(next *http.ServeMux, hits chan<- string) *http.ServeMux { out := http.NewServeMux() out.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.URL.Path, ".") { select { case hits <- r.URL.Path + " " + r.Header.Get("Sec-Purpose"): default: } } next.ServeHTTP(w, r) }) return out } // waitFor reports the first hit for path within d, or "". func waitFor(c chan string, path string, d time.Duration) string { deadline := time.After(d) for { select { case h := <-c: if strings.HasPrefix(h, path+" ") { return h } case <-deadline: return "" } } } func centre(t *testing.T, ctx context.Context, sel string) (float64, float64) { p := probe(t, ctx, sel, 0.5, 0.5, 0, 0) return p.X, p.Y } // TestAPrefetchedIndexStillReturnsFocus: headless Chromium does not // activate a prerender (it delivers the page from the prefetch, // measured: deliveryType "navigational-prefetch"), so this is the // nearest a headless drive gets to the prerendered-index leg: a deep // link's back control, pressed long enough for the rules to fetch the // index, lands on an index delivered from the speculation, with focus // on the section it left. The true activation leg stays on the by-hand // list. The hover and pointer-down legs, and the control with the // rules switched off, are the server's behaviour and stay with // rastrillo's drive. func TestAPrefetchedIndexStillReturnsFocus(t *testing.T) { hits := make(chan string, 64) rig := harness.New(t, prerenderSite(t, hits), harness.WithCoarsePointer()) ctx, done, thrown := tab(t, rig, "") defer done() visit(t, ctx, rig.Origin+"/invoices") settleUntil(t, ctx, `!!document.querySelector("[rst-shell-back] a")`) x, y := centre(t, ctx, "[rst-shell-back] a") mustRun(t, ctx, chromedp.MouseEvent(input.MousePressed, x, y, chromedp.ButtonLeft, chromedp.ClickCount(1))) if h := waitFor(hits, "/", 4*time.Second); !strings.Contains(h, "prerender") { t.Fatalf("pressing the back control fetched no speculation of the index (got %q); the leg has not arisen", h) } mustRun(t, ctx, chromedp.MouseEvent(input.MouseReleased, x, y, chromedp.ButtonLeft, chromedp.ClickCount(1))) settleUntil(t, ctx, `location.pathname === "/"`) var delivery string mustRun(t, ctx, chromedp.Evaluate(`performance.getEntriesByType("navigation")[0].deliveryType`, &delivery)) if delivery != "navigational-prefetch" { t.Fatalf("the index was delivered as %q, not from the speculation; the leg has not arisen", delivery) } settleUntil(t, ctx, `document.activeElement.id === "nav-invoices"`) // scriptErrors, not a raw length check: the back-control navigation // is the same shape as TestThePhoneIndexWorksWithScripts's, which // headless Chromium itself skips the incoming transition for on // roughly one navigation in five (see scriptErrors's comment) — // an unfiltered check would flake on exactly that. errs, _ := scriptErrors(t, *thrown) if len(errs) > 0 { t.Errorf("uncaught: %v", errs) } }