Skip to main content

max / goingson

24.7 KB · 646 lines History Blame Raw
1 #!/usr/bin/env node
2 /**
3 * GO Frontend JS Test Runner
4 *
5 * Sets up the global environment once, loads all source modules,
6 * then runs all test suites.
7 *
8 * Usage: node src-tauri/frontend/js/tests/run.js
9 */
10
11 const { describe, test, assert, assertEqual, assertDeepEqual, report } = require('./test-runner');
12 const fs = require('fs');
13 const path = require('path');
14
15 console.log('Running GO frontend tests...\n');
16
17 // ============================================================
18 // Global environment setup (mocks for browser APIs)
19 // ============================================================
20
21 const mockElements = {};
22
23 function createMockElement(tag) {
24 return {
25 tagName: (tag || 'div').toUpperCase(),
26 className: '',
27 id: '',
28 style: {},
29 dataset: {},
30 innerHTML: '',
31 _text: '',
32 set textContent(v) {
33 this._text = v;
34 this.innerHTML = String(v)
35 .replace(/&/g, '&')
36 .replace(/</g, '&lt;')
37 .replace(/>/g, '&gt;')
38 .replace(/"/g, '&quot;')
39 .replace(/'/g, '&#039;');
40 },
41 get textContent() { return this._text; },
42 children: [],
43 classList: {
44 _classes: new Set(),
45 add(c) { this._classes.add(c); },
46 remove(c) { this._classes.delete(c); },
47 contains(c) { return this._classes.has(c); },
48 },
49 attributes: [],
50 _listeners: {},
51 setAttribute(k, v) { this[k] = v; },
52 getAttribute(k) { return this[k] || null; },
53 removeAttribute(k) { delete this[k]; },
54 addEventListener(ev, fn) { this._listeners[ev] = fn; },
55 querySelector() { return createMockElement('span'); },
56 querySelectorAll() { return []; },
57 appendChild(child) { this.children.push(child); },
58 remove() {},
59 parentNode: { insertBefore() {} },
60 };
61 }
62
63 globalThis.document = {
64 createElement: createMockElement,
65 getElementById: (id) => {
66 if (!mockElements[id]) {
67 mockElements[id] = createMockElement('div');
68 mockElements[id].id = id;
69 }
70 return mockElements[id];
71 },
72 querySelectorAll: () => [],
73 };
74
75 globalThis.window = { GoingsOn: {} };
76 globalThis.GoingsOn = window.GoingsOn;
77
78 // ============================================================
79 // Load source modules (same order as index.html)
80 // ============================================================
81
82 require('../goingson'); // Initializes GoingsOn namespace
83 require('../escape'); // GoingsOn.escape (escaping primitives, single source)
84 require('../state'); // GoingsOn.state (AppStateManager)
85 require('../utils'); // GoingsOn.utils (re-exports GoingsOn.escape.*)
86 require('../pagination-manager'); // GoingsOn.PaginationManager
87 require('../selection-manager'); // GoingsOn.SelectionManager
88 require('../whats-new'); // GoingsOn.whatsNew (changelog parser/renderer)
89
90 // ============================================================
91 // Test: AppStateManager / GoingsOn.state
92 // ============================================================
93
94 describe('GoingsOn.state', () => {
95 test('set() stores value and notifies subscribers', () => {
96 let called = false;
97 GoingsOn.state.subscribe('_t1', () => { called = true; });
98 GoingsOn.state.set('_t1', 'hello');
99 assert(called, 'Subscriber should fire');
100 assertEqual(GoingsOn.state._t1, 'hello');
101 });
102
103 test('subscribe() returns working unsubscribe function', () => {
104 let count = 0;
105 const unsub = GoingsOn.state.subscribe('_t2', () => { count++; });
106 GoingsOn.state.set('_t2', 'a');
107 assertEqual(count, 1);
108 unsub();
109 GoingsOn.state.set('_t2', 'b');
110 assertEqual(count, 1);
111 });
112
113 test('notify() passes new and old values to callback', () => {
114 GoingsOn.state.set('_t3', 'first');
115 let capturedOld, capturedNew;
116 GoingsOn.state.subscribe('_t3', (n, o) => { capturedNew = n; capturedOld = o; });
117 GoingsOn.state.set('_t3', 'second');
118 assertEqual(capturedOld, 'first');
119 assertEqual(capturedNew, 'second');
120 });
121
122 test('update() batch-updates multiple properties', () => {
123 let aFired = false, bFired = false;
124 GoingsOn.state.subscribe('_t4a', () => { aFired = true; });
125 GoingsOn.state.subscribe('_t4b', () => { bFired = true; });
126 GoingsOn.state.update({ _t4a: 'x', _t4b: 'y' });
127 assert(aFired && bFired, 'Both subscribers should fire');
128 assertEqual(GoingsOn.state._t4a, 'x');
129 assertEqual(GoingsOn.state._t4b, 'y');
130 });
131
132 test('resetPagination(task) resets taskPage to 1', () => {
133 GoingsOn.state.set('taskPage', 5);
134 GoingsOn.state.resetPagination('task');
135 assertEqual(GoingsOn.state.taskPage, 1);
136 });
137
138 test('resetPagination(email) resets emailPage to 1', () => {
139 GoingsOn.state.set('emailPage', 3);
140 GoingsOn.state.resetPagination('email');
141 assertEqual(GoingsOn.state.emailPage, 1);
142 });
143
144 test('clearSelection(task) clears selectedTaskIds Set', () => {
145 GoingsOn.state.selectedTaskIds.add('t1');
146 GoingsOn.state.selectedTaskIds.add('t2');
147 GoingsOn.state.clearSelection('task');
148 assertEqual(GoingsOn.state.selectedTaskIds.size, 0);
149 });
150
151 test('clearSelection(email) clears selectedEmailIds Set', () => {
152 GoingsOn.state.selectedEmailIds.add('e1');
153 GoingsOn.state.clearSelection('email');
154 assertEqual(GoingsOn.state.selectedEmailIds.size, 0);
155 });
156
157 test('multiple subscribers on same key all fire', () => {
158 let a = false, b = false;
159 GoingsOn.state.subscribe('_t5', () => { a = true; });
160 GoingsOn.state.subscribe('_t5', () => { b = true; });
161 GoingsOn.state.set('_t5', 'v');
162 assert(a && b, 'Both should fire');
163 });
164
165 test('subscriber error does not break other subscribers', () => {
166 let secondFired = false;
167 GoingsOn.state.subscribe('_t6', () => { throw new Error('boom'); });
168 GoingsOn.state.subscribe('_t6', () => { secondFired = true; });
169 // Suppress console.error for this test
170 const origError = console.error;
171 console.error = () => {};
172 GoingsOn.state.set('_t6', 'v');
173 console.error = origError;
174 assert(secondFired, 'Second subscriber should still fire');
175 });
176 });
177
178 // ============================================================
179 // Test: GoingsOn.utils — escapeAttr
180 // ============================================================
181
182 describe('GoingsOn.utils attribute escaping (CHRONIC-XSS)', () => {
183 test('the unsafe JS-string escaper is NOT exposed on the namespace', () => {
184 // escapeAttr backslash-escaped quotes, which does not stop HTML
185 // attribute breakout. It was renamed to a module-private escapeJsString
186 // so it can no longer be reached for attribute contexts.
187 assertEqual(GoingsOn.utils.escapeAttr, undefined);
188 assertEqual(GoingsOn.utils.escapeJsString, undefined);
189 });
190
191 test('escapeAttrValue entity-encodes so a value cannot break out of an attribute', () => {
192 const r = GoingsOn.utils.escapeAttrValue('x" onfocus=alert(1) autofocus="');
193 assert(!r.includes('"'), 'no raw double quote survives');
194 assert(r.includes('&quot;'), 'double quote is entity-encoded');
195 assert(GoingsOn.utils.escapeAttrValue('a&b').includes('&amp;'), 'ampersand encoded');
196 });
197
198 test('escapeAttrValue returns empty for null/undefined and stringifies', () => {
199 assertEqual(GoingsOn.utils.escapeAttrValue(null), '');
200 assertEqual(GoingsOn.utils.escapeAttrValue(undefined), '');
201 assertEqual(GoingsOn.utils.escapeAttrValue(123), '123');
202 });
203
204 test('escapeHandlerArg is safe on both the JS-string and HTML-attribute layers', () => {
205 // A value inside onclick="fn('${...}')" must survive both layers.
206 const r = GoingsOn.utils.escapeHandlerArg("it's \" evil");
207 assert(!r.includes('"'), 'no raw double quote (would close the attribute)');
208 assert(r.includes("\\'") || r.includes('&#') || r.includes('\\&'), 'single quote is JS-escaped');
209 // An id passes through unchanged.
210 assertEqual(GoingsOn.utils.escapeHandlerArg('abc-123'), 'abc-123');
211 });
212 });
213
214 // ============================================================
215 // Test: GoingsOn.utils — getErrorMessage
216 // ============================================================
217
218 describe('GoingsOn.utils.getErrorMessage', () => {
219 test('extracts from string', () => {
220 assertEqual(GoingsOn.utils.getErrorMessage('oops'), 'oops');
221 });
222
223 test('extracts from Error object (.message)', () => {
224 assertEqual(GoingsOn.utils.getErrorMessage(new Error('fail')), 'fail');
225 });
226
227 test('uses fallback for unknown type', () => {
228 assertEqual(GoingsOn.utils.getErrorMessage(42, 'fallback'), 'fallback');
229 });
230
231 test('uses default fallback when none provided', () => {
232 assertEqual(GoingsOn.utils.getErrorMessage({}, undefined), 'An error occurred');
233 });
234 });
235
236 // ============================================================
237 // Test: GoingsOn.utils — validateLength, validateEmail
238 // ============================================================
239
240 describe('GoingsOn.utils.validateLength', () => {
241 test('accepts valid length', () => {
242 assert(GoingsOn.utils.validateLength('hello', 10), 'Should accept');
243 });
244
245 test('rejects too long', () => {
246 assert(!GoingsOn.utils.validateLength('hello world', 5), 'Should reject');
247 });
248
249 test('accepts null/empty', () => {
250 assert(GoingsOn.utils.validateLength('', 10), 'Empty should be valid');
251 assert(GoingsOn.utils.validateLength(null, 10), 'Null should be valid');
252 });
253 });
254
255 describe('GoingsOn.utils.validateEmail', () => {
256 test('accepts valid addresses', () => {
257 assert(GoingsOn.utils.validateEmail('user@example.com'), 'Should accept valid');
258 });
259
260 test('rejects invalid', () => {
261 assert(!GoingsOn.utils.validateEmail('notanemail'), 'Should reject invalid');
262 });
263
264 test('accepts empty (optional field)', () => {
265 assert(GoingsOn.utils.validateEmail(''), 'Empty should be valid');
266 });
267 });
268
269 // ============================================================
270 // Test: GoingsOn.utils — parseEmailAddress
271 // ============================================================
272
273 describe('GoingsOn.utils.parseEmailAddress', () => {
274 test('extracts "Name <email>" format', () => {
275 const r = GoingsOn.utils.parseEmailAddress('Jane Smith <jane@example.com>');
276 assertEqual(r.name, 'Jane Smith');
277 assertEqual(r.email, 'jane@example.com');
278 });
279
280 test('handles bare email address', () => {
281 const r = GoingsOn.utils.parseEmailAddress('jane@example.com');
282 assertEqual(r.name, null);
283 assertEqual(r.email, 'jane@example.com');
284 });
285
286 test('handles null/empty input', () => {
287 const r1 = GoingsOn.utils.parseEmailAddress(null);
288 assertEqual(r1.name, null);
289 assertEqual(r1.email, null);
290 const r2 = GoingsOn.utils.parseEmailAddress('');
291 assertEqual(r2.name, null);
292 assertEqual(r2.email, null);
293 });
294 });
295
296 // ============================================================
297 // Test: GoingsOn.utils — debounce
298 // ============================================================
299
300 describe('GoingsOn.utils.debounce', () => {
301 test('does not fire immediately', () => {
302 let called = false;
303 const fn = GoingsOn.utils.debounce(() => { called = true; }, 10);
304 fn();
305 assert(!called, 'Should not fire immediately');
306 });
307
308 test('rapid calls only execute last one', () => {
309 let callCount = 0, lastArg = null;
310 const origST = globalThis.setTimeout;
311 const origCT = globalThis.clearTimeout;
312 let pendingCb = null;
313 globalThis.setTimeout = (cb) => { pendingCb = cb; return 1; };
314 globalThis.clearTimeout = () => { pendingCb = null; };
315
316 const fn = GoingsOn.utils.debounce((arg) => { callCount++; lastArg = arg; }, 100);
317 fn('a');
318 fn('b');
319 fn('c');
320 if (pendingCb) pendingCb();
321
322 assertEqual(callCount, 1);
323 assertEqual(lastArg, 'c');
324
325 globalThis.setTimeout = origST;
326 globalThis.clearTimeout = origCT;
327 });
328 });
329
330 // ============================================================
331 // Test: GoingsOn.utils — escapeHtml
332 // ============================================================
333
334 describe('GoingsOn.utils.escapeHtml', () => {
335 test('escapes angle brackets', () => {
336 const r = GoingsOn.utils.escapeHtml('<b>hi</b>');
337 assert(r.includes('&lt;') && r.includes('&gt;'));
338 });
339
340 test('returns empty for falsy', () => {
341 assertEqual(GoingsOn.utils.escapeHtml(''), '');
342 assertEqual(GoingsOn.utils.escapeHtml(null), '');
343 });
344 });
345
346 // ============================================================
347 // Test: PaginationManager
348 // ============================================================
349
350 describe('PaginationManager', () => {
351 test('constructor sets defaults (page=1, totalItems=0)', () => {
352 const pm = new GoingsOn.PaginationManager('test', 10);
353 assertEqual(pm.currentPage, 1);
354 assertEqual(pm.totalItems, 0);
355 assertEqual(pm.itemsPerPage, 10);
356 });
357
358 test('goToPage(next) increments page', () => {
359 const pm = new GoingsOn.PaginationManager('test', 10);
360 pm.totalItems = 30;
361 pm.goToPage('next');
362 assertEqual(pm.currentPage, 2);
363 });
364
365 test('goToPage(prev) decrements page, clamps to 1', () => {
366 const pm = new GoingsOn.PaginationManager('test', 10);
367 pm.totalItems = 30;
368 pm.currentPage = 2;
369 pm.goToPage('prev');
370 assertEqual(pm.currentPage, 1);
371 pm.goToPage('prev');
372 assertEqual(pm.currentPage, 1);
373 });
374
375 test('goToPage(n) sets specific page, clamped', () => {
376 const pm = new GoingsOn.PaginationManager('test', 10);
377 pm.totalItems = 30;
378 pm.goToPage(3);
379 assertEqual(pm.currentPage, 3);
380 pm.goToPage(99);
381 assertEqual(pm.currentPage, 3); // max page is 3
382 pm.goToPage(0);
383 assertEqual(pm.currentPage, 1);
384 });
385
386 test('getMaxPage() calculates ceil(total/perPage)', () => {
387 const pm = new GoingsOn.PaginationManager('test', 10);
388 pm.totalItems = 25;
389 assertEqual(pm.getMaxPage(), 3);
390 pm.totalItems = 30;
391 assertEqual(pm.getMaxPage(), 3);
392 pm.totalItems = 0;
393 assertEqual(pm.getMaxPage(), 1); // min 1
394 });
395
396 test('setTotalItems() updates and clamps currentPage', () => {
397 const pm = new GoingsOn.PaginationManager('test', 10);
398 pm.currentPage = 5;
399 pm.setTotalItems(20);
400 assertEqual(pm.totalItems, 20);
401 assertEqual(pm.currentPage, 2); // clamped to max page
402 });
403
404 test('reset() returns to page 1', () => {
405 const pm = new GoingsOn.PaginationManager('test', 10);
406 pm.currentPage = 3;
407 pm.reset();
408 assertEqual(pm.currentPage, 1);
409 });
410
411 test('getOffset() returns (page-1)*perPage', () => {
412 const pm = new GoingsOn.PaginationManager('test', 10);
413 pm.currentPage = 3;
414 assertEqual(pm.getOffset(), 20);
415 });
416
417 test('paginate() slices array correctly', () => {
418 const pm = new GoingsOn.PaginationManager('test', 3);
419 pm.totalItems = 7;
420 const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
421 pm.currentPage = 2;
422 assertDeepEqual(pm.paginate(items), ['d', 'e', 'f']);
423 pm.currentPage = 3;
424 assertDeepEqual(pm.paginate(items), ['g']);
425 });
426
427 test('getInfo() returns correct summary object', () => {
428 const pm = new GoingsOn.PaginationManager('test', 10);
429 pm.totalItems = 25;
430 pm.currentPage = 2;
431 const info = pm.getInfo();
432 assertEqual(info.currentPage, 2);
433 assertEqual(info.maxPage, 3);
434 assertEqual(info.start, 11);
435 assertEqual(info.end, 20);
436 assertEqual(info.total, 25);
437 assertEqual(info.hasPrev, true);
438 assertEqual(info.hasNext, true);
439 });
440 });
441
442 // ============================================================
443 // Test: SelectionManager
444 // ============================================================
445
446 describe('SelectionManager', () => {
447 test('constructor initializes empty Set', () => {
448 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
449 assertEqual(sm.selectedIds.size, 0);
450 assertEqual(sm.lastClickedIndex, -1);
451 });
452
453 test('setItems() stores items array', () => {
454 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
455 const items = [{ id: '1' }, { id: '2' }];
456 sm.setItems(items);
457 assertEqual(sm.items.length, 2);
458 });
459
460 test('getSelected() returns the Set', () => {
461 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
462 sm.selectedIds.add('x');
463 assert(sm.getSelected() instanceof Set);
464 assert(sm.getSelected().has('x'));
465 });
466
467 test('hasSelection() returns true when items selected, false when empty', () => {
468 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
469 assert(!sm.hasSelection());
470 sm.selectedIds.add('a');
471 assert(sm.hasSelection());
472 });
473
474 test('getCount() returns correct count', () => {
475 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
476 assertEqual(sm.getCount(), 0);
477 sm.selectedIds.add('a');
478 sm.selectedIds.add('b');
479 assertEqual(sm.getCount(), 2);
480 });
481
482 test('isSelected(id) returns boolean correctly', () => {
483 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
484 sm.selectedIds.add('x');
485 assert(sm.isSelected('x'));
486 assert(!sm.isSelected('y'));
487 });
488
489 test('toggle adds/removes from selectedIds', () => {
490 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
491 sm.setItems([{ id: 'a' }, { id: 'b' }]);
492 // Simulate checking
493 sm.toggle('a', { checked: true }, null);
494 assert(sm.selectedIds.has('a'));
495 // Simulate unchecking
496 sm.toggle('a', { checked: false }, null);
497 assert(!sm.selectedIds.has('a'));
498 });
499
500 test('clear() empties Set and resets lastClickedIndex', () => {
501 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
502 sm.selectedIds.add('a');
503 sm.selectedIds.add('b');
504 sm.lastClickedIndex = 3;
505 sm.clear();
506 assertEqual(sm.selectedIds.size, 0);
507 assertEqual(sm.lastClickedIndex, -1);
508 });
509 });
510
511 // ============================================================
512 // Test: GoingsOn.whatsNew (changelog parsing + rendering)
513 // ============================================================
514
515 describe('GoingsOn.whatsNew', () => {
516 const SAMPLE = [
517 '# Changelog',
518 '',
519 '## [0.4.0] — 2026-06-01',
520 '',
521 'Polish release.',
522 '',
523 '### Added',
524 '- What\'s New dialog after updates',
525 '- Standardized empty states',
526 '',
527 '### Fixed',
528 '- Project list empty copy',
529 '',
530 '## [0.3.0] — 2026-03-28',
531 '',
532 '### Added',
533 '- Initial beta',
534 ].join('\n');
535
536 test('extractSection pulls the requested version body, excluding its header', () => {
537 const section = GoingsOn.whatsNew.extractSection(SAMPLE, '0.4.0');
538 assert(section.includes('Polish release.'), 'keeps the intro line');
539 assert(section.includes('### Added'), 'keeps group headers');
540 assert(section.includes('What\'s New dialog after updates'), 'keeps bullets');
541 assert(!section.includes('[0.4.0]'), 'drops the version header line');
542 assert(!section.includes('Initial beta'), 'stops before the next version');
543 });
544
545 test('extractSection returns empty string for an absent version', () => {
546 assertEqual(GoingsOn.whatsNew.extractSection(SAMPLE, '9.9.9'), '');
547 });
548
549 test('renderSection escapes content and builds structural markup', () => {
550 const html = GoingsOn.whatsNew.renderSection('### Added\n- safe <script> item');
551 assert(html.includes('<h3 class="whats-new-group">Added</h3>'), 'renders group heading');
552 assert(html.includes('<ul class="whats-new-list">'), 'opens a list');
553 assert(html.includes('&lt;script&gt;'), 'escapes HTML in bullets');
554 assert(!html.includes('<script>'), 'no raw markup leaks through');
555 });
556 });
557
558 // ============================================================
559 // Enforcement gate: HTML-attribute escaping (CHRONIC-XSS)
560 //
561 // Static-scan every frontend source file for the wrong-shaped escaping that
562 // has recurred across multiple ultra-fuzz runs. Making these patterns a build
563 // failure is the structural fix: the bad pattern is no longer writable.
564 // - `utils.escapeAttr` (the old JS-string escaper) must not reappear.
565 // - `esc(` (escapeHtml, which does not encode ") must not be used directly
566 // inside a quoted attribute value.
567 // ============================================================
568
569 describe('escaping enforcement gate (CHRONIC-XSS)', () => {
570 const srcDir = path.join(__dirname, '..');
571 const files = fs.readdirSync(srcDir).filter((f) => f.endsWith('.js'));
572
573 // The renamed JS-string escaper, not followed by another identifier char,
574 // so `escapeAttrValue` is not matched.
575 const OLD_NAME = /utils\.escapeAttr(?![A-Za-z])/;
576 // escapeHtml alias used directly inside a single- or double-quoted attribute.
577 const ESC_IN_ATTR = /=["']\$\{\s*esc\(/;
578 // An attribute escaper (escAttr / escapeAttrValue, any namespace) used inside
579 // a single-quoted JS string, i.e. an inline-handler argument like
580 // `onclick="fn('${escAttr(x)}')"`. escapeAttrValue does not escape `'` or `\`,
581 // so it cannot protect the JS-string layer — escapeHandlerArg is required.
582 // The leading `'` anchor keeps this off legitimate double-quoted attribute
583 // uses like `title="${escAttr(x)}"`.
584 const ATTR_ESC_IN_HANDLER_ARG = /'\$\{\s*(?:[A-Za-z_$][\w$]*\.)*(?:escAttr(?:Val(?:ue)?)?|escapeAttrValue)\s*\(/;
585
586 test('no source file references the removed utils.escapeAttr', () => {
587 const offenders = [];
588 for (const f of files) {
589 const src = fs.readFileSync(path.join(srcDir, f), 'utf8');
590 src.split('\n').forEach((line, i) => {
591 if (OLD_NAME.test(line)) offenders.push(`${f}:${i + 1} ${line.trim()}`);
592 });
593 }
594 assert(offenders.length === 0,
595 `escapeAttr (unsafe JS-string escaper) reintroduced:\n ${offenders.join('\n ')}`);
596 });
597
598 test('no escapeHtml (esc) is interpolated directly into a quoted attribute', () => {
599 const offenders = [];
600 for (const f of files) {
601 const src = fs.readFileSync(path.join(srcDir, f), 'utf8');
602 src.split('\n').forEach((line, i) => {
603 if (ESC_IN_ATTR.test(line)) offenders.push(`${f}:${i + 1} ${line.trim()}`);
604 });
605 }
606 assert(offenders.length === 0,
607 `esc() used in an attribute (use escapeAttrValue):\n ${offenders.join('\n ')}`);
608 });
609
610 test('no attribute escaper is used for an inline-handler argument (use escapeHandlerArg)', () => {
611 const offenders = [];
612 for (const f of files) {
613 const src = fs.readFileSync(path.join(srcDir, f), 'utf8');
614 src.split('\n').forEach((line, i) => {
615 if (ATTR_ESC_IN_HANDLER_ARG.test(line)) offenders.push(`${f}:${i + 1} ${line.trim()}`);
616 });
617 }
618 assert(offenders.length === 0,
619 `attribute escaper inside a quoted JS-string handler arg (use escapeHandlerArg):\n ${offenders.join('\n ')}`);
620 });
621
622 // The hand-rolled attribute escaper `.replace(/"/g, '&quot;')` is the exact
623 // shape the compose window used to duplicate outside this gate's reach.
624 // escape.js is the single source of truth; no other file may re-implement it.
625 const HANDROLLED_ATTR = /replace\(\/"\/g,\s*['"]&quot;['"]\)/;
626 test('no file re-implements an attribute escaper (single source: escape.js)', () => {
627 const offenders = [];
628 for (const f of files) {
629 if (f === 'escape.js') continue;
630 const src = fs.readFileSync(path.join(srcDir, f), 'utf8');
631 src.split('\n').forEach((line, i) => {
632 if (HANDROLLED_ATTR.test(line)) offenders.push(`${f}:${i + 1} ${line.trim()}`);
633 });
634 }
635 assert(offenders.length === 0,
636 `hand-rolled attribute escaper found (use GoingsOn.escape / GoingsOn.utils.escapeAttrValue):\n ${offenders.join('\n ')}`);
637 });
638 });
639
640 // ============================================================
641 // Report
642 // ============================================================
643
644 const success = report();
645 process.exit(success ? 0 : 1);
646