package check import ( "bytes" "encoding/json" "fmt" "regexp" "slices" "strings" "testing" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/nodetest" ) // fiestaJS is js/fiesta.js as the tree ships it. func fiestaJS(t *testing.T) []byte { t.Helper() js, ok := fiesta.Script("fiesta.js") if !ok { t.Fatal("fiesta.js is not shipped") } return js } // The shim's behaviour is driven in a browser (scheme_browser_test.go) // and in Node (fiesta_node.mjs, below). What a Go test can hold honest // is the contract the docs promise: the vocabulary the file answers to, // its inert-by-default IIFE shape, and the absence of anything a CSP // would reject. Ported from rastrillo's ui/shim_test.go at the seed // commit: the polling strings are gone with the polling, and the cookie // strings are replaced by Fiesta's own contract, the event, the storage // key, the ownership attribute and the store. func TestShimContract(t *testing.T) { js := string(fiestaJS(t)) for _, want := range []string{ // Light dismiss: the menu classes it answers to, the nested // rst-menu-group among them, so a submenu is never left open // behind its closing parent, the containment test that keeps the // menu being used open, the Escape key, and the focus hand-back to // the summary. Delegated on the document, so the count of // addEventListener calls stays at two however many menus a page // renders. "rst-dropdown", "rst-menu-group", "rst-row-menu", "closeMenus", "contains", "Escape", "summary.focus()", // Both spellings. tokens.css pairs them for the window between // upgrading the module and running `rastrillo markup`, and the // shim has to pair them for the same window or an app's menus // stop dismissing halfway through the upgrade we hand it. `MENUS.replace(`, // The topbar's and console's narrow Menu is a card over the page, // so it is in the light-dismiss list, in both spellings written // out (the rewrite above would give .rst-shell-menu, and the // class is .rst-shell__menu), with its tail counted as inside it // only while its summary is rendered. Scoped to a topbar or a // console that names its view: an old console's Menu also // reveals its rail, which must not close on a tap at a group // label. `[rst-shell-topbar] [rst-shell-menu][open],[rst-shell-console~=page] [rst-shell-menu][open],[rst-shell-console~=index] [rst-shell-menu][open]`, `.rst-shell-topbar .rst-shell__menu[open],.rst-shell-console--page .rst-shell__menu[open],.rst-shell-console--index .rst-shell__menu[open]`, `TAIL = "[rst-shell-tail],.rst-shell__tail"`, "menuAround", "getClientRects().length", // The colour-scheme switch: the menu it answers to in both // spellings, the attribute the themes read, the event a choice // dispatches, the key it is remembered under, the attribute that // says the page remembers, and the store it remembers in. `"[rst-scheme],.rst-scheme"`, "data-theme", `"rst-scheme"`, `"fiesta-scheme"`, "data-rst-scheme-store", "localStorage", // The iOS touch listener: :active lands on a tap only when // a touchstart listener exists, and this file owns it so an app // that deletes light.js keeps its pressed states. `addEventListener("touchstart", function () {}, { passive: true })`, } { if !strings.Contains(js, want) { t.Errorf("shim does not mention %q", want) } } if !strings.HasPrefix(strings.TrimSpace(strings.SplitN(js, "\n(function", 2)[0]), "/*") { t.Error("shim should open with its contract comment") } if !strings.Contains(js, "(function () {") || !strings.Contains(js, "})();") { t.Error("shim should be a single IIFE") } if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") { t.Error("shim must stay CSP-clean") } // Light dismiss is delegated, not bound per element: a menu that // arrives after load has to be covered the moment it lands, and // re-scanning must never double-bind. A querySelectorAll over the // menus at load would be exactly that bug, so pin the listeners to // the document. if !strings.Contains(js, `document.addEventListener("click", dismissMenus, true)`) || !strings.Contains(js, `document.addEventListener("keydown", dismissMenus, true)`) { t.Error("light dismiss is not two delegated document listeners") } // Three mentions and no more: the definition and the two listeners. // A fourth would mean someone started binding it per element again. if n := strings.Count(js, "dismissMenus"); n != 3 { t.Errorf("dismissMenus appears %d times, want 3 (one definition, two document listeners)", n) } // The old sidebar drawer and the toggle-block stay out of it: neither // is a menu, and dismissing them on an outside click would fight the // user. (The topbar's Menu is in, above: it is a card that overlays // the page now, which is what light dismiss is for.) The check reads // the light-dismiss code, closeMenus through the two listeners: the // switch's drag names the toggle block's head, which drags like a // switch, and that is not light dismiss. reach, err := lightDismissReach(js) if err != nil { t.Fatal(err) } for _, bad := range reach { t.Errorf("light dismiss reaches for %q; it covers menus only", bad) } } // lightDismissReach returns which of the names light dismiss must never // touch appear in its code, which runs from the menu list, MENUS, with // its extensions, through menuAround and closeMenus to the two document // listeners; an error when that code is not where it expects. func lightDismissReach(js string) ([]string, error) { from, to := strings.Index(js, "var MENUS"), strings.Index(js, `document.addEventListener("keydown", dismissMenus, true)`) if from < 0 || to < from { return nil, fmt.Errorf("the light-dismiss code is not where this check expects it") } var out []string for _, bad := range []string{"rst-shell-chrome", "rst-tblock"} { if strings.Contains(js[from:to], bad) { out = append(out, bad) } } return out, nil } // TestTheLightDismissGateReadsTheMenuList (switch review 5): the gate // has to read the menu list, not only the functions after it. A drawer // or a toggle block spliced into MENUS makes closeMenus(null) close it, // and the functions' own text would never say so. The shipped shim is // the control. func TestTheLightDismissGateReadsTheMenuList(t *testing.T) { js := string(fiestaJS(t)) if reach, err := lightDismissReach(js); err != nil || len(reach) != 0 { t.Fatalf("the shipped shim: reach %v, err %v; want none", reach, err) } for _, m := range []struct{ name, bad string }{{"the drawer spliced into MENUS", "rst-shell-chrome"}, {"the toggle block spliced into MENUS", "rst-tblock"}} { mutant := strings.Replace(js, `var MENUS = "`, `var MENUS = "[`+m.bad+`][open],`, 1) if mutant == js { t.Fatal("MENUS is not where this test expects it") } reach, err := lightDismissReach(mutant) if err != nil { t.Fatal(err) } if len(reach) != 1 || reach[0] != m.bad { t.Errorf("%s: the gate reports %v; want %q, which closeMenus(null) would now close", m.name, reach, m.bad) } } } // fiesta.js is the plain-browser half of rastrillo.js: nothing in it // talks to a server. Polling, its push channel, the request headers // that marked a poll, the scheme cookie and any fetch stay in // rastrillo. func TestFiestaJSHasNoServerBehaviour(t *testing.T) { js := string(fiestaJS(t)) for _, bad := range []string{"data-poll", "EventSource", "Rastrillo-", "document.cookie", "fetch("} { if strings.Contains(js, bad) { t.Errorf("fiesta.js mentions %q; the server half stays in rastrillo", bad) } } } // 12 KiB: what is left of rastrillo.js once polling, its header and // the cookie are gone, with the scheme memory added (measured 12,144 // bytes: 6.4 KB of code, the rest comment). The point of the cap is the // same as rastrillo's: whoever links this file can read the whole of // it in one sitting. Past it, trim a comment before raising the cap. func TestFiestaJSIsSmall(t *testing.T) { js := fiestaJS(t) if n := len(js); n > 12*1024 { t.Fatalf("fiesta.js is %d bytes; the point is that a reader can take it in one sitting, trim it", n) } if bytes.Contains(js, []byte("\t")) { t.Error("fiesta.js uses two-space indentation, not tabs") } } // schemeRun is what one scenario in fiesta_node.mjs reports after // fiesta.js ran against its stub page and, where the scenario submits, // after the submit. type schemeRun struct { Theme string // data-theme on , "" for none Prevented bool // the submit's default was prevented Stored string // localStorage["fiesta-scheme"], "" for none Keys int // keys in localStorage StorageCalls int // calls on localStorage's methods Cookies int // writes to document.cookie Events []string // detail.scheme of each rst-scheme event dispatched Current string // the scheme whose button is aria-current Open bool // the menu is still open Error string // what the submit threw, "" for nothing Ran bool // the scenario wrote this report at all } // fiestaReport is fiesta_node.mjs's whole report, a scenario per field. type fiestaReport struct { Idempotent struct{ Once, Twice int } Standalone, System, Broken, Server, StoredOnLoad, ServerIgnoresStored schemeRun } // fiestaNode runs fiesta_node.mjs and decodes its report. func fiestaNode(t *testing.T) fiestaReport { t.Helper() var out fiestaReport if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/fiesta_node.mjs"}}), &out); err != nil { t.Fatal(err) } return out } // A second copy of fiesta.js on a page does nothing: the guard at the // top returns before a listener is added. Two evaluations in one // context register as many listeners as one, and one registers some. func TestFiestaJSIsIdempotent(t *testing.T) { got := fiestaNode(t).Idempotent if got.Once == 0 { t.Fatal("one evaluation registered no listeners") } if got.Twice != got.Once { t.Errorf("two evaluations registered %d listeners, one registered %d; the second copy is not inert", got.Twice, got.Once) } } // On a page that says it remembers, choosing Dark applies it, stores // it, writes no cookie, and tells the page once. func TestAStandaloneSubmitStoresTheScheme(t *testing.T) { got := fiestaNode(t).Standalone if got.Error != "" { t.Fatalf("the submit threw: %s", got.Error) } if got.Theme != "dark" || !got.Prevented || got.Stored != "dark" || got.Cookies != 0 { t.Errorf("after Dark: theme %q, prevented %v, stored %q, cookie writes %d; want dark, true, dark, 0", got.Theme, got.Prevented, got.Stored, got.Cookies) } if len(got.Events) != 1 || got.Events[0] != "dark" { t.Errorf("rst-scheme events %q, want one with detail.scheme dark", got.Events) } if got.Current != "dark" || got.Open { t.Errorf("after Dark: aria-current on %q and the menu open is %v; want dark and closed", got.Current, got.Open) } } // System is no attribute and no key: a stored Dark is removed, not // overwritten with "system". func TestSystemClearsTheStoredScheme(t *testing.T) { got := fiestaNode(t).System if got.Error != "" { t.Fatalf("the submit threw: %s", got.Error) } if got.Theme != "" || got.Keys != 0 || got.Stored != "" { t.Errorf("after System: theme %q, %d keys in storage, stored %q; want none, 0, none", got.Theme, got.Keys, got.Stored) } if len(got.Events) != 1 || got.Events[0] != "system" { t.Errorf("rst-scheme events %q, want one with detail.scheme system", got.Events) } if got.Current != "system" { t.Errorf("aria-current is on %q, want system", got.Current) } } // A browser with storage switched off throws on the first touch. The // switch still switches, and the page still hears about it. func TestABrokenStorageStillSwitches(t *testing.T) { got := fiestaNode(t).Broken if got.Error != "" { t.Fatalf("a storage that throws escaped the submit: %s", got.Error) } if got.Theme != "dark" || !got.Prevented || len(got.Events) != 1 { t.Errorf("after Dark on broken storage: theme %q, prevented %v, events %q; want dark, true, one", got.Theme, got.Prevented, got.Events) } } // Without the attribute the page is the server's, or has no memory: // the same attribute and event as standalone, and storage untouched. func TestAServerOwnedSubmitStoresNothing(t *testing.T) { got := fiestaNode(t).Server if got.Error != "" { t.Fatalf("the submit threw: %s", got.Error) } if got.Theme != "dark" || !got.Prevented || len(got.Events) != 1 || got.Events[0] != "dark" { t.Errorf("after Dark: theme %q, prevented %v, events %q; want dark, true, one dark", got.Theme, got.Prevented, got.Events) } if got.StorageCalls != 0 || got.Keys != 0 || got.Cookies != 0 { t.Errorf("a server-owned submit made %d storage calls, left %d keys and wrote %d cookies; want none", got.StorageCalls, got.Keys, got.Cookies) } } // With the attribute, a stored Dark and no data-theme, evaluating the // script sets data-theme synchronously, so a copy in without // defer restores before first paint; once the DOM is ready the Dark // button is current. func TestTheStoredSchemeIsAppliedOnLoad(t *testing.T) { got := fiestaNode(t).StoredOnLoad if got.Theme != "dark" { t.Errorf("after evaluating the script, data-theme is %q, want dark", got.Theme) } if got.Current != "dark" { t.Errorf("once the DOM is ready, aria-current is on %q, want dark", got.Current) } } // Without the attribute, a stored Dark means nothing: the root stays // without data-theme, since a missing data-theme is the server's // System. func TestAServerOwnedPageIgnoresTheStoredScheme(t *testing.T) { got := fiestaNode(t).ServerIgnoresStored // Every field this test wants is a zero value, which is also what // a scenario that went missing from the report decodes to. if !got.Ran { t.Fatal("the serverIgnoresStored scenario is not in fiesta_node.mjs's report") } if got.Theme != "" { t.Errorf("a server-owned page took data-theme %q from storage; want none", got.Theme) } if got.StorageCalls != 0 { t.Errorf("a server-owned page read storage %d times on load; want never", got.StorageCalls) } } // ── The other scripts' contracts (ui/shim_test.go) ────────────────── // The busy rule is a DEFAULT, and this is the assertion that says so in // Go rather than in a browser: one delegated submit listener on the // document, no scan over forms carrying an attribute, and the only // thing data-busy can now say is "false". // // The bug this catches is a quiet regression, not a crash: someone // restores the old `document.querySelectorAll("form[data-busy]")` scan, // every drive that plants data-busy on its form keeps passing, and every // app that never wrote the attribute silently loses the rule. func TestBusyRuleIsTheDefault(t *testing.T) { js := string(shippedScript(t, "busy.js")) if !strings.Contains(js, `document.addEventListener("submit", busySubmit, true)`) { t.Error("the busy rule is not one delegated capture-phase submit listener on the document") } // Three mentions and no more: the definition, the one listener, and // the window listener's late call for a data-live-form form its // server's script left to the browser (armed there because only then // is it known whether the script took it). A fourth would mean // someone started binding it per form again. if n := strings.Count(js, "busySubmit"); n != 3 { t.Errorf("busySubmit appears %d times, want 3 (one definition, one document listener, one late call for a data-live-form)", n) } if !strings.Contains(js, "busySubmit(e, true)") { t.Error("busy.js no longer arms a data-live-form form its server's script left to the browser") } // The opt-in scan is gone. Any querySelectorAll over a data-busy // selector is the old shape coming back. for _, bad := range []string{`"form[data-busy]"`, `"[data-busy]"`, "busyForm"} { if strings.Contains(js, bad) { t.Errorf("busy.js scans for %s; the busy rule is on by default, not opted into", bad) } } // data-busy survives only as an opt-out, on the form and on the // button. Both readings have to be there: a rule you can only turn // off for a whole form is not the rule the docs describe. if n := strings.Count(js, `getAttribute("data-busy") === "false"`); n != 2 { t.Errorf(`data-busy=="false" is tested %d times, want 2 (the form's opt-out and the button's)`, n) } // A form is [LegacyOverrideBuiltIns], so a control named "target" // shadows form.target with the input element — truthy, not "_self", // and the property form would silently switch the rule off for that // form. Every attribute the handler reads goes through getAttribute, // and the guard is the form's own aria-busy rather than an expando a // control could shadow (or, under strict mode, throw on). // Comments stripped first, or this gate would fire on the very // sentence in the shim that explains why the property read is wrong. code := js[strings.Index(js, "(function () {"):] var b strings.Builder for _, line := range strings.Split(code, "\n") { if strings.HasPrefix(strings.TrimSpace(line), "//") { continue } b.WriteString(line) b.WriteByte('\n') } code = b.String() for _, bad := range []string{"form.target", "form.method", ".formMethod", "form.rstBusy", ".rstBusy ="} { if strings.Contains(code, bad) { t.Errorf("the shim reads %s as a property; a control of that name shadows it — switching the rule off, or, for an assignment under strict mode, throwing", bad) } } if !strings.Contains(js, `form.getAttribute("target")`) { t.Error("the shim does not read the form's target through getAttribute") } if !strings.Contains(js, `if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; }`) { t.Error("the double-submit guard is not the form's own aria-busy attribute") } // A submit that goes NOWHERE must not be guarded, and there are // three shapes of it. target="_blank" leaves this page where it is; // so does
, the standard close button inside a // ; and so does