rastrillo / fiesta Public

Hold each stripped script to the listeners it registers and the functions it exports on a stub page

TestEveryShippedScriptStripsToTheSameShape loads each script with no
document, so five of the eight stop at their first line and the
comparison is of two identical errors. A stripped fiesta.js with its
touchstart listener renamed passed it.

The new test gives the scripts a stub page: a loading document with a
root and a body, a window with matchMedia and storage, and the
observers and constructors they reach for on load. Each script runs
its page half as far as the stub lets it, and what it did there is
compared between the file as written and the file as stripped: the
listener types it put on the document, the window and the root, in
order; the source of each exported function, the original's stripped
on its own and both with whitespace folded; and the error it stopped
at, if any. fiesta.js registers eleven listeners on the stub, the
touchstart among them, and the test requires that one, so the check
has provably reached the page half it exists to read.
Human

Prompt

The final review's Minor 6: the stripping equivalence test accepted identical early failures, so a stripped fiesta.js with touchstart renamed still passed.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

internal/build: TestStrippedScriptsRegisterTheSameListeners runs every shipped script as written and as stripped on a stub page with a document, a window and the constructors the scripts reach for on load, and compares the listener types registered on the document, the window and the root, in order, each exported function's source with comments stripped and whitespace folded, and where each stopped. fiesta.js must reach its touchstart listener.
Agent notes
The shape test stays: it is the no-document leg. Red run: with touchstart replaced by not-touchstart in the stripped fiesta.js, the new test fails on fiesta.js's listener list and the old one still passes; both runs are in the final-fix report.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 197242cf31bd60644f19899f8e85fb3484db2281 parent af1c647
1 file changed, +132 −0
  • internal/build/strip_test.go +132 −0
