| 1 | package build |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "io/fs" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "amadan.net/rastrillo/fiesta" |
| 11 | "amadan.net/rastrillo/fiesta/internal/nodetest" |
| 12 | ) |
| 13 | |
| 14 | // The strippers' own tests, from rastrillo's weight_test.go at the |
| 15 | // seed commit, over Fiesta's tree: the hard cases each stripper must |
| 16 | // leave alone, and every shipped file run as written and as stripped |
| 17 | // in Node. |
| 18 | |
| 19 | // shippedFiles is every stylesheet and script in the tree, by file |
| 20 | // name. |
| 21 | func shippedFiles(t *testing.T) map[string][]byte { |
| 22 | t.Helper() |
| 23 | files := map[string][]byte{"tokens.css": fiesta.TokensCSS(), "shell.css": fiesta.ShellCSS()} |
| 24 | for _, name := range fiesta.ThemeNames() { |
| 25 | css, _ := fiesta.ThemeCSS(name) |
| 26 | files[name+".css"] = css |
| 27 | } |
| 28 | for _, name := range fiesta.ScriptNames() { |
| 29 | js, ok := fiesta.Script(name) |
| 30 | if !ok { |
| 31 | t.Fatalf("Script(%q) is not shipped", name) |
| 32 | } |
| 33 | files[name] = js |
| 34 | } |
| 35 | return files |
| 36 | } |
| 37 | |
| 38 | // Every shipped file gets smaller under gzip, and smaller again without |
| 39 | // its comments. The literal figures are not asserted anywhere: they are |
| 40 | // the compressor's, not this package's. |
| 41 | func TestEveryShippedFileWeighsLessCompressedAndLessAgainStripped(t *testing.T) { |
| 42 | for name, body := range shippedFiles(t) { |
| 43 | var bare []byte |
| 44 | if strings.HasSuffix(name, ".css") { |
| 45 | bare = stripCSSComments(body) |
| 46 | } else { |
| 47 | bare = stripJSComments(body) |
| 48 | } |
| 49 | raw, gz, stripped := len(body), Gzipped(body), Gzipped(bare) |
| 50 | if gz >= raw { |
| 51 | t.Errorf("%s: gzipped %d is not below raw %d", name, gz, raw) |
| 52 | } |
| 53 | if len(bare) == len(body) { |
| 54 | t.Errorf("%s: no comments found to strip; every shipped file carries some", name) |
| 55 | } else if stripped > gz { |
| 56 | t.Errorf("%s: stripped %d is above gzipped %d", name, stripped, gz) |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | // The JavaScript stripper's hard cases: everything that only looks like |
| 62 | // a comment stays, and code either side of a removed comment still |
| 63 | // parses as the same code. |
| 64 | func TestStripJSCommentsLeavesWhatOnlyLooksLikeAComment(t *testing.T) { |
| 65 | for _, c := range []struct{ in, want string }{ |
| 66 | {"var a = 1; // note\nvar b = 2;\n", "var a = 1; \nvar b = 2;\n"}, |
| 67 | {"// alone\nvar a;\n", "var a;\n"}, |
| 68 | {" /* alone */\nvar a;\n", "var a;\n"}, |
| 69 | {"var s = 'http://x/*y*/';\n", "var s = 'http://x/*y*/';\n"}, |
| 70 | {`var s = "a\"//b";` + "\n", `var s = "a\"//b";` + "\n"}, |
| 71 | {"var r = /\\/*x/g; // c\n", "var r = /\\/*x/g; \n"}, |
| 72 | {"var r = /[/]/.test(s);\n", "var r = /[/]/.test(s);\n"}, |
| 73 | {"var d = a / b / c; // c\n", "var d = a / b / c; \n"}, |
| 74 | {"return /x/.test(s);\n", "return /x/.test(s);\n"}, |
| 75 | {"var t = `a ${ {b: 1}.b /* c */ } // d`;\n", "var t = `a ${ {b: 1}.b } // d`;\n"}, |
| 76 | {"var t = `${`${'//'}`}`; // e\n", "var t = `${`${'//'}`}`; \n"}, |
| 77 | {"a/**/b\n", "a b\n"}, |
| 78 | {"a /* one\ntwo */ b\n", "a \n b\n"}, |
| 79 | } { |
| 80 | if got := string(stripJSComments([]byte(c.in))); got != c.want { |
| 81 | t.Errorf("stripJSComments(%q)\n got %q\nwant %q", c.in, got, c.want) |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestStripCSSCommentsLeavesStringsAlone(t *testing.T) { |
| 87 | for _, c := range []struct{ in, want string }{ |
| 88 | {"/* head */\na { color: red; }\n", "a { color: red; }\n"}, |
| 89 | {"a { content: \"/* no */\"; } /* yes */\n", "a { content: \"/* no */\"; } \n"}, |
| 90 | {"a { b: c; /* d */ e: f; }\n", "a { b: c; e: f; }\n"}, |
| 91 | } { |
| 92 | if got := string(stripCSSComments([]byte(c.in))); got != c.want { |
| 93 | t.Errorf("stripCSSComments(%q)\n got %q\nwant %q", c.in, got, c.want) |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // nodeEval runs each script in a fresh node vm context and returns what |
| 99 | // it came to: its completion value as JSON (awaited, so an async case |
| 100 | // reports what it resolved to), or the error it threw. |
| 101 | // Equivalence is the question a stripper has to answer, and node |
| 102 | // --check cannot: a+/**/++b and a+++b both parse. |
| 103 | const nodeEval = ` |
| 104 | const vm = require("vm"); |
| 105 | const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); |
| 106 | (async () => { |
| 107 | const out = []; |
| 108 | for (const src of srcs) { |
| 109 | try { out.push("value " + JSON.stringify(await vm.runInNewContext(src, {}))); } |
| 110 | catch (e) { out.push("throws " + e.name + ": " + e.message); } |
| 111 | } |
| 112 | process.stdout.write(JSON.stringify(out)); |
| 113 | })(); |
| 114 | ` |
| 115 | |
| 116 | func evalInNode(t *testing.T, srcs []string) []string { |
| 117 | t.Helper() |
| 118 | in, err := json.Marshal(srcs) |
| 119 | if err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | var out []string |
| 123 | if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", nodeEval}, Stdin: in}), &out); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | return out |
| 127 | } |
| 128 | |
| 129 | // The cases a character-level stripper gets wrong, each run in node as |
| 130 | // written and as stripped: a regex after a control-flow header's ")", |
| 131 | // where a / starts a regex rather than dividing, and a comment between |
| 132 | // two operators, whose removal must not fuse them into a third. |
| 133 | func TestStrippedScriptsEvaluateAsWritten(t *testing.T) { |
| 134 | cases := []string{ |
| 135 | `var r = 0; if (true) /[/*]/.test("/") && (r = 1); r`, |
| 136 | `var n = 0; while (n < 1) /a/.test("a") && n++; n`, |
| 137 | `var k = 0; for (var i = 0; i < 2; i++) /[*/]/.test("*") && k++; k`, |
| 138 | `let a = 1, b = 2; [a+/**/++b, a, b]`, |
| 139 | `let a = 5, b = 2; [a-/* x */-b, a /**/ / b]`, |
| 140 | `var x = 4, g = 2; [x / 2 / g, (x) / 2, x++ / 2]`, |
| 141 | `var s = "/*", t = '//', u = ` + "`${s}/*${t}*/`" + `; [s, t, u] // tail`, |
| 142 | `var o = {a: /}/.source}; /* gone */ o.a`, |
| 143 | `(async () => { var r = 0; for await (const x of [1]) /[/*]/.test("/") && (r = 1); return r })()`, |
| 144 | } |
| 145 | var both []string |
| 146 | for _, c := range cases { |
| 147 | both = append(both, c, string(stripJSComments([]byte(c)))) |
| 148 | } |
| 149 | got := evalInNode(t, both) |
| 150 | for i, c := range cases { |
| 151 | if got[2*i] != got[2*i+1] { |
| 152 | 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]) |
| 153 | } |
| 154 | if strings.HasPrefix(got[2*i], "throws") { |
| 155 | t.Errorf("the case itself does not run: %s: %s", c, got[2*i]) |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // shapeEval loads each shipped script into an empty context, with a |
| 161 | // CommonJS module and no document, and reports what it left behind: the |
| 162 | // globals it defined, what it exported, and the error it stopped at. |
| 163 | // The scripts export their pure halves to node behind a document guard, |
| 164 | // so this runs real code from each, not only its declarations. |
| 165 | const shapeEval = ` |
| 166 | const vm = require("vm"); |
| 167 | const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); |
| 168 | const shape = v => v === null ? "null" : typeof v === "function" ? "function/" + v.length : typeof v; |
| 169 | const describe = o => Object.keys(o).sort().map(k => k + ":" + shape(o[k])).join(","); |
| 170 | process.stdout.write(JSON.stringify(srcs.map(src => { |
| 171 | const module = {exports: {}}; |
| 172 | const ctx = {module, exports: module.exports}; |
| 173 | let err = ""; |
| 174 | try { vm.runInNewContext(src, ctx); } catch (e) { err = e.name + ": " + e.message; } |
| 175 | return "globals " + describe(ctx) + " | exports " + describe(module.exports) + " | " + err; |
| 176 | }))); |
| 177 | ` |
| 178 | |
| 179 | // Every shipped script, stripped, still evaluates to the same thing: |
| 180 | // the same globals and exports of the same kinds and arities, and the |
| 181 | // same error where it reaches for a document node does not have. A |
| 182 | // stripper that ate code a regex was hiding would change one of them. |
| 183 | func TestEveryShippedScriptStripsToTheSameShape(t *testing.T) { |
| 184 | names := fiesta.ScriptNames() |
| 185 | var srcs []string |
| 186 | for _, name := range names { |
| 187 | body, err := fs.ReadFile(fiesta.FS(), "js/"+name) |
| 188 | if err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | srcs = append(srcs, string(body), string(stripJSComments(body))) |
| 192 | } |
| 193 | in, err := json.Marshal(srcs) |
| 194 | if err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | var got []string |
| 198 | if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", shapeEval}, Stdin: in}), &got); err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | for i, name := range names { |
| 202 | if got[2*i] != got[2*i+1] { |
| 203 | t.Errorf("%s evaluates differently stripped:\n as written: %s\n stripped: %s", name, got[2*i], got[2*i+1]) |
| 204 | } |
| 205 | t.Logf("%s: %s", name, got[2*i]) |
| 206 | if strings.Contains(got[2*i], "SyntaxError") { |
| 207 | t.Errorf("%s does not even parse as written: %s", name, got[2*i]) |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | // listenersEval loads each script into a stub page, a loading document |
| 213 | // with a root, a window and the constructors the scripts reach for on |
| 214 | // load, and reports what it did there: every listener type it put on |
| 215 | // the document, the window and the root, in order, each exported |
| 216 | // function's source, and the error it stopped at, if any. The shape |
| 217 | // test above runs the scripts with no document, where five of them |
| 218 | // stop at once; here they run their page half as far as a stub lets |
| 219 | // them, which is where a changed event name or a changed body shows. |
| 220 | const listenersEval = ` |
| 221 | const vm = require("vm"); |
| 222 | const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); |
| 223 | const element = () => { |
| 224 | const attrs = {}; |
| 225 | const el = { |
| 226 | getAttribute: (k) => (k in attrs ? attrs[k] : null), hasAttribute: (k) => k in attrs, |
| 227 | setAttribute: (k, v) => { attrs[k] = String(v); }, removeAttribute: (k) => { delete attrs[k]; }, |
| 228 | toggleAttribute: (k) => { if (k in attrs) delete attrs[k]; else attrs[k] = ""; return k in attrs; }, |
| 229 | addEventListener() {}, removeEventListener() {}, dispatchEvent: () => true, |
| 230 | querySelectorAll: () => [], querySelector: () => null, matches: () => false, closest: () => null, |
| 231 | classList: { add() {}, remove() {}, contains: () => false, toggle() {} }, style: { setProperty() {}, removeProperty() {} }, |
| 232 | dataset: {}, lang: "en", contains: () => false, appendChild: (c) => c, append() {}, insertBefore: (c) => c, |
| 233 | remove() {}, isConnected: true, parentNode: null, parentElement: null, children: [], childNodes: [], |
| 234 | getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }), |
| 235 | getClientRects: () => [], focus() {}, blur() {}, textContent: "", innerHTML: "", id: "", tagName: "DIV", |
| 236 | }; |
| 237 | return el; |
| 238 | }; |
| 239 | process.stdout.write(JSON.stringify(srcs.map((src) => { |
| 240 | const listeners = []; |
| 241 | const on = (target) => (type) => { listeners.push(target + ":" + type); }; |
| 242 | const root = element(); |
| 243 | root.addEventListener = on("root"); |
| 244 | const body = element(); |
| 245 | const document = Object.assign(element(), { |
| 246 | readyState: "loading", documentElement: root, body, head: element(), activeElement: null, |
| 247 | addEventListener: on("document"), createElement: () => element(), createTextNode: () => ({}), |
| 248 | getElementById: () => null, cookie: "", |
| 249 | }); |
| 250 | const window = { |
| 251 | addEventListener: on("window"), removeEventListener() {}, innerHeight: 600, innerWidth: 800, |
| 252 | matchMedia: () => ({ matches: false, addEventListener() {}, onchange: null }), |
| 253 | localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, |
| 254 | requestAnimationFrame: () => 0, cancelAnimationFrame() {}, getComputedStyle: () => ({ getPropertyValue: () => "" }), |
| 255 | visualViewport: { addEventListener() {}, removeEventListener() {}, height: 600, width: 800, offsetTop: 0 }, |
| 256 | CSS: { supports: () => false }, navigator: { userAgent: "" }, location: { protocol: "http:", href: "http://x/", pathname: "/" }, |
| 257 | history: { replaceState() {} }, scrollTo() {}, scrollX: 0, scrollY: 0, |
| 258 | }; |
| 259 | const module = { exports: {} }; |
| 260 | const ctx = Object.assign(Object.create(null), window, { |
| 261 | window, self: window, document, module, exports: module.exports, |
| 262 | MutationObserver: class { observe() {} disconnect() {} takeRecords() { return []; } }, |
| 263 | ResizeObserver: class { observe() {} disconnect() {} }, |
| 264 | IntersectionObserver: class { observe() {} disconnect() {} }, |
| 265 | CustomEvent, Event, AbortController, Intl, setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, queueMicrotask, console, |
| 266 | HTMLElement: class {}, HTMLInputElement: class {}, Node: class {}, Element: class {}, |
| 267 | }); |
| 268 | let err = ""; |
| 269 | try { vm.runInNewContext(src, ctx); } catch (e) { err = e.name + ": " + e.message; } |
| 270 | const exported = {}; |
| 271 | for (const k of Object.keys(module.exports).sort()) { |
| 272 | const v = module.exports[k]; |
| 273 | exported[k] = typeof v === "function" ? String(v) : JSON.stringify(v); |
| 274 | } |
| 275 | return { listeners, exported, err }; |
| 276 | }))); |
| 277 | ` |
| 278 | |
| 279 | // sameShape is a function's source with every run of whitespace made |
| 280 | // one space: the stripped file's function is a slice of a whole file |
| 281 | // the stripper reshaped, and the original's, stripped on its own, can |
| 282 | // differ from it only in how its whitespace fell. |
| 283 | func sameShape(src string) string { |
| 284 | return strings.Join(strings.Fields(src), " ") |
| 285 | } |
| 286 | |
| 287 | // TestStrippedScriptsRegisterTheSameListeners: on the stub page, every |
| 288 | // shipped script as written and as stripped registers the same listener |
| 289 | // types on the same targets in the same order, exports the same |
| 290 | // functions with the same bodies (comments aside), and stops at the |
| 291 | // same place if it stops. A stripper that changed one event name, or |
| 292 | // ate a line a regex hid, changes one of these; the shape test above |
| 293 | // cannot see either, since with no document five scripts stop before |
| 294 | // their first listener. fiesta.js must get as far as its touchstart |
| 295 | // listener, or the check has not reached the page half at all. |
| 296 | func TestStrippedScriptsRegisterTheSameListeners(t *testing.T) { |
| 297 | names := fiesta.ScriptNames() |
| 298 | var srcs []string |
| 299 | for _, name := range names { |
| 300 | body, err := fs.ReadFile(fiesta.FS(), "js/"+name) |
| 301 | if err != nil { |
| 302 | t.Fatal(err) |
| 303 | } |
| 304 | srcs = append(srcs, string(body), string(stripJSComments(body))) |
| 305 | } |
| 306 | in, err := json.Marshal(srcs) |
| 307 | if err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | type ran struct { |
| 311 | Listeners []string `json:"listeners"` |
| 312 | Exported map[string]string `json:"exported"` |
| 313 | Err string `json:"err"` |
| 314 | } |
| 315 | var got []ran |
| 316 | if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", listenersEval}, Stdin: in}), &got); err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | for i, name := range names { |
| 320 | as, stripped := got[2*i], got[2*i+1] |
| 321 | t.Logf("%s: %d listeners %v, %d exports, stopped at %q", name, len(as.Listeners), as.Listeners, len(as.Exported), as.Err) |
| 322 | if strings.Join(as.Listeners, "\n") != strings.Join(stripped.Listeners, "\n") { |
| 323 | t.Errorf("%s registers different listeners stripped:\n as written: %v\n stripped: %v", name, as.Listeners, stripped.Listeners) |
| 324 | } |
| 325 | if as.Err != stripped.Err { |
| 326 | t.Errorf("%s stops differently stripped:\n as written: %s\n stripped: %s", name, as.Err, stripped.Err) |
| 327 | } |
| 328 | for k, src := range as.Exported { |
| 329 | want := sameShape(string(stripJSComments([]byte(src)))) |
| 330 | if got := sameShape(stripped.Exported[k]); got != want { |
| 331 | t.Errorf("%s exports a different %s stripped:\n as written, stripped alone: %s\n from the stripped file: %s", name, k, want, got) |
| 332 | } |
| 333 | } |
| 334 | if len(as.Exported) != len(stripped.Exported) { |
| 335 | t.Errorf("%s exports %d names as written and %d stripped", name, len(as.Exported), len(stripped.Exported)) |
| 336 | } |
| 337 | if name == "fiesta.js" && !slices.Contains(as.Listeners, "document:touchstart") { |
| 338 | 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) |
| 339 | } |
| 340 | } |
| 341 | } |
| 342 | |