rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package build
2
3import (
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.
21func 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.
41func 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.
64func 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
86func 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.
103const nodeEval = `
104const vm = require("vm");
105const 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
116func 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.
133func 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.
165const shapeEval = `
166const vm = require("vm");
167const srcs = JSON.parse(require("fs").readFileSync(0, "utf8"));
168const shape = v => v === null ? "null" : typeof v === "function" ? "function/" + v.length : typeof v;
169const describe = o => Object.keys(o).sort().map(k => k + ":" + shape(o[k])).join(",");
170process.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.
183func 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.
220const listenersEval = `
221const vm = require("vm");
222const srcs = JSON.parse(require("fs").readFileSync(0, "utf8"));
223const 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};
239process.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.
283func 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.
296func 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