diff --git a/internal/build/strip_test.go b/internal/build/strip_test.go
index 071cb06..c103289 100644
--- a/internal/build/strip_test.go
+++ b/internal/build/strip_test.go
@@ -3,6 +3,7 @@ package build
import (
"encoding/json"
"io/fs"
+ "slices"
"strings"
"testing"
@@ -207,3 +208,134 @@ func TestEveryShippedScriptStripsToTheSameShape(t *testing.T) {
}
}
}
+
+// listenersEval loads each script into a stub page, a loading document
+// with a root, a window and the constructors the scripts reach for on
+// load, and reports what it did there: every listener type it put on
+// the document, the window and the root, in order, each exported
+// function's source, and the error it stopped at, if any. The shape
+// test above runs the scripts with no document, where five of them
+// stop at once; here they run their page half as far as a stub lets
+// them, which is where a changed event name or a changed body shows.
+const listenersEval = `
+const vm = require("vm");
+const srcs = JSON.parse(require("fs").readFileSync(0, "utf8"));
+const element = () => {
+ const attrs = {};
+ const el = {
+ getAttribute: (k) => (k in attrs ? attrs[k] : null), hasAttribute: (k) => k in attrs,
+ setAttribute: (k, v) => { attrs[k] = String(v); }, removeAttribute: (k) => { delete attrs[k]; },
+ toggleAttribute: (k) => { if (k in attrs) delete attrs[k]; else attrs[k] = ""; return k in attrs; },
+ addEventListener() {}, removeEventListener() {}, dispatchEvent: () => true,
+ querySelectorAll: () => [], querySelector: () => null, matches: () => false, closest: () => null,
+ classList: { add() {}, remove() {}, contains: () => false, toggle() {} }, style: { setProperty() {}, removeProperty() {} },
+ dataset: {}, lang: "en", contains: () => false, appendChild: (c) => c, append() {}, insertBefore: (c) => c,
+ remove() {}, isConnected: true, parentNode: null, parentElement: null, children: [], childNodes: [],
+ getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }),
+ getClientRects: () => [], focus() {}, blur() {}, textContent: "", innerHTML: "", id: "", tagName: "DIV",
+ };
+ return el;
+};
+process.stdout.write(JSON.stringify(srcs.map((src) => {
+ const listeners = [];
+ const on = (target) => (type) => { listeners.push(target + ":" + type); };
+ const root = element();
+ root.addEventListener = on("root");
+ const body = element();
+ const document = Object.assign(element(), {
+ readyState: "loading", documentElement: root, body, head: element(), activeElement: null,
+ addEventListener: on("document"), createElement: () => element(), createTextNode: () => ({}),
+ getElementById: () => null, cookie: "",
+ });
+ const window = {
+ addEventListener: on("window"), removeEventListener() {}, innerHeight: 600, innerWidth: 800,
+ matchMedia: () => ({ matches: false, addEventListener() {}, onchange: null }),
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
+ requestAnimationFrame: () => 0, cancelAnimationFrame() {}, getComputedStyle: () => ({ getPropertyValue: () => "" }),
+ visualViewport: { addEventListener() {}, removeEventListener() {}, height: 600, width: 800, offsetTop: 0 },
+ CSS: { supports: () => false }, navigator: { userAgent: "" }, location: { protocol: "http:", href: "http://x/", pathname: "/" },
+ history: { replaceState() {} }, scrollTo() {}, scrollX: 0, scrollY: 0,
+ };
+ const module = { exports: {} };
+ const ctx = Object.assign(Object.create(null), window, {
+ window, self: window, document, module, exports: module.exports,
+ MutationObserver: class { observe() {} disconnect() {} takeRecords() { return []; } },
+ ResizeObserver: class { observe() {} disconnect() {} },
+ IntersectionObserver: class { observe() {} disconnect() {} },
+ CustomEvent, Event, AbortController, Intl, setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, queueMicrotask, console,
+ HTMLElement: class {}, HTMLInputElement: class {}, Node: class {}, Element: class {},
+ });
+ let err = "";
+ try { vm.runInNewContext(src, ctx); } catch (e) { err = e.name + ": " + e.message; }
+ const exported = {};
+ for (const k of Object.keys(module.exports).sort()) {
+ const v = module.exports[k];
+ exported[k] = typeof v === "function" ? String(v) : JSON.stringify(v);
+ }
+ return { listeners, exported, err };
+})));
+`
+
+// sameShape is a function's source with every run of whitespace made
+// one space: the stripped file's function is a slice of a whole file
+// the stripper reshaped, and the original's, stripped on its own, can
+// differ from it only in how its whitespace fell.
+func sameShape(src string) string {
+ return strings.Join(strings.Fields(src), " ")
+}
+
+// TestStrippedScriptsRegisterTheSameListeners: on the stub page, every
+// shipped script as written and as stripped registers the same listener
+// types on the same targets in the same order, exports the same
+// functions with the same bodies (comments aside), and stops at the
+// same place if it stops. A stripper that changed one event name, or
+// ate a line a regex hid, changes one of these; the shape test above
+// cannot see either, since with no document five scripts stop before
+// their first listener. fiesta.js must get as far as its touchstart
+// listener, or the check has not reached the page half at all.
+func TestStrippedScriptsRegisterTheSameListeners(t *testing.T) {
+ names := fiesta.ScriptNames()
+ var srcs []string
+ for _, name := range names {
+ body, err := fs.ReadFile(fiesta.FS(), "js/"+name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srcs = append(srcs, string(body), string(stripJSComments(body)))
+ }
+ in, err := json.Marshal(srcs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ type ran struct {
+ Listeners []string `json:"listeners"`
+ Exported map[string]string `json:"exported"`
+ Err string `json:"err"`
+ }
+ var got []ran
+ if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", listenersEval}, Stdin: in}), &got); err != nil {
+ t.Fatal(err)
+ }
+ for i, name := range names {
+ as, stripped := got[2*i], got[2*i+1]
+ t.Logf("%s: %d listeners %v, %d exports, stopped at %q", name, len(as.Listeners), as.Listeners, len(as.Exported), as.Err)
+ if strings.Join(as.Listeners, "\n") != strings.Join(stripped.Listeners, "\n") {
+ t.Errorf("%s registers different listeners stripped:\n as written: %v\n stripped: %v", name, as.Listeners, stripped.Listeners)
+ }
+ if as.Err != stripped.Err {
+ t.Errorf("%s stops differently stripped:\n as written: %s\n stripped: %s", name, as.Err, stripped.Err)
+ }
+ for k, src := range as.Exported {
+ want := sameShape(string(stripJSComments([]byte(src))))
+ if got := sameShape(stripped.Exported[k]); got != want {
+ t.Errorf("%s exports a different %s stripped:\n as written, stripped alone: %s\n from the stripped file: %s", name, k, want, got)
+ }
+ }
+ if len(as.Exported) != len(stripped.Exported) {
+ t.Errorf("%s exports %d names as written and %d stripped", name, len(as.Exported), len(stripped.Exported))
+ }
+ if name == "fiesta.js" && !slices.Contains(as.Listeners, "document:touchstart") {
+ t.Errorf("fiesta.js did not reach its touchstart listener on the stub page (%v, stopped at %q), so this check has not run its page half", as.Listeners, as.Err)
+ }
+ }
+}