| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 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. |
| 21 | const 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. |
| 32 | 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"}]}` |
| 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). |
| 45 | func 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. |
| 74 | func 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 "". |
| 89 | func 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 | |
| 103 | func 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. |
| 118 | func 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 | |