aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/gen-floating-designer-registry.py117
-rw-r--r--scripts/test-floating-designer.mjs144
2 files changed, 261 insertions, 0 deletions
diff --git a/scripts/gen-floating-designer-registry.py b/scripts/gen-floating-designer-registry.py
new file mode 100644
index 0000000..1b150d6
--- /dev/null
+++ b/scripts/gen-floating-designer-registry.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""gen-floating-designer-registry.py — Task 4 of the v2->v3 repair plan. (v2)
+
+Rebuilds the floating-ui-designer CMDS registry from ground truth:
+ * union of `commands['X']` registrations in modules/core + modules/ext
+ (bundle/worker files skipped), and
+ * terminal.js dispatch builtins (`cmd === 'x'`).
+
+Emits /tmp/fui_cmds_snippet.txt with one JS line per command:
+ ['name','desc','cat',1],
+Descriptions are re-escaped safely: apostrophes -> \u2019, backslashes -> \u005C
+(no raw backslash pairs ever enter the page). Prints acceptance diagnostics.
+"""
+import os, re
+from collections import Counter
+
+BASE = '/var/www/webx11.duckdns.org/spaces/newos4'
+OUT = '/tmp/fui_cmds_snippet.txt'
+
+files = []
+for d in ('modules/core', 'modules/ext'):
+ dd = os.path.join(BASE, d)
+ for f in sorted(os.listdir(dd)):
+ if not f.endswith('.js'):
+ continue
+ if 'bundle' in f or 'worker' in f:
+ continue
+ files.append(os.path.join(d, f))
+
+texts = {}
+for rel in files:
+ try:
+ texts[rel] = open(os.path.join(BASE, rel), encoding='utf-8', errors='replace').read()
+ except Exception as e:
+ print('skip', rel, e)
+
+cmd_re = re.compile(r"commands\['([^']+)'\]\s*=")
+src_of = {}
+for rel, txt in texts.items():
+ for name in cmd_re.findall(txt):
+ src_of.setdefault(name, set()).add(rel)
+
+disp = set()
+t = texts.get('modules/core/terminal.js', '')
+for m in re.finditer(r"cmd === '([a-z0-9-]+)'", t):
+ disp.add(m.group(1))
+for n in disp:
+ src_of.setdefault(n, set()).add('modules/core/terminal.js')
+
+ALL = sorted(src_of.keys())
+
+def cat_for(rels):
+ joined = ' '.join(rels)
+ rules = [
+ (('aiassist', 'aiasm', 'smartctx', 'chatstudio', 'ollama'), 'ai'),
+ (('agent-registry', 'orchestrator', 'leases', 'memory-graph', 'world-model'), 'agents'),
+ (('module-fs', 'busybox', 'shell', 'filemgr', 'editor', 'finder', 'diff', 'neural-vfs', 'cryptogzip'), 'files'),
+ (('searxng', 'fetcher', 'webdav'), 'network'),
+ (('calc', 'mindmap', 'three', 'media', 'voice', 'wpm', 'tutorial', 'console'), 'tools'),
+ (('floating', 'theme', 'kanban'), 'ui'),
+ (('system', 'config', 'modmgr', 'debug-supervisor', 'predictor', 'servermon', 'sandbox', 'secret-vault', 'terminal'), 'system'),
+ ]
+ for keys, cat in rules:
+ if any(k in joined for k in keys):
+ return cat
+ return 'system'
+
+DESC_FALLBACK = 'Terminal command'
+
+def desc_for(name):
+ pat = re.compile(r"'" + re.escape(name) + r"'\s*:\s*'((?:[^'\\]|\\.){1,200})'")
+ for rel in sorted(texts.keys()):
+ m = pat.search(texts[rel])
+ if not m:
+ continue
+ raw = m.group(1)
+ # unescape source-level escaping: \\ -> placeholder, \' and \" -> quote
+ raw = raw.replace('\\\\', '\x01')
+ raw = raw.replace("\\'", "'").replace('\\"', '"')
+ raw = raw.replace('\x01', '\\')
+ # strip control chars
+ raw = ''.join(' ' if ord(c) < 32 else c for c in raw)
+ if '\u2014' in raw:
+ raw = raw.split('\u2014', 1)[1].strip()
+ if not raw or raw.lower() == name:
+ continue
+ if len(raw) > 90:
+ raw = raw[:87].rstrip() + '...'
+ return raw
+ return DESC_FALLBACK
+
+entries = []
+for n in ALL:
+ d = desc_for(n)
+ d = d.replace('\\', '\\u005C').replace("'", '\\u2019')
+ entries.append((cat_for(src_of[n]), n, d))
+
+entries.sort(key=lambda e: (e[0], e[1]))
+
+cnt = Counter(c for c, _, _ in entries)
+print('total=%d' % len(entries))
+for k in sorted(cnt):
+ print(' %-8s %d' % (k, cnt[k]))
+
+want = ['session-new', 'health-check', 'doctor', 'uptime', 'ai-stop', 'kanban', 'webdav-browser', 'vfs-export', 'fetch']
+bad = ['new-session', 'clipboard-history', 'web', 'save', 'load', 'import', 'svg']
+have = set(e[1] for e in entries)
+print('missing:', [w for w in want if w not in have])
+print('excluded-present:', [b for b in bad if b in have])
+print('no-desc fallback count:', sum(1 for _, _, d in entries if d == DESC_FALLBACK))
+
+assert len(entries) >= 330, 'TOO FEW COMMANDS: %d' % len(entries)
+
+with open(OUT, 'w', encoding='utf-8') as f:
+ for cat, n, d in entries:
+ f.write("['%s','%s','%s',1],\n" % (n, d, cat))
+print('WROTE', OUT)
diff --git a/scripts/test-floating-designer.mjs b/scripts/test-floating-designer.mjs
new file mode 100644
index 0000000..3647683
--- /dev/null
+++ b/scripts/test-floating-designer.mjs
@@ -0,0 +1,144 @@
+#!/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);