package build import ( "encoding/json" "io/fs" "slices" "strings" "testing" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/nodetest" ) // The strippers' own tests, from rastrillo's weight_test.go at the // seed commit, over Fiesta's tree: the hard cases each stripper must // leave alone, and every shipped file run as written and as stripped // in Node. // shippedFiles is every stylesheet and script in the tree, by file // name. func shippedFiles(t *testing.T) map[string][]byte { t.Helper() files := map[string][]byte{"tokens.css": fiesta.TokensCSS(), "shell.css": fiesta.ShellCSS()} for _, name := range fiesta.ThemeNames() { css, _ := fiesta.ThemeCSS(name) files[name+".css"] = css } for _, name := range fiesta.ScriptNames() { js, ok := fiesta.Script(name) if !ok { t.Fatalf("Script(%q) is not shipped", name) } files[name] = js } return files } // Every shipped file gets smaller under gzip, and smaller again without // its comments. The literal figures are not asserted anywhere: they are // the compressor's, not this package's. func TestEveryShippedFileWeighsLessCompressedAndLessAgainStripped(t *testing.T) { for name, body := range shippedFiles(t) { var bare []byte if strings.HasSuffix(name, ".css") { bare = stripCSSComments(body) } else { bare = stripJSComments(body) } raw, gz, stripped := len(body), Gzipped(body), Gzipped(bare) if gz >= raw { t.Errorf("%s: gzipped %d is not below raw %d", name, gz, raw) } if len(bare) == len(body) { t.Errorf("%s: no comments found to strip; every shipped file carries some", name) } else if stripped > gz { t.Errorf("%s: stripped %d is above gzipped %d", name, stripped, gz) } } } // The JavaScript stripper's hard cases: everything that only looks like // a comment stays, and code either side of a removed comment still // parses as the same code. func TestStripJSCommentsLeavesWhatOnlyLooksLikeAComment(t *testing.T) { for _, c := range []struct{ in, want string }{ {"var a = 1; // note\nvar b = 2;\n", "var a = 1; \nvar b = 2;\n"}, {"// alone\nvar a;\n", "var a;\n"}, {" /* alone */\nvar a;\n", "var a;\n"}, {"var s = 'http://x/*y*/';\n", "var s = 'http://x/*y*/';\n"}, {`var s = "a\"//b";` + "\n", `var s = "a\"//b";` + "\n"}, {"var r = /\\/*x/g; // c\n", "var r = /\\/*x/g; \n"}, {"var r = /[/]/.test(s);\n", "var r = /[/]/.test(s);\n"}, {"var d = a / b / c; // c\n", "var d = a / b / c; \n"}, {"return /x/.test(s);\n", "return /x/.test(s);\n"}, {"var t = `a ${ {b: 1}.b /* c */ } // d`;\n", "var t = `a ${ {b: 1}.b } // d`;\n"}, {"var t = `${`${'//'}`}`; // e\n", "var t = `${`${'//'}`}`; \n"}, {"a/**/b\n", "a b\n"}, {"a /* one\ntwo */ b\n", "a \n b\n"}, } { if got := string(stripJSComments([]byte(c.in))); got != c.want { t.Errorf("stripJSComments(%q)\n got %q\nwant %q", c.in, got, c.want) } } } func TestStripCSSCommentsLeavesStringsAlone(t *testing.T) { for _, c := range []struct{ in, want string }{ {"/* head */\na { color: red; }\n", "a { color: red; }\n"}, {"a { content: \"/* no */\"; } /* yes */\n", "a { content: \"/* no */\"; } \n"}, {"a { b: c; /* d */ e: f; }\n", "a { b: c; e: f; }\n"}, } { if got := string(stripCSSComments([]byte(c.in))); got != c.want { t.Errorf("stripCSSComments(%q)\n got %q\nwant %q", c.in, got, c.want) } } } // nodeEval runs each script in a fresh node vm context and returns what // it came to: its completion value as JSON (awaited, so an async case // reports what it resolved to), or the error it threw. // Equivalence is the question a stripper has to answer, and node // --check cannot: a+/**/++b and a+++b both parse. const nodeEval = ` const vm = require("vm"); const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); (async () => { const out = []; for (const src of srcs) { try { out.push("value " + JSON.stringify(await vm.runInNewContext(src, {}))); } catch (e) { out.push("throws " + e.name + ": " + e.message); } } process.stdout.write(JSON.stringify(out)); })(); ` func evalInNode(t *testing.T, srcs []string) []string { t.Helper() in, err := json.Marshal(srcs) if err != nil { t.Fatal(err) } var out []string if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", nodeEval}, Stdin: in}), &out); err != nil { t.Fatal(err) } return out } // The cases a character-level stripper gets wrong, each run in node as // written and as stripped: a regex after a control-flow header's ")", // where a / starts a regex rather than dividing, and a comment between // two operators, whose removal must not fuse them into a third. func TestStrippedScriptsEvaluateAsWritten(t *testing.T) { cases := []string{ `var r = 0; if (true) /[/*]/.test("/") && (r = 1); r`, `var n = 0; while (n < 1) /a/.test("a") && n++; n`, `var k = 0; for (var i = 0; i < 2; i++) /[*/]/.test("*") && k++; k`, `let a = 1, b = 2; [a+/**/++b, a, b]`, `let a = 5, b = 2; [a-/* x */-b, a /**/ / b]`, `var x = 4, g = 2; [x / 2 / g, (x) / 2, x++ / 2]`, `var s = "/*", t = '//', u = ` + "`${s}/*${t}*/`" + `; [s, t, u] // tail`, `var o = {a: /}/.source}; /* gone */ o.a`, `(async () => { var r = 0; for await (const x of [1]) /[/*]/.test("/") && (r = 1); return r })()`, } var both []string for _, c := range cases { both = append(both, c, string(stripJSComments([]byte(c)))) } got := evalInNode(t, both) for i, c := range cases { if got[2*i] != got[2*i+1] { t.Errorf("stripping changed what a script does:\n script: %s\nstripped: %s\n before: %s\n after: %s", c, both[2*i+1], got[2*i], got[2*i+1]) } if strings.HasPrefix(got[2*i], "throws") { t.Errorf("the case itself does not run: %s: %s", c, got[2*i]) } } } // shapeEval loads each shipped script into an empty context, with a // CommonJS module and no document, and reports what it left behind: the // globals it defined, what it exported, and the error it stopped at. // The scripts export their pure halves to node behind a document guard, // so this runs real code from each, not only its declarations. const shapeEval = ` const vm = require("vm"); const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); const shape = v => v === null ? "null" : typeof v === "function" ? "function/" + v.length : typeof v; const describe = o => Object.keys(o).sort().map(k => k + ":" + shape(o[k])).join(","); process.stdout.write(JSON.stringify(srcs.map(src => { const module = {exports: {}}; const ctx = {module, exports: module.exports}; let err = ""; try { vm.runInNewContext(src, ctx); } catch (e) { err = e.name + ": " + e.message; } return "globals " + describe(ctx) + " | exports " + describe(module.exports) + " | " + err; }))); ` // Every shipped script, stripped, still evaluates to the same thing: // the same globals and exports of the same kinds and arities, and the // same error where it reaches for a document node does not have. A // stripper that ate code a regex was hiding would change one of them. func TestEveryShippedScriptStripsToTheSameShape(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) } var got []string if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", shapeEval}, Stdin: in}), &got); err != nil { t.Fatal(err) } for i, name := range names { if got[2*i] != got[2*i+1] { t.Errorf("%s evaluates differently stripped:\n as written: %s\n stripped: %s", name, got[2*i], got[2*i+1]) } t.Logf("%s: %s", name, got[2*i]) if strings.Contains(got[2*i], "SyntaxError") { t.Errorf("%s does not even parse as written: %s", name, got[2*i]) } } } // 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) } } }