#!/usr/bin/env node /* * test-floating-designer.mjs — behavioral + byte-level regression tests for * downloads/floating-ui-designer.html (newOS4 Floating UI Designer). * * Usage: node scripts/test-floating-designer.mjs * Exit code 0 = all pass; 1 = failures (details printed). * * Created 2026-10-17 during the v2 -> v3 repair * (see .hermes/plans/2026-10-17_195836-floating-ui-designer-review-fixes.md). */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join as pjoin } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const PAGE = pjoin(here, '..', 'downloads', 'floating-ui-designer.html'); const src = readFileSync(PAGE, 'utf8'); const raw = readFileSync(PAGE); // Buffer for byte-level checks const BS = String.fromCharCode(92); // backslash /* ---------- tiny test runner ---------- */ const results = []; function check(name, fn) { try { fn(); results.push(['ok', name]); } catch (e) { results.push(['FAIL', name + ' -- ' + e.message]); } } function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); } function eq(a, b, msg) { if (a !== b) throw new Error((msg || '') + ' | expected ' + JSON.stringify(b) + ' got ' + JSON.stringify(a)); } function deepEq(a, b, msg) { const A = JSON.stringify(a), B = JSON.stringify(b); if (A !== B) throw new Error((msg || '') + ' | expected ' + B + ' got ' + A); } /* ---------- extract JS pieces from the page ---------- */ function sliceBetween(source, from, to) { const i = source.indexOf(from); if (i === -1) throw new Error('marker not found: ' + from); const j = source.indexOf(to, i + from.length); if (j === -1) throw new Error('marker not found: ' + to); return source.slice(i, j); } function extractFn(source, name) { const start = source.indexOf('function ' + name + '('); if (start === -1) throw new Error('function not found: ' + name); let depth = 0, i = source.indexOf('{', start); for (; i < source.length; i++) { if (source[i] === '{') depth++; else if (source[i] === '}') { depth--; if (depth === 0) { i++; break; } } } return source.slice(start, i); } const cmdsBlock = sliceBetween(src, 'var CMDS = [', 'var KNOWN'); const knownBlock = sliceBetween(src, 'var KNOWN', 'var state = {'); const code = cmdsBlock + knownBlock + ['q', 'slug', 'validateCmd', 'tokenize', 'parseFA'].map(n => extractFn(src, n)).join('\n') + '\nreturn { CMDS, KNOWN, q, slug, validateCmd, tokenize, parseFA };'; const api = new Function(code)(); /* ---------- behavioral checks ---------- */ const LINE = 'floating-add menu files-menu "🗂️ Files" btn:List:ls btn:Cfg:"ai-config confirm-timeout 0"'; check('q() quotes multi-word labels', () => eq(api.q('My Label'), '"My Label"')); check('q() quotes multi-word commands', () => eq(api.q('cmd with args'), '"cmd with args"')); check('q() leaves single words bare', () => eq(api.q('ls'), 'ls')); check('slug() normalizes spaced label', () => eq(api.slug('New Session'), 'new-session')); check('slug() drops emoji + space', () => eq(api.slug('🗂️ Files'), 'files')); check('tokenize() 6 tokens, quotes stripped, inner spaces kept', () => { const t = api.tokenize(LINE); eq(t.length, 6, 'token count'); eq(t[3], '🗂️ Files', 'label token'); eq(t[5], 'btn:Cfg:ai-config confirm-timeout 0', 'quoted command token'); }); check('parseFA() parses quoted menu items', () => { const fa = api.parseFA(LINE); assert(fa, 'parseFA returned null'); eq(fa.type, 'menu'); eq(fa.id, 'files-menu'); deepEq(fa.items, [ { label: 'List', command: 'ls' }, { label: 'Cfg', command: 'ai-config confirm-timeout 0' }, ]); }); check('validateCmd(): real cmd -> ok', () => eq(api.validateCmd('ai-config confirm-timeout 0').level, 'ok')); check('validateCmd(): unknown -> warn', () => eq(api.validateCmd('nope-cmd').level, 'warn')); check('validateCmd(): empty -> err', () => eq(api.validateCmd('').level, 'err')); /* ---------- byte-level checks ---------- */ check('bytes: zero double-backslash pairs in file', () => { let n = 0; for (let i = 0; i < raw.length - 1; i++) if (raw[i] === 92 && raw[i + 1] === 92) n++; eq(n, 0, 'double-backslash byte pairs'); }); const latin = raw.toString('latin1'); const JOIN_N = "join('" + BS + "n')"; const SPLIT_N = "split('" + BS + "n')"; function occurrences(hay, needle) { let n = 0, i = 0; while ((i = hay.indexOf(needle, i)) !== -1) { n++; i += needle.length; } return n; } check('bytes: join newline sites present (>=2)', () => { const n = occurrences(latin, JOIN_N); assert(n >= 2, 'join(' + BS + 'n) count = ' + n); }); check('bytes: split newline site present (>=1)', () => { const n = occurrences(latin, SPLIT_N); assert(n >= 1, 'split(' + BS + 'n) count = ' + n); }); /* ---------- registry checks ---------- */ check('registry: >= 330 commands', () => assert(api.CMDS.length >= 330, 'CMDS length = ' + api.CMDS.length)); check('registry: contains new commands', () => { const have = new Set(api.CMDS.map(c => c[0])); const want = ['session-new', 'health-check', 'doctor', 'uptime', 'ai-stop', 'kanban', 'webdav-browser', 'vfs-export', 'fetch']; const missing = want.filter(w => !have.has(w)); eq(missing.length, 0, 'missing: ' + missing.join(', ')); }); check('registry: no fake entries', () => { const have = new Set(api.CMDS.map(c => c[0])); const bad = ['new-session', 'clipboard-history', 'web', 'save', 'load', 'import', 'svg']; const present = bad.filter(b => have.has(b)); eq(present.length, 0, 'should not contain: ' + present.join(', ')); }); /* ---------- report ---------- */ let failed = 0; for (const [st, name] of results) { console.log(st + ' ' + name); if (st !== 'ok') failed++; } console.log('----'); console.log(failed === 0 ? 'ALL PASS (' + results.length + ' checks)' : failed + ' FAILED of ' + results.length + ' checks'); process.exit(failed ? 1 : 0);