rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package check
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "regexp"
8 "slices"
9 "strings"
10 "testing"
11
12 "amadan.net/rastrillo/fiesta"
13 "amadan.net/rastrillo/fiesta/internal/nodetest"
14)
15
16// fiestaJS is js/fiesta.js as the tree ships it.
17func fiestaJS(t *testing.T) []byte {
18 t.Helper()
19 js, ok := fiesta.Script("fiesta.js")
20 if !ok {
21 t.Fatal("fiesta.js is not shipped")
22 }
23 return js
24}
25
26// The shim's behaviour is driven in a browser (scheme_browser_test.go)
27// and in Node (fiesta_node.mjs, below). What a Go test can hold honest
28// is the contract the docs promise: the vocabulary the file answers to,
29// its inert-by-default IIFE shape, and the absence of anything a CSP
30// would reject. Ported from rastrillo's ui/shim_test.go at the seed
31// commit: the polling strings are gone with the polling, and the cookie
32// strings are replaced by Fiesta's own contract, the event, the storage
33// key, the ownership attribute and the store.
34func TestShimContract(t *testing.T) {
35 js := string(fiestaJS(t))
36 for _, want := range []string{
37 // Light dismiss: the menu classes it answers to, the nested
38 // rst-menu-group among them, so a submenu is never left open
39 // behind its closing parent, the containment test that keeps the
40 // menu being used open, the Escape key, and the focus hand-back to
41 // the summary. Delegated on the document, so the count of
42 // addEventListener calls stays at two however many menus a page
43 // renders.
44 "rst-dropdown", "rst-menu-group", "rst-row-menu",
45 "closeMenus", "contains", "Escape", "summary.focus()",
46 // Both spellings. tokens.css pairs them for the window between
47 // upgrading the module and running `rastrillo markup`, and the
48 // shim has to pair them for the same window or an app's menus
49 // stop dismissing halfway through the upgrade we hand it.
50 `MENUS.replace(`,
51 // The topbar's and console's narrow Menu is a card over the page,
52 // so it is in the light-dismiss list, in both spellings written
53 // out (the rewrite above would give .rst-shell-menu, and the
54 // class is .rst-shell__menu), with its tail counted as inside it
55 // only while its summary is rendered. Scoped to a topbar or a
56 // console that names its view: an old console's Menu also
57 // reveals its rail, which must not close on a tap at a group
58 // label.
59 `[rst-shell-topbar] [rst-shell-menu][open],[rst-shell-console~=page] [rst-shell-menu][open],[rst-shell-console~=index] [rst-shell-menu][open]`,
60 `.rst-shell-topbar .rst-shell__menu[open],.rst-shell-console--page .rst-shell__menu[open],.rst-shell-console--index .rst-shell__menu[open]`,
61 `TAIL = "[rst-shell-tail],.rst-shell__tail"`, "menuAround", "getClientRects().length",
62 // The colour-scheme switch: the menu it answers to in both
63 // spellings, the attribute the themes read, the event a choice
64 // dispatches, the key it is remembered under, the attribute that
65 // says the page remembers, and the store it remembers in.
66 `"[rst-scheme],.rst-scheme"`, "data-theme",
67 `"rst-scheme"`, `"fiesta-scheme"`, "data-rst-scheme-store", "localStorage",
68 // The iOS touch listener: :active lands on a tap only when
69 // a touchstart listener exists, and this file owns it so an app
70 // that deletes light.js keeps its pressed states.
71 `addEventListener("touchstart", function () {}, { passive: true })`,
72 } {
73 if !strings.Contains(js, want) {
74 t.Errorf("shim does not mention %q", want)
75 }
76 }
77 if !strings.HasPrefix(strings.TrimSpace(strings.SplitN(js, "\n(function", 2)[0]), "/*") {
78 t.Error("shim should open with its contract comment")
79 }
80 if !strings.Contains(js, "(function () {") || !strings.Contains(js, "})();") {
81 t.Error("shim should be a single IIFE")
82 }
83 if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") {
84 t.Error("shim must stay CSP-clean")
85 }
86 // Light dismiss is delegated, not bound per element: a menu that
87 // arrives after load has to be covered the moment it lands, and
88 // re-scanning must never double-bind. A querySelectorAll over the
89 // menus at load would be exactly that bug, so pin the listeners to
90 // the document.
91 if !strings.Contains(js, `document.addEventListener("click", dismissMenus, true)`) ||
92 !strings.Contains(js, `document.addEventListener("keydown", dismissMenus, true)`) {
93 t.Error("light dismiss is not two delegated document listeners")
94 }
95 // Three mentions and no more: the definition and the two listeners.
96 // A fourth would mean someone started binding it per element again.
97 if n := strings.Count(js, "dismissMenus"); n != 3 {
98 t.Errorf("dismissMenus appears %d times, want 3 (one definition, two document listeners)", n)
99 }
100 // The old sidebar drawer and the toggle-block stay out of it: neither
101 // is a menu, and dismissing them on an outside click would fight the
102 // user. (The topbar's Menu is in, above: it is a card that overlays
103 // the page now, which is what light dismiss is for.) The check reads
104 // the light-dismiss code, closeMenus through the two listeners: the
105 // switch's drag names the toggle block's head, which drags like a
106 // switch, and that is not light dismiss.
107 reach, err := lightDismissReach(js)
108 if err != nil {
109 t.Fatal(err)
110 }
111 for _, bad := range reach {
112 t.Errorf("light dismiss reaches for %q; it covers menus only", bad)
113 }
114}
115
116// lightDismissReach returns which of the names light dismiss must never
117// touch appear in its code, which runs from the menu list, MENUS, with
118// its extensions, through menuAround and closeMenus to the two document
119// listeners; an error when that code is not where it expects.
120func lightDismissReach(js string) ([]string, error) {
121 from, to := strings.Index(js, "var MENUS"), strings.Index(js, `document.addEventListener("keydown", dismissMenus, true)`)
122 if from < 0 || to < from {
123 return nil, fmt.Errorf("the light-dismiss code is not where this check expects it")
124 }
125 var out []string
126 for _, bad := range []string{"rst-shell-chrome", "rst-tblock"} {
127 if strings.Contains(js[from:to], bad) {
128 out = append(out, bad)
129 }
130 }
131 return out, nil
132}
133
134// TestTheLightDismissGateReadsTheMenuList (switch review 5): the gate
135// has to read the menu list, not only the functions after it. A drawer
136// or a toggle block spliced into MENUS makes closeMenus(null) close it,
137// and the functions' own text would never say so. The shipped shim is
138// the control.
139func TestTheLightDismissGateReadsTheMenuList(t *testing.T) {
140 js := string(fiestaJS(t))
141 if reach, err := lightDismissReach(js); err != nil || len(reach) != 0 {
142 t.Fatalf("the shipped shim: reach %v, err %v; want none", reach, err)
143 }
144 for _, m := range []struct{ name, bad string }{{"the drawer spliced into MENUS", "rst-shell-chrome"}, {"the toggle block spliced into MENUS", "rst-tblock"}} {
145 mutant := strings.Replace(js, `var MENUS = "`, `var MENUS = "[`+m.bad+`][open],`, 1)
146 if mutant == js {
147 t.Fatal("MENUS is not where this test expects it")
148 }
149 reach, err := lightDismissReach(mutant)
150 if err != nil {
151 t.Fatal(err)
152 }
153 if len(reach) != 1 || reach[0] != m.bad {
154 t.Errorf("%s: the gate reports %v; want %q, which closeMenus(null) would now close", m.name, reach, m.bad)
155 }
156 }
157}
158
159// fiesta.js is the plain-browser half of rastrillo.js: nothing in it
160// talks to a server. Polling, its push channel, the request headers
161// that marked a poll, the scheme cookie and any fetch stay in
162// rastrillo.
163func TestFiestaJSHasNoServerBehaviour(t *testing.T) {
164 js := string(fiestaJS(t))
165 for _, bad := range []string{"data-poll", "EventSource", "Rastrillo-", "document.cookie", "fetch("} {
166 if strings.Contains(js, bad) {
167 t.Errorf("fiesta.js mentions %q; the server half stays in rastrillo", bad)
168 }
169 }
170}
171
172// 12 KiB: what is left of rastrillo.js once polling, its header and
173// the cookie are gone, with the scheme memory added (measured 12,144
174// bytes: 6.4 KB of code, the rest comment). The point of the cap is the
175// same as rastrillo's: whoever links this file can read the whole of
176// it in one sitting. Past it, trim a comment before raising the cap.
177func TestFiestaJSIsSmall(t *testing.T) {
178 js := fiestaJS(t)
179 if n := len(js); n > 12*1024 {
180 t.Fatalf("fiesta.js is %d bytes; the point is that a reader can take it in one sitting, trim it", n)
181 }
182 if bytes.Contains(js, []byte("\t")) {
183 t.Error("fiesta.js uses two-space indentation, not tabs")
184 }
185}
186
187// schemeRun is what one scenario in fiesta_node.mjs reports after
188// fiesta.js ran against its stub page and, where the scenario submits,
189// after the submit.
190type schemeRun struct {
191 Theme string // data-theme on <html>, "" for none
192 Prevented bool // the submit's default was prevented
193 Stored string // localStorage["fiesta-scheme"], "" for none
194 Keys int // keys in localStorage
195 StorageCalls int // calls on localStorage's methods
196 Cookies int // writes to document.cookie
197 Events []string // detail.scheme of each rst-scheme event dispatched
198 Current string // the scheme whose button is aria-current
199 Open bool // the menu is still open
200 Error string // what the submit threw, "" for nothing
201 Ran bool // the scenario wrote this report at all
202}
203
204// fiestaReport is fiesta_node.mjs's whole report, a scenario per field.
205type fiestaReport struct {
206 Idempotent struct{ Once, Twice int }
207 Standalone, System, Broken, Server, StoredOnLoad, ServerIgnoresStored schemeRun
208}
209
210// fiestaNode runs fiesta_node.mjs and decodes its report.
211func fiestaNode(t *testing.T) fiestaReport {
212 t.Helper()
213 var out fiestaReport
214 if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/fiesta_node.mjs"}}), &out); err != nil {
215 t.Fatal(err)
216 }
217 return out
218}
219
220// A second copy of fiesta.js on a page does nothing: the guard at the
221// top returns before a listener is added. Two evaluations in one
222// context register as many listeners as one, and one registers some.
223func TestFiestaJSIsIdempotent(t *testing.T) {
224 got := fiestaNode(t).Idempotent
225 if got.Once == 0 {
226 t.Fatal("one evaluation registered no listeners")
227 }
228 if got.Twice != got.Once {
229 t.Errorf("two evaluations registered %d listeners, one registered %d; the second copy is not inert", got.Twice, got.Once)
230 }
231}
232
233// On a page that says it remembers, choosing Dark applies it, stores
234// it, writes no cookie, and tells the page once.
235func TestAStandaloneSubmitStoresTheScheme(t *testing.T) {
236 got := fiestaNode(t).Standalone
237 if got.Error != "" {
238 t.Fatalf("the submit threw: %s", got.Error)
239 }
240 if got.Theme != "dark" || !got.Prevented || got.Stored != "dark" || got.Cookies != 0 {
241 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)
242 }
243 if len(got.Events) != 1 || got.Events[0] != "dark" {
244 t.Errorf("rst-scheme events %q, want one with detail.scheme dark", got.Events)
245 }
246 if got.Current != "dark" || got.Open {
247 t.Errorf("after Dark: aria-current on %q and the menu open is %v; want dark and closed", got.Current, got.Open)
248 }
249}
250
251// System is no attribute and no key: a stored Dark is removed, not
252// overwritten with "system".
253func TestSystemClearsTheStoredScheme(t *testing.T) {
254 got := fiestaNode(t).System
255 if got.Error != "" {
256 t.Fatalf("the submit threw: %s", got.Error)
257 }
258 if got.Theme != "" || got.Keys != 0 || got.Stored != "" {
259 t.Errorf("after System: theme %q, %d keys in storage, stored %q; want none, 0, none", got.Theme, got.Keys, got.Stored)
260 }
261 if len(got.Events) != 1 || got.Events[0] != "system" {
262 t.Errorf("rst-scheme events %q, want one with detail.scheme system", got.Events)
263 }
264 if got.Current != "system" {
265 t.Errorf("aria-current is on %q, want system", got.Current)
266 }
267}
268
269// A browser with storage switched off throws on the first touch. The
270// switch still switches, and the page still hears about it.
271func TestABrokenStorageStillSwitches(t *testing.T) {
272 got := fiestaNode(t).Broken
273 if got.Error != "" {
274 t.Fatalf("a storage that throws escaped the submit: %s", got.Error)
275 }
276 if got.Theme != "dark" || !got.Prevented || len(got.Events) != 1 {
277 t.Errorf("after Dark on broken storage: theme %q, prevented %v, events %q; want dark, true, one", got.Theme, got.Prevented, got.Events)
278 }
279}
280
281// Without the attribute the page is the server's, or has no memory:
282// the same attribute and event as standalone, and storage untouched.
283func TestAServerOwnedSubmitStoresNothing(t *testing.T) {
284 got := fiestaNode(t).Server
285 if got.Error != "" {
286 t.Fatalf("the submit threw: %s", got.Error)
287 }
288 if got.Theme != "dark" || !got.Prevented || len(got.Events) != 1 || got.Events[0] != "dark" {
289 t.Errorf("after Dark: theme %q, prevented %v, events %q; want dark, true, one dark", got.Theme, got.Prevented, got.Events)
290 }
291 if got.StorageCalls != 0 || got.Keys != 0 || got.Cookies != 0 {
292 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)
293 }
294}
295
296// With the attribute, a stored Dark and no data-theme, evaluating the
297// script sets data-theme synchronously, so a copy in <head> without
298// defer restores before first paint; once the DOM is ready the Dark
299// button is current.
300func TestTheStoredSchemeIsAppliedOnLoad(t *testing.T) {
301 got := fiestaNode(t).StoredOnLoad
302 if got.Theme != "dark" {
303 t.Errorf("after evaluating the script, data-theme is %q, want dark", got.Theme)
304 }
305 if got.Current != "dark" {
306 t.Errorf("once the DOM is ready, aria-current is on %q, want dark", got.Current)
307 }
308}
309
310// Without the attribute, a stored Dark means nothing: the root stays
311// without data-theme, since a missing data-theme is the server's
312// System.
313func TestAServerOwnedPageIgnoresTheStoredScheme(t *testing.T) {
314 got := fiestaNode(t).ServerIgnoresStored
315 // Every field this test wants is a zero value, which is also what
316 // a scenario that went missing from the report decodes to.
317 if !got.Ran {
318 t.Fatal("the serverIgnoresStored scenario is not in fiesta_node.mjs's report")
319 }
320 if got.Theme != "" {
321 t.Errorf("a server-owned page took data-theme %q from storage; want none", got.Theme)
322 }
323 if got.StorageCalls != 0 {
324 t.Errorf("a server-owned page read storage %d times on load; want never", got.StorageCalls)
325 }
326}
327
328// ── The other scripts' contracts (ui/shim_test.go) ──────────────────
329
330// The busy rule is a DEFAULT, and this is the assertion that says so in
331// Go rather than in a browser: one delegated submit listener on the
332// document, no scan over forms carrying an attribute, and the only
333// thing data-busy can now say is "false".
334//
335// The bug this catches is a quiet regression, not a crash: someone
336// restores the old `document.querySelectorAll("form[data-busy]")` scan,
337// every drive that plants data-busy on its form keeps passing, and every
338// app that never wrote the attribute silently loses the rule.
339func TestBusyRuleIsTheDefault(t *testing.T) {
340 js := string(shippedScript(t, "busy.js"))
341 if !strings.Contains(js, `document.addEventListener("submit", busySubmit, true)`) {
342 t.Error("the busy rule is not one delegated capture-phase submit listener on the document")
343 }
344 // Three mentions and no more: the definition, the one listener, and
345 // the window listener's late call for a data-live-form form its
346 // server's script left to the browser (armed there because only then
347 // is it known whether the script took it). A fourth would mean
348 // someone started binding it per form again.
349 if n := strings.Count(js, "busySubmit"); n != 3 {
350 t.Errorf("busySubmit appears %d times, want 3 (one definition, one document listener, one late call for a data-live-form)", n)
351 }
352 if !strings.Contains(js, "busySubmit(e, true)") {
353 t.Error("busy.js no longer arms a data-live-form form its server's script left to the browser")
354 }
355 // The opt-in scan is gone. Any querySelectorAll over a data-busy
356 // selector is the old shape coming back.
357 for _, bad := range []string{`"form[data-busy]"`, `"[data-busy]"`, "busyForm"} {
358 if strings.Contains(js, bad) {
359 t.Errorf("busy.js scans for %s; the busy rule is on by default, not opted into", bad)
360 }
361 }
362 // data-busy survives only as an opt-out, on the form and on the
363 // button. Both readings have to be there: a rule you can only turn
364 // off for a whole form is not the rule the docs describe.
365 if n := strings.Count(js, `getAttribute("data-busy") === "false"`); n != 2 {
366 t.Errorf(`data-busy=="false" is tested %d times, want 2 (the form's opt-out and the button's)`, n)
367 }
368 // A form is [LegacyOverrideBuiltIns], so a control named "target"
369 // shadows form.target with the input element — truthy, not "_self",
370 // and the property form would silently switch the rule off for that
371 // form. Every attribute the handler reads goes through getAttribute,
372 // and the guard is the form's own aria-busy rather than an expando a
373 // control could shadow (or, under strict mode, throw on).
374 // Comments stripped first, or this gate would fire on the very
375 // sentence in the shim that explains why the property read is wrong.
376 code := js[strings.Index(js, "(function () {"):]
377 var b strings.Builder
378 for _, line := range strings.Split(code, "\n") {
379 if strings.HasPrefix(strings.TrimSpace(line), "//") {
380 continue
381 }
382 b.WriteString(line)
383 b.WriteByte('\n')
384 }
385 code = b.String()
386 for _, bad := range []string{"form.target", "form.method", ".formMethod", "form.rstBusy", ".rstBusy ="} {
387 if strings.Contains(code, bad) {
388 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)
389 }
390 }
391 if !strings.Contains(js, `form.getAttribute("target")`) {
392 t.Error("the shim does not read the form's target through getAttribute")
393 }
394 if !strings.Contains(js, `if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; }`) {
395 t.Error("the double-submit guard is not the form's own aria-busy attribute")
396 }
397 // A submit that goes NOWHERE must not be guarded, and there are
398 // three shapes of it. target="_blank" leaves this page where it is;
399 // so does <form method="dialog">, the standard close button inside a
400 // <dialog>; and so does <button formmethod="dialog"> in an ordinary
401 // form, the same close reached through the submitter's own
402 // attribute. The dialog shapes are the worse: nothing ever navigates
403 // or reloads afterwards, so a guard armed there is never cleared —
404 // the close button ends up disabled and the dialog cannot be closed
405 // through that form again, ever.
406 //
407 // One effective method, computed once, with the submitter's
408 // formmethod beating the form's method the way HTML says it does.
409 // Both read through getAttribute — never form.method or
410 // btn.formMethod — for the [LegacyOverrideBuiltIns] reason above,
411 // and matched case-insensitively because method is an enumerated
412 // attribute.
413 for _, want := range []string{
414 `if (/^dialog$/i.test(btn && btn.getAttribute("formmethod") ||`,
415 ` form.getAttribute("method"))) return;`,
416 } {
417 if !strings.Contains(js, want) {
418 t.Errorf(`the busy rule does not bail out of an effective method of "dialog" (%s missing); a dialog's close form — or a button's formmethod="dialog" — arms a guard nothing will ever clear`, want)
419 }
420 }
421 // The submitter has to be in hand BEFORE the guard runs, or the
422 // effective method cannot see it and the bail reads the form's
423 // method alone.
424 if strings.Index(js, " var btn = e.submitter;") > strings.Index(js, ` if (/^dialog$/i.test(`) {
425 t.Error("the submitter is read after the dialog bail; the bail cannot see formmethod")
426 }
427 // The reset has to reach a submit button that belongs to the form
428 // through form="id" while living somewhere else in the document — a
429 // sticky header's Save. busySubmit busies it (it is the submit
430 // event's submitter, wherever it sits), so a reset that swept the
431 // form's own subtree left it disabled and spinning after every
432 // bfcache restore, for good. Sweep the document and test ownership.
433 if strings.Contains(js, `form.querySelectorAll('[aria-busy="true"]')`) {
434 t.Error(`busyOff sweeps the form's descendants; a submit button associated through form="id" is not one, and never comes back`)
435 }
436 if !strings.Contains(js, `if (b.form !== form && !form.contains(b)) return;`) {
437 t.Error("busyOff does not test ownership, so it would clear busy state belonging to another form")
438 }
439 // The payload trap, pinned where a reader will trip over it: the
440 // two mutations that can change what the server receives — disabled
441 // and an <input type="submit">'s value — happen inside the deferred
442 // callback, never in the synchronous part of the handler.
443 sync, deferred, ok := strings.Cut(js, " setTimeout(function () {")
444 if !ok {
445 t.Fatal("the busy handler no longer defers anything; disabled must not be set during the submit event")
446 }
447 sync = sync[strings.Index(sync, "function busySubmit"):]
448 for _, bad := range []string{"btn.disabled", "btn.value ="} {
449 if strings.Contains(sync, bad) {
450 t.Errorf("%s runs synchronously in the submit handler; it drops the button's name/value from the payload", bad)
451 }
452 if !strings.Contains(deferred, bad) {
453 t.Errorf("%s is not in the deferred callback; the busy state never hardens", bad)
454 }
455 }
456}
457
458// select.js holds to the same contract as the shim, and to the same
459// reason for existing: small enough that the app owner who now owns it
460// can read the whole thing.
461func TestSelectContract(t *testing.T) {
462 js := string(shippedScript(t, "select.js"))
463 for _, want := range []string{
464 "data-rst-select", "data-rst-select-filter",
465 "data-rst-select-results", "data-rst-select-result-one",
466 // The convention, pinned: an ARIA combobox that mirrors rather
467 // than replaces, announced to assistive tech.
468 "combobox", "aria-activedescendant", "aria-expanded", "aria-live",
469 "rst-sr-only",
470 } {
471 if !strings.Contains(js, want) {
472 t.Errorf("select.js does not mention %q", want)
473 }
474 }
475 // The whole point: the native control survives enhancement, because
476 // it is what the form submits. The one thing select.js does remove is
477 // a row it drew itself (a page's extra suggestions, replaced), so the
478 // fence is on the native select and the markup around it.
479 for _, bad := range []string{"native.remove()", "removeChild(native", "outerHTML =", "innerHTML ="} {
480 if strings.Contains(js, bad) {
481 t.Errorf("select.js destroys DOM (%q); the native select must survive", bad)
482 }
483 }
484 // Inert by default, and re-scannable.
485 if !strings.Contains(js, "rstEnhanced") {
486 t.Error("select.js is not idempotent; re-scanning would double-enhance")
487 }
488 // The markup-side opt-out, and the groups.
489 if !strings.Contains(js, `!== "false"`) {
490 t.Error(`select.js does not honour data-rst-select="false"; a hand-written select cannot opt out`)
491 }
492 for _, want := range []string{"OPTGROUP", `"group"`, "rst-select-group"} {
493 if !strings.Contains(js, want) {
494 t.Errorf("select.js does not mention %q; a grouped select would be flattened", want)
495 }
496 }
497 // Raised from 8KB to 12KB, once, by optgroups and the markup
498 // opt-out. Flattening native.options was a line shorter and silently
499 // threw away the headings an author wrote to make a long list
500 // readable; rendering the groups costs a nested list, an ARIA group
501 // per optgroup, and a filter that hides a heading when its rows all
502 // go. That is the trade, and it is a decision, not a drift.
503 //
504 // Raised again, to 37KB (measured 36,296 bytes), by the convergence
505 // with Tito Go's searchselect (2026-09-26): ranking that puts the row
506 // someone meant first (accents folded, ISO and calling codes as
507 // terms), prompts that are never picks, a list that keeps pace with
508 // fast typing by moving only what a search shows, settle-on-leave,
509 // dividers, compact boxes and borrowed validity. Splitting the pure
510 // ranking into its own file was tried and rejected: importing it makes
511 // this an ES module, which every app's classic <script defer> tag and
512 // the design system's preview files would have to change for, and an
513 // app that re-vendored without editing its layout would lose the
514 // enhancement silently. Most of the growth is the why-comments, which
515 // are what let the app owner who now owns it read it in one sitting.
516 // The headroom is about what the old 12KB ceiling had: room for a fix
517 // and its why, not for a feature.
518 //
519 // Then to 47KB by the alignment with Tito's final searchselect (titogo
520 // 9d21ce1): the box steps aside when a page replaces the select
521 // outright, and rebuilds itself when a page changes the options —
522 // without both it showed one pick while the form posted another — and,
523 // from Paul's iPhone, the list opens upward when the keyboard leaves no
524 // room below, with layout read once a frame. Measured 48,035 bytes after
525 // rows moved from the hidden attribute to inline display (titogo
526 // #3145); comments were trimmed to stay inside rather than raise it.
527 //
528 // Then to 48KB (measured 48,573 bytes) by rst:select-scan, the one way
529 // a page that adds content after load gets its selects enhanced (Tito's
530 // modal forms need it). The header was trimmed first; the ranking
531 // comments were not, because they are kept in step with Tito's.
532 //
533 // Then to 50KB (measured 51,096 bytes) by placement in frames: a band a
534 // host tells the frame (rst:select-viewport), floors under sticky bars
535 // (data-rst-select-floor), the list's padding and borders counted
536 // against the room, and the gap re-read — Tito Go #3146's rule, which
537 // the checkout widget's iPhone keyboard needed.
538 //
539 // Then to 51KB (measured 52,098 bytes) by data-rst-class, a row and the
540 // closed box styled per option: Tito Go's font menus set each name in
541 // its own face. Comments were trimmed to the one that says why typing
542 // takes the classes off.
543 //
544 // Then to 52KB (measured 53,128 bytes) by selecting the pick on a
545 // focusing click as well as on focus: WebKit put the caret where a
546 // click landed after focus, so clearing "Dublin" took a double-click.
547 // The comment is the one that says why each pointer selects when.
548 //
549 // Then to 54KB (measured 54,601 bytes) by the string tables: the
550 // file sat 120 bytes under the cap, and fiestaPickLang and
551 // fiestaWord, the lookup that reads a label off the attribute, then
552 // the page's table, then the fallback, added 1,473 net. The same
553 // bytes went into datetime.js, whose cap moved with them; see
554 // TestTheTwoLookupsAreTheSame. 53KB does not hold it; 54KB does,
555 // with 695 to spare.
556 //
557 // Then to 57KB (measured 57,787 bytes) by the placement fix synced
558 // from rastrillo: the room is cut only by the ancestors that can
559 // clip the list, its chain of containing blocks, held to CSS's own
560 // rules (paint containment and content-visibility, will-change's
561 // triggers by each property's rule, the top layer, slots, table
562 // rows and ruby skipped, aliases only where the engine has them),
563 // and data-rst-select-ceiling, floor's twin for a sticky header. A
564 // list fixed or anchored out of a bar that scrolls sideways had
565 // opened 0px tall. Rastrillo holds the same code at 55KB; the
566 // string tables above are the 1,473 bytes between the two caps.
567 //
568 // Then to 59KB (measured 59,673 bytes, +1,886) by the native
569 // state: a disabled select's box is disabled with it and refuses
570 // every row, an option under a disabled <optgroup> is off limits,
571 // a disabled <fieldset> is read at each choice, and a form reset
572 // repaints the box a task later. 58KB (59,392) does not hold it;
573 // 59KB (60,416) does, with 743 to spare.
574 if n := len(js); n > 59*1024 {
575 t.Fatalf("select.js is %d bytes; it is split out of the shim precisely to stay readable — trim it", n)
576 }
577 if strings.Contains(js, "\t") {
578 t.Error("select.js uses two-space indentation, not tabs")
579 }
580}
581
582// transition.js is the one script a page loads before its stylesheets,
583// so it holds to the others' shape and stays small, and it puts nothing
584// on screen: no words to look up, no attribute or text written. It
585// listens for the two events a view transition arrives on and nothing
586// else.
587func TestTransitionContract(t *testing.T) {
588 js := string(shippedScript(t, "transition.js"))
589 if !strings.HasPrefix(strings.TrimSpace(js), "/*") || !strings.Contains(js, "(function () {") || !strings.HasSuffix(strings.TrimSpace(js), "})();") {
590 t.Error("transition.js should be its contract comment and a single IIFE")
591 }
592 if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") || strings.Contains(js, "\t") {
593 t.Error("transition.js must stay CSP-clean and use two-space indentation")
594 }
595 for _, screen := range []string{"fiestaWord", "fiestaStrings", "data-rst-", "textContent", "setAttribute", "innerHTML"} {
596 if strings.Contains(js, screen) {
597 t.Errorf("transition.js mentions %s; it puts nothing on screen", screen)
598 }
599 }
600 listens := regexp.MustCompile(`addEventListener\("([a-z]+)"`).FindAllStringSubmatch(js, -1)
601 var events []string
602 for _, m := range listens {
603 events = append(events, m[1])
604 }
605 if !slices.Equal(events, []string{"pageswap", "pagereveal"}) {
606 t.Errorf("transition.js listens for %v, want pageswap and pagereveal only", events)
607 }
608 if n := len(js); n > 1024 {
609 t.Errorf("transition.js is %d bytes; it blocks the first render, so keep it under 1 KiB", n)
610 }
611}
612
613// No shipped script may reach off-origin: every one fiesta.ScriptNames
614// lists is vendored, first-party and dependency-free. The count is
615// pinned so a list that lost its names could not pass by naming none.
616func TestScriptsAreSelfContained(t *testing.T) {
617 names := fiesta.ScriptNames()
618 if len(names) != 10 {
619 t.Fatalf("fiesta.ScriptNames() lists %d scripts, want 10: %v", len(names), names)
620 }
621 for _, name := range names {
622 js := string(shippedScript(t, name))
623 for _, bad := range []string{"http://", "https://", "import ", "require(", "//cdn"} {
624 if strings.Contains(js, bad) {
625 t.Errorf("%s reaches outside the page (%q)", name, bad)
626 }
627 }
628 }
629}
630
631// anchor.js is the one script an app is told to delete in time, so its
632// contract is that it is inert where it is not needed: the feature test
633// returns before any listener is added, and the class it writes is the
634// one tokens.css opens a menu from. Small, like the others.
635func TestAnchorContract(t *testing.T) {
636 js := string(shippedScript(t, "anchor.js"))
637 test, listen := strings.Index(js, `CSS.supports("position-area: block-end")`), strings.Index(js, "addEventListener")
638 if test < 0 || listen < 0 || test > listen {
639 t.Error("anchor.js must test for anchor positioning, and return, before it listens for anything")
640 }
641 if !strings.Contains(js, "is-flip-inline") || !slices.Contains(ruleSelectors(t, string(fiesta.TokensCSS())), "[rst-dropdown].is-flip-inline > [rst-dropdown-menu]") {
642 t.Error("anchor.js and tokens.css no longer agree on .is-flip-inline")
643 }
644 if n := len(js); n > 4*1024 {
645 t.Errorf("anchor.js is %d bytes; it does one thing, keep it small", n)
646 }
647 if strings.Contains(js, "\t") {
648 t.Error("anchor.js uses two-space indentation, not tabs")
649 }
650}
651
652// busy.js was split out of the shim when the hold was added (the shim
653// sat 20 bytes under its cap). Its contract, held the same way: the
654// vocabulary, the spinner and submitter, the cancelled-submit hand-back,
655// the hold (its duration, the window listener that runs last, the
656// re-submit that keeps the submitter), and Back in both directions.
657func TestBusyContract(t *testing.T) {
658 js := string(shippedScript(t, "busy.js"))
659 for _, want := range []string{
660 "data-busy", "data-busy-label",
661 `spin.setAttribute("rst-spin", "")`, "e.submitter", "defaultPrevented",
662 "HOLD_MS = 650", `window.addEventListener("submit"`, "requestSubmit(btn || undefined)",
663 "held.has(e)", "form === releasing", "checkValidity()",
664 "pagehide", "clearTimeout", "pageshow", "e.persisted",
665 // Review fixes: an opted-out button is never left disabled, and a
666 // submitter that left the form during the hold hands the form back.
667 "btn.disabled = wasDisabled", "btn.form !== form", `getAttribute("formtarget")`,
668 // Only a submit with a spinner showing is held.
669 `btn.getAttribute("aria-busy") !== "true") return;`,
670 // A data-live-form form is its server's script's when the script
671 // sends it, so it is armed late, and only if the script did not
672 // claim it (e.liveForm) in either script order
673 // (TestBusyLeavesALiveFormItsScriptClaimed drives it).
674 `form.hasAttribute("data-live-form") && !late`,
675 `e.defaultPrevented || e.liveForm) return;`,
676 } {
677 if !strings.Contains(js, want) {
678 t.Errorf("busy.js does not mention %q", want)
679 }
680 }
681 if !strings.HasPrefix(strings.TrimSpace(js), "/*") || !strings.Contains(js, "(function () {") || !strings.HasSuffix(strings.TrimSpace(js), "})();") {
682 t.Error("busy.js should be its contract comment and a single IIFE")
683 }
684 if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") || strings.Contains(js, "\t") {
685 t.Error("busy.js must stay CSP-clean and use two-space indentation")
686 }
687 if n := len(js); n > 16*1024 {
688 t.Fatalf("busy.js is %d bytes; keep it readable in one sitting", n)
689 }
690}
691
692// shell.js holds to the contract every scaffolded script does, with an
693// 8 KiB cap of its own (busy.js's precedent): the three behaviours it
694// exists for, named; both spellings of what it reads; the storage guard;
695// the prerender and pageswap facts its ordering depends on.
696func TestShellContract(t *testing.T) {
697 js := string(shippedScript(t, "shell.js"))
698 for _, want := range []string{
699 // 1. Direction.
700 "pagereveal", "viewTransition.types.add", `"back"`, `"forward"`, "navigationType", `"traverse"`,
701 // 2. History reuse, only when proven. The Navigation API is read
702 // through a local (nav), so the call is spelled nav.entries().
703 "history.back()", "nav.entries()", "sameDocument", "e.button !== 0", "defaultPrevented",
704 // 3. Focus return, record first, never while prerendering.
705 "rst-shell-return", "pageswap", "pagehide", "document.prerendering", "decodeURIComponent", "sessionStorage",
706 // Both spellings.
707 "[rst-shell-back]", ".rst-shell__back", `[rst-shell-sidebar~="index"]`, ".rst-shell-sidebar--index",
708 `[rst-shell-console~="page"]`, ".rst-shell-console--page",
709 } {
710 if !strings.Contains(js, want) {
711 t.Errorf("shell.js does not mention %q", want)
712 }
713 }
714 if !strings.HasPrefix(strings.TrimSpace(js), "/*") || !strings.Contains(js, "(function () {") || !strings.HasSuffix(strings.TrimSpace(js), "})();") {
715 t.Error("shell.js should be its contract comment and a single IIFE")
716 }
717 if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") || strings.Contains(js, "\t") {
718 t.Error("shell.js must stay CSP-clean and use two-space indentation")
719 }
720 if n := len(js); n > 8*1024 {
721 t.Fatalf("shell.js is %d bytes; keep it readable in one sitting (8 KiB)", n)
722 }
723}
724
725// copy.js holds to the contract every scaffolded script does, with the
726// promise its header makes: where there is no clipboard API it returns
727// before it listens for anything, so the buttons stay hidden rather
728// than showing a button that does nothing. It reads the button by its
729// attribute and owns one guard on the window, and it carries the string
730// lookup select.js carries, byte for byte, so an attribute, a page's
731// table and the English fall back in the same order in every script.
732func TestCopyContract(t *testing.T) {
733 js := string(shippedScript(t, "copy.js"))
734 listen := strings.Index(js, "addEventListener")
735 test := strings.Index(js, "typeof navigator.clipboard?.writeText")
736 if test < 0 {
737 test = strings.Index(js, "navigator.clipboard && navigator.clipboard.writeText")
738 }
739 if test < 0 || listen < 0 || test > listen {
740 t.Error("copy.js must test for navigator.clipboard.writeText, and return, before it listens for anything")
741 }
742 for _, want := range []string{"rst-snippet-copy", "fiestaCopy"} {
743 if !strings.Contains(js, want) {
744 t.Errorf("copy.js does not mention %q", want)
745 }
746 }
747 sel := string(shippedScript(t, "select.js"))
748 for _, name := range []string{"fiestaPickLang", "fiestaWord"} {
749 mine, theirs := functionBody(js, name), functionBody(sel, name)
750 if mine == "" {
751 t.Errorf("copy.js does not carry %s", name)
752 } else if mine != theirs {
753 t.Errorf("%s differs between copy.js and select.js:\n--- copy.js\n%s\n--- select.js\n%s", name, mine, theirs)
754 }
755 }
756 if !strings.HasPrefix(strings.TrimSpace(js), "/*") || !strings.Contains(js, "(function () {") || !strings.HasSuffix(strings.TrimSpace(js), "})();") {
757 t.Error("copy.js should be its contract comment and a single IIFE")
758 }
759 if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") || strings.Contains(js, "\t") {
760 t.Error("copy.js must stay CSP-clean and use two-space indentation")
761 }
762 if n := len(js); n > 8*1024 {
763 t.Fatalf("copy.js is %d bytes; it does one thing, keep it readable in one sitting (8 KiB)", n)
764 }
765}
766