rastrillo / fiesta Public

Take Rastrillo's shell.js and busy.js fixes, each held by its own test

shell.js swallows a skipped view transition's rejection at startup, on
pagereveal and on pageswap, and lets every other error through
(Rastrillo d9c9ed13, 023740c5, e5e35a3e, 6b1c5478). busy.js arms a
data-live-form form only once its server's script has left it to the
browser, so it no longer wipes the busy state of a form the script
sends itself (Rastrillo 8548e455). Both files are Rastrillo's bytes.

TestOneOhScriptsAreUnchanged lets each through by a changedOneOhScripts
entry naming the SHA-256 of the fixed file, why, and the test that
holds it, so a further change fails again. The photographs serve one
js/ to both sides and cannot see a script change, so each fix has its
own test: check/node/transition_node.mjs, ported from Rastrillo's
shell_transition_node.mjs, for shell.js, and
TestBusyLeavesALiveFormItsScriptClaimed, a browser drive with a stand-in
for the server's script in either load order, for busy.js.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e3c46ab30bb81ed3f3085a48ec93f6e01d0ae446 parent ce96683
10 files changed, +387 −31
  • check/liveform_browser_test.go +120 −0
  • check/node/transition_node.mjs +96 −0
  • check/scripts_test.go +16 −4
  • check/shell_test.go +26 −0
  • js/busy.js +18 −1
  • js/shell.js +27 −16
  • releases/1.2.0/SHA256SUMS +2 −2
  • releases/1.2.0/busy.js +6 −1
  • releases/1.2.0/shell.js +17 −1
  • releases_test.go +59 −6
