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>
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) |
| + } |
| + } |
| +} |