diff --git a/check/liveform_browser_test.go b/check/liveform_browser_test.go
new file mode 100644
index 0000000..c05707a
--- /dev/null
+++ b/check/liveform_browser_test.go
@@ -0,0 +1,120 @@
+//go:build browser
+
+package check
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/chromedp/chromedp"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/internal/harness"
+)
+
+// A server that swaps forms in place marks them data-live-form, and its
+// own script decides on each submit whether to send the form itself or
+// leave it to the browser. 1.2.0's busy.js (Rastrillo 8548e455) arms
+// such a form only once that script has let it go: one the script
+// claimed is the script's to show busy, and arming it as well would hand
+// it back a tick after the script cancelled the submit, wiping the
+// script's busy state mid-request. One left to the browser keeps the
+// busy rule, whichever script loaded first.
+
+// liveStub stands in for the server's script, as Rastrillo's live.js
+// behaves: a capture listener marks the submit of a form it would send
+// (e.liveForm), and a later listener cancels it and shows its own busy
+// state, with no spinner, so the state is told apart from busy.js's. A
+// form whose data-live-form is "native" is left to the browser.
+const liveStub = `(function () {
+ "use strict";
+ document.addEventListener("submit", function (e) {
+ if (e.target.getAttribute("data-live-form") === "claimed") e.liveForm = true;
+ }, true);
+ document.addEventListener("submit", function (e) {
+ if (!e.liveForm || e.defaultPrevented) return;
+ e.preventDefault();
+ e.target.setAttribute("aria-busy", "true");
+ if (e.submitter) e.submitter.setAttribute("aria-busy", "true");
+ });
+})();`
+
+// liveFormPage is the two forms under busy.js and the stub, loaded in
+// the order given.
+func liveFormPage(scripts ...string) string {
+ var head strings.Builder
+ for _, name := range scripts {
+ head.WriteString(`<script defer src="/` + name + `"></script>`)
+ }
+ return `<!doctype html><html lang="en" dir="ltr"><head><meta charset="utf-8">` +
+ `<title>live forms</title><link rel="stylesheet" href="/tokens.css">` +
+ `<link rel="stylesheet" href="/theme.css">` + head.String() + `</head><body>` +
+ `<form method="post" action="/claimed" data-live-form="claimed">` +
+ `<button rst-btn id="claimed">Save</button></form>` +
+ `<form method="post" action="/native" data-live-form="native">` +
+ `<input type="hidden" name="form" value="native">` +
+ `<button rst-btn id="native">Save</button></form>` +
+ `</body></html>`
+}
+
+// liveFormState is a button's busy state: its aria-busy and whether
+// busy.js's spinner is in it.
+func liveFormState(id string) string {
+ return `(() => { const button = document.getElementById(` + jsStr(id) + `);` +
+ ` return "busy:" + (button.getAttribute("aria-busy") || "-") +` +
+ ` " spin:" + (button.querySelector("[rst-spin]") ? "yes" : "no"); })()`
+}
+
+func TestBusyLeavesALiveFormItsScriptClaimed(t *testing.T) {
+ pages := map[string]string{
+ "/busy-first": liveFormPage("busy.js", "live-stub.js"),
+ "/stub-first": liveFormPage("live-stub.js", "busy.js"),
+ }
+ scripts := func(name string) ([]byte, bool) {
+ if name == "live-stub.js" {
+ return []byte(liveStub), true
+ }
+ return fiesta.Script(name)
+ }
+ payloads := make(chan string, 8)
+ rig := harness.New(t, func(string) http.Handler {
+ mux := sizingMuxWith(t, pages, scripts)
+ received(mux, "/claimed", "", payloads, http.StatusNoContent)
+ received(mux, "/native", "", payloads, http.StatusNoContent)
+ return mux
+ })
+ ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
+ defer cancel()
+
+ for _, order := range []string{"/busy-first", "/stub-first"} {
+ // Claimed: the stub's busy state is still there after busy.js's
+ // hand-back tick would have run, with no spinner of busy.js's,
+ // and nothing reached the server.
+ var claimed string
+ mustRun(t, ctx, chromedp.Navigate(rig.Origin+order), chromedp.WaitVisible("#claimed", chromedp.ByQuery),
+ chromedp.Click("#claimed", chromedp.ByQuery), chromedp.Sleep(300*time.Millisecond),
+ chromedp.Evaluate(liveFormState("claimed"), &claimed))
+ if want := "busy:true spin:no"; claimed != want {
+ t.Errorf("%s: a form the live script claimed reads %q after the submit, want %q: busy.js armed it and handed it back, wiping the script's busy state", order, claimed, want)
+ }
+ tookNothing(t, payloads, order+", the claimed form")
+
+ // Native: busy.js arms it, late, and the browser sends it.
+ var native string
+ mustRun(t, ctx, chromedp.Navigate(rig.Origin+order), chromedp.WaitVisible("#native", chromedp.ByQuery),
+ chromedp.Click("#native", chromedp.ByQuery), chromedp.Sleep(150*time.Millisecond),
+ chromedp.Evaluate(liveFormState("native"), &native))
+ if want := "busy:true spin:yes"; native != want {
+ t.Errorf("%s: a form the live script left to the browser reads %q after the submit, want %q: busy.js did not arm it", order, native, want)
+ }
+ if got := took(t, payloads, order+", the native form"); got != "form=native" {
+ t.Errorf("%s: the native form sent %q, want form=native", order, got)
+ }
+ // The server has the form before the browser has its 204, and
+ // a navigation started meanwhile is aborted by the answer.
+ mustRun(t, ctx, chromedp.Sleep(500*time.Millisecond))
+ }
+}
diff --git a/check/node/transition_node.mjs b/check/node/transition_node.mjs
new file mode 100644
index 0000000..f8489dc
--- /dev/null
+++ b/check/node/transition_node.mjs
@@ -0,0 +1,96 @@
+// transition_node — runs shell.js or transition.js, the file named on
+// the command line, against a stub page in plain Node, and hands it view
+// transitions whose promises reject. A transition the browser skips
+// rejects ready, finished and updateCallbackDone with a DOMException
+// named AbortError, InvalidStateError or TimeoutError even though the
+// navigation succeeds, and nothing else handles it, so it reached the
+// console as an uncaught error. The script must swallow exactly those
+// three, at startup (document.activeViewTransition), on pagereveal and
+// on pageswap, and let every other rejection through: an Error, a
+// TypeError, a DOMException of another name, an Error merely named
+// AbortError, and null.
+//
+// Ported from Rastrillo's ui/shell_transition_node.mjs (d9c9ed13,
+// 023740c5, e5e35a3e, 6b1c5478). check/shell_test.go is the only
+// caller. A failed assertion exits non-zero with its message; a pass
+// prints one JSON object, the count of rejections checked, so the caller
+// can tell a run that checked them all from one that checked none.
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import vm from "node:vm";
+
+const script = process.argv[2];
+if (script !== "shell.js" && script !== "transition.js") {
+ throw new Error(`name shell.js or transition.js, not ${script}`);
+}
+const source = readFileSync(new URL(`../../js/${script}`, import.meta.url), "utf8");
+const listeners = {};
+const document = {
+ querySelector: () => null,
+ addEventListener() {},
+};
+const window = {
+ onpagereveal: null,
+ onpageswap: null,
+ addEventListener: (event, listener) => { listeners[event] = listener; },
+};
+const start = () => vm.runInNewContext(source, {
+ window, document, URL, DOMException,
+ location: new URL("https://app.test/"),
+});
+
+start();
+assert.equal(typeof listeners.pageswap, "function",
+ `${script} observes outgoing transitions, for a page that opts in with its own CSS`);
+
+const unhandled = [];
+process.on("unhandledRejection", (reason) => { unhandled.push(reason); });
+const turn = () => new Promise((resolve) => setImmediate(resolve));
+const skips = ["AbortError", "InvalidStateError", "TimeoutError"];
+let checked = 0;
+for (const event of ["startup", "pagereveal", "pageswap"]) {
+ for (const promise of ["ready", "finished", "updateCallbackDone"]) {
+ const reasons = skips.map((name) => new DOMException("No transition", name));
+ reasons.push(new Error("application failure"),
+ new TypeError("broken callback"), new DOMException("denied", "SecurityError"),
+ Object.assign(new Error("application abort"), { name: "AbortError" }), null);
+ for (const reason of reasons) {
+ unhandled.length = 0;
+ const transition = {
+ types: new Set(),
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ updateCallbackDone: Promise.resolve(),
+ };
+ transition[promise] = Promise.reject(reason);
+ if (event === "startup") {
+ // The same transition at startup and again on pagereveal is
+ // observed once, and the types are left as the page set them.
+ document.activeViewTransition = transition;
+ start();
+ assert.equal(transition.types.size, 0);
+ await turn();
+ listeners.pagereveal({ viewTransition: transition });
+ } else {
+ listeners[event]({ viewTransition: transition });
+ }
+ await turn();
+ await turn();
+ const skipped = reason instanceof DOMException && skips.includes(reason.name);
+ assert.deepEqual(unhandled, skipped ? [] : [reason], `${script}, ${event}.${promise}: ${reason}`);
+ checked++;
+ }
+ }
+}
+
+// A filter on every rejection would hide this unrelated application
+// failure, so the script must answer only the transitions it is given.
+unhandled.length = 0;
+const unrelated = new DOMException("application abort", "AbortError");
+Promise.reject(unrelated);
+await turn();
+await turn();
+assert.deepEqual(unhandled, [unrelated], `${script} swallowed a rejection that was not a transition's`);
+checked++;
+
+process.stdout.write(JSON.stringify({ checked }) + "\n");
diff --git a/check/scripts_test.go b/check/scripts_test.go
index 9e36305..dc08e34 100644
--- a/check/scripts_test.go
+++ b/check/scripts_test.go
@@ -340,10 +340,16 @@ func TestBusyRuleIsTheDefault(t *testing.T) {
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")
}
- // Two mentions and no more: the definition and the one listener. A
- // third would mean someone started binding it per form again.
- if n := strings.Count(js, "busySubmit"); n != 2 {
- t.Errorf("busySubmit appears %d times, want 2 (one definition, one document listener)", n)
+ // 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.
@@ -629,6 +635,12 @@ func TestBusyContract(t *testing.T) {
"btn.disabled = wasDisabled", "btn.form !== form", `getAttribute("formtarget")`,
// Only a submit with a spinner showing is held.
`btn.getAttribute("aria-busy") !== "true") return;`,
+ // A data-live-form form is its server's script's when the script
+ // sends it, so it is armed late, and only if the script did not
+ // claim it (e.liveForm) in either script order
+ // (TestBusyLeavesALiveFormItsScriptClaimed drives it).
+ `form.hasAttribute("data-live-form") && !late`,
+ `e.defaultPrevented || e.liveForm) return;`,
} {
if !strings.Contains(js, want) {
t.Errorf("busy.js does not mention %q", want)
diff --git a/check/shell_test.go b/check/shell_test.go
index c4978f9..c34b1e2 100644
--- a/check/shell_test.go
+++ b/check/shell_test.go
@@ -26,3 +26,29 @@ func TestACancelledBackDoesNotSlideTheNextPageBack(t *testing.T) {
t.Errorf("Back tapped, the prompt cancelled, then a link to another page followed: it slid %q, want forward", got.Cancelled)
}
}
+
+// A view transition the browser skips rejects its promises though the
+// navigation succeeds, and with nothing to handle them the page logged
+// an uncaught AbortError, which also failed any browser test that
+// watches the console. 1.2.0's shell.js (Rastrillo d9c9ed13, 023740c5,
+// e5e35a3e and 6b1c5478) swallows exactly a skipped transition's
+// rejection, at startup, on pagereveal and on pageswap, and lets every
+// other error through. node/transition_node.mjs hands it each kind.
+func TestShellJSSwallowsOnlyASkippedTransition(t *testing.T) {
+ skippedTransitionsAreSwallowed(t, "shell.js")
+}
+
+// skippedTransitionsAreSwallowed runs node/transition_node.mjs against
+// one script and holds it to having checked every rejection: three
+// moments, three promises and eight reasons each, and one unrelated
+// rejection.
+func skippedTransitionsAreSwallowed(t *testing.T, script string) {
+ t.Helper()
+ var got struct{ Checked int }
+ if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/transition_node.mjs", script}}), &got); err != nil {
+ t.Fatal(err)
+ }
+ if want := 3*3*8 + 1; got.Checked != want {
+ t.Errorf("node/transition_node.mjs checked %d rejections against %s, want %d", got.Checked, script, want)
+ }
+}
diff --git a/js/busy.js b/js/busy.js
index bd359df..5f7228a 100644
--- a/js/busy.js
+++ b/js/busy.js
@@ -25,6 +25,10 @@
included — changes nothing
data-busy-label="…" on the form or on the button: replacement
button text while it works
+ data-live-form on a <form>: live.js's to send, and to show
+ busy, whenever it claims the submit (marking
+ the event e.liveForm); this file arms it only
+ when live.js lets it go natively
A script that handles a submit itself (a fetch, say) cancels it in a
listener on the form or the document, as it would anyway: those run
@@ -88,9 +92,18 @@
});
}
- function busySubmit(e) {
+ function busySubmit(e, late) {
var form = e.target;
if (!form || form.tagName !== "FORM") return;
+ // A swapping form is live.js's whenever live.js claims it: arming it
+ // here too would hand it back a tick after live.js cancels the
+ // submit, clearing live.js's busy state mid-request. Whether this
+ // capture listener runs before live.js's is down to script order, so
+ // it is armed late, from the window listener, and only if live.js
+ // did not claim it (e.liveForm, set in live.js's capture listener)
+ // and nobody cancelled it. One live.js leaves to the browser (a GET,
+ // another origin, a page live.js is not running on) keeps the rule.
+ if (form.hasAttribute("data-live-form") && !late) return;
// pow.js owns every submit of a form it protects: it cancels it,
// shows its own busy state (this file's vocabulary) while it waits,
// and re-sends it marked data-pow-released. Arming here before that
@@ -184,6 +197,10 @@
var form = e.target;
if (!form || form.tagName !== "FORM") return;
if (form === releasing) { releasing = null; return; }
+ if (form.hasAttribute("data-live-form")) {
+ if (e.defaultPrevented || e.liveForm) return;
+ busySubmit(e, true);
+ }
// Never hold a pow form: pow has already held it while it solved,
// and a second hold is a second wait for nothing. Worse, holding it
// here and re-sending later would send it back through pow, which
diff --git a/js/shell.js b/js/shell.js
index 93a5e1b..72cde24 100644
--- a/js/shell.js
+++ b/js/shell.js
@@ -1,4 +1,4 @@
-/* shell.js — phone navigation for the sidebar and console shells.
+/* shell.js — transition observation and phone shell navigation.
First-party, dependency-free, CSP-clean, and optional: delete it (and
its <script> tag) and every page still works, because the index and
the back control are ordinary server-rendered pages and links. It is
@@ -26,10 +26,9 @@
width focuses the nav link to that path, or else the one the URL's
#fragment names.
- The layouts load it with blocking="render": the direction is set in
- pagereveal, at the first render, and a deferred script can run after
- it. An engine that ignores blocking gets an untyped transition, which
- shell.css draws as a cross-fade. */
+ Sidebar and console use defer blocking="render" for focus and
+ direction after parsing. Other layouts use transition.js before
+ styles so incoming transitions skipped before pagereveal are observed. */
(function () {
"use strict";
@@ -87,17 +86,28 @@
if (hit) hit.focus();
}
- // 1. Direction. Back when the page being left recorded that its back
- // control was used AND this page is where it pointed, or when the
- // Navigation API reports a traverse to an earlier entry (the browser's
- // own Back); forward otherwise. The record is written on the click,
- // before anything commits, so a Back whose leave-page prompt was
- // cancelled leaves it behind: scoped to its destination, the next link
- // followed still slides forward. Taken on every reveal, matched or not,
- // so a stale one never waits for its URL to come round. A URL pattern
- // cannot decide it: the new page cannot know which URL is the index,
- // and the pattern is the app's.
+ // A skipped browser transition rejects even though navigation succeeds.
+ // Startup and pagereveal can expose the same object; observe it once.
+ var observed = new WeakSet();
+ function observeTransition(transition) {
+ if (!transition || observed.has(transition)) return;
+ observed.add(transition);
+ ["ready", "finished", "updateCallbackDone"].forEach(function (key) {
+ if (transition[key]) transition[key].catch(function (err) {
+ if (!(err instanceof DOMException) ||
+ ["AbortError", "InvalidStateError", "TimeoutError"].indexOf(err.name) < 0) throw err;
+ });
+ });
+ }
+
+ observeTransition(document.activeViewTransition);
+
+ // 1. Direction. The back record is scoped to its destination because
+ // a cancelled leave-page prompt can leave it behind. Consume it on
+ // every reveal so it cannot affect a later visit. Browser Back uses
+ // the Navigation API's entry order; URL patterns belong to the app.
function reveal(e) {
+ observeTransition(e.viewTransition);
var back = take(WENT_BACK) === location.pathname + location.search, a = window.navigation && navigation.activation;
if (!back && a && a.navigationType === "traverse" && a.from && a.entry) back = a.entry.index < a.from.index;
// Focus first, and the type only where there is a set to add it to:
@@ -116,7 +126,8 @@
// revealed; pagehide is the fallback where there is no pageswap.
// Never both: a pagehide landing after the index took the record
// would leave a stale one behind.
- function leave() {
+ function leave(e) {
+ observeTransition(e.viewTransition);
if (view(PAGE)) store(function (s) { s.setItem(RETURN, location.pathname + location.search); });
}
window.addEventListener("onpageswap" in window ? "pageswap" : "pagehide", leave);
diff --git a/releases/1.2.0/SHA256SUMS b/releases/1.2.0/SHA256SUMS
index 8bfaff5..ef40fa9 100644
--- a/releases/1.2.0/SHA256SUMS
+++ b/releases/1.2.0/SHA256SUMS
@@ -1,7 +1,7 @@
3f3d9e0024b1921b067d6f7f88deb4a60cbe7a78e76c64e3f1d7fc3b779b9d04 LICENSE
b495047bd93a9b06913511076f504daba17d5bbeb3e0650f3bb53a4220329c57 NOTICE
05a60a7d6c5f1441995652083e7135d11142eb414b48c021d05c3d9b52b422bd anchor.js
-ed41c1fedaea05b21530141671eff062c259cce971f2229b2ab51c8768ea9558 busy.js
+af57823e17475ed7cfa715c3986dc9093f126d8f23b1b1cdaa2af39a1aae0dd8 busy.js
a085f3eabd8556baebcfee2032f57464282549c65ac49ff66f3f65b2f8bc6f49 calendar.js
92e7423f7275d543784ccd9529438b35800b965ba9b64737d0be6322b1c267ab copy.js
f21d680c67d9a2b874109824826f8ea2c4449be556fcf458bf250c955a96bc51 datetime.js
@@ -51,6 +51,6 @@ d9f6b21fe977c603adf81e7723c22686d36cb6161e5b4538d6c8e9248f3dedb7 icons/x.svg
afc01911332dc6407112214fab82e513838f7252293970e2e21f9d84bb9ae28b plain.css
f9da2afe8f29dd30217e209e850487123906dc73190584587c7d28dc08652545 select.js
2a6c6a0284d9faddb68d1a3b79377c6a1c6144c1e07e7fde2f81dcf6e32e9e2e shell.css
-4e1e4ba193a1e5f08901b998b5501918a91775ba572a2cc115a35d9556cac653 shell.js
+0c379643d755c59cf1655c169342ab26af27c31a63c8d7c91aa77e199a30c178 shell.js
f75da06dfc8fff7d59eeed29050506e1e2fd5ecf60e99c25ab45260abe31076d signal.css
064412f4dc68300359d122eef587bab1be650edcccf158892e170867407c7214 tokens.css
diff --git a/releases/1.2.0/busy.js b/releases/1.2.0/busy.js
index 21e1bca..53f370d 100644
--- a/releases/1.2.0/busy.js
+++ b/releases/1.2.0/busy.js
@@ -19,9 +19,10 @@
});
}
- function busySubmit(e) {
+ function busySubmit(e, late) {
var form = e.target;
if (!form || form.tagName !== "FORM") return;
+ if (form.hasAttribute("data-live-form") && !late) return;
if (form.hasAttribute("data-pow-form") && !form.hasAttribute("data-pow-released")) return;
if (form.getAttribute("data-busy") === "false") return;
var btn = e.submitter;
@@ -68,6 +69,10 @@
var form = e.target;
if (!form || form.tagName !== "FORM") return;
if (form === releasing) { releasing = null; return; }
+ if (form.hasAttribute("data-live-form")) {
+ if (e.defaultPrevented || e.liveForm) return;
+ busySubmit(e, true);
+ }
if (form.hasAttribute("data-pow-form")) return;
if (e.defaultPrevented || form.getAttribute("aria-busy") !== "true") return;
var btn = e.submitter || null;
diff --git a/releases/1.2.0/shell.js b/releases/1.2.0/shell.js
index 7045417..8d1293d 100644
--- a/releases/1.2.0/shell.js
+++ b/releases/1.2.0/shell.js
@@ -41,7 +41,22 @@
if (hit) hit.focus();
}
+ var observed = new WeakSet();
+ function observeTransition(transition) {
+ if (!transition || observed.has(transition)) return;
+ observed.add(transition);
+ ["ready", "finished", "updateCallbackDone"].forEach(function (key) {
+ if (transition[key]) transition[key].catch(function (err) {
+ if (!(err instanceof DOMException) ||
+ ["AbortError", "InvalidStateError", "TimeoutError"].indexOf(err.name) < 0) throw err;
+ });
+ });
+ }
+
+ observeTransition(document.activeViewTransition);
+
function reveal(e) {
+ observeTransition(e.viewTransition);
var back = take(WENT_BACK) === location.pathname + location.search, a = window.navigation && navigation.activation;
if (!back && a && a.navigationType === "traverse" && a.from && a.entry) back = a.entry.index < a.from.index;
returnFocus();
@@ -50,7 +65,8 @@
if ("onpagereveal" in window) window.addEventListener("pagereveal", reveal);
else window.addEventListener("pageshow", returnFocus);
- function leave() {
+ function leave(e) {
+ observeTransition(e.viewTransition);
if (view(PAGE)) store(function (s) { s.setItem(RETURN, location.pathname + location.search); });
}
window.addEventListener("onpageswap" in window ? "pageswap" : "pagehide", leave);
diff --git a/releases_test.go b/releases_test.go
index a4f5d51..6ec2b04 100644
--- a/releases_test.go
+++ b/releases_test.go
@@ -2,6 +2,8 @@ package fiesta
import (
"bytes"
+ "crypto/sha256"
+ "fmt"
"io/fs"
"os"
"os/exec"
@@ -349,21 +351,72 @@ func TestTheBaselineIsOneOhOh(t *testing.T) {
}
}
+// changedScript is a 1.0 script a 1.x release changes on purpose, to
+// fix it: the SHA-256 of the bytes the fix leaves, so the next change
+// to the file fails here again and is named in its own entry; why; and
+// the test that holds the fixed behaviour, since the photographs serve
+// the one js/ to both sides and cannot see a script change.
+type changedScript struct {
+ Sum, Why, Test string
+}
+
+// changedOneOhScripts is keyed by the script's path. Every entry must
+// be used: a script with an entry that is 1.0.0's again is an exception
+// for nothing.
+var changedOneOhScripts = map[string]changedScript{
+ "js/shell.js": {
+ Sum: "734771a617f39a340a1ce2ad11e08607aef191434631fbe913fc1d829b149fcd",
+ Why: "1.2.0 takes Rastrillo d9c9ed13, 023740c5, e5e35a3e and " +
+ "6b1c5478: a view transition the browser skips rejects its " +
+ "promises, which logged an uncaught AbortError, so shell.js " +
+ "swallows exactly that rejection and no other",
+ Test: "check/shell_test.go TestShellJSSwallowsOnlyASkippedTransition",
+ },
+ "js/busy.js": {
+ Sum: "eb7210110c5ba6b23552407953bc97374f834787fcec411e643e7513524f69b7",
+ Why: "1.2.0 takes Rastrillo 8548e455: a data-live-form form is " +
+ "armed only once its server's script has left it to the " +
+ "browser, so busy.js no longer wipes the busy state of a " +
+ "form the script sends itself",
+ Test: "check/liveform_browser_test.go TestBusyLeavesALiveFormItsScriptClaimed",
+ },
+}
+
// TestOneOhScriptsAreUnchanged: every script 1.0.0 shipped is in js/
-// byte for byte. 1.1 adds a script and changes none, and the 1.0
-// photographs serve one js/ to both sides on the strength of that: a
-// changed script would be photographed on both sides at once and
-// differ from neither.
+// byte for byte, or is exactly the bytes its changedOneOhScripts entry
+// names. 1.1 adds a script and changes none, and the 1.0 photographs
+// serve one js/ to both sides on the strength of that: a changed script
+// would be photographed on both sides at once and differ from neither,
+// which is why each fix names the test that holds it.
func TestOneOhScriptsAreUnchanged(t *testing.T) {
requireOneOhTag(t)
+ used := map[string]bool{}
for _, path := range taggedFiles(t, oneOhTag, "js") {
b, err := os.ReadFile(filepath.FromSlash(path))
if err != nil {
t.Errorf("%s: %v; %s shipped it, and a 1.0 page may load it", path, err, oneOhTag)
continue
}
- if !bytes.Equal(b, gitOut(t, "show", oneOhTag+":"+path)) {
- t.Errorf("%s differs from %s:%s; a 1.x release changes no 1.0 script", path, oneOhTag, path)
+ if bytes.Equal(b, gitOut(t, "show", oneOhTag+":"+path)) {
+ continue
+ }
+ change, ok := changedOneOhScripts[path]
+ switch sum := fmt.Sprintf("%x", sha256.Sum256(b)); {
+ case !ok:
+ t.Errorf("%s differs from %s:%s; a 1.x release changes no 1.0 script but by a changedOneOhScripts entry", path, oneOhTag, path)
+ case sum != change.Sum:
+ t.Errorf("%s differs from %s:%s and is not the fix its changedOneOhScripts entry names (SHA-256 %s, want %s); a further change is a new entry, with its reason and its test", path, oneOhTag, path, sum, change.Sum)
+ default:
+ used[path] = true
+ }
+ }
+ for _, path := range sortedNames(changedOneOhScripts) {
+ if !used[path] {
+ t.Errorf("changedOneOhScripts[%q] is used by no changed script; an exception for nothing is a hole waiting to be used", path)
+ }
+ file, name, _ := strings.Cut(changedOneOhScripts[path].Test, " ")
+ if source, err := os.ReadFile(filepath.FromSlash(file)); err != nil || !bytes.Contains(source, []byte("func "+name+"(t *testing.T)")) {
+ t.Errorf("changedOneOhScripts[%q] names %s, which is not a test in %s; the fix must be held by a test of its own", path, name, file)
}
}
}