343 lines
15 KiB
JavaScript
343 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ObsiGate — Frontend unit tests (pure functions, no DOM required).
|
|
* Usage: node tests/frontend/unit.test.mjs
|
|
*/
|
|
|
|
import { strict as assert } from 'assert';
|
|
|
|
// ── Test escapeHtml (from utils.js) ────────────────────────────────────────
|
|
// We test the logic directly since utils.js imports from state.js which needs DOM
|
|
const escapeHtml = (str) => {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
};
|
|
|
|
function testEscapeHtml() {
|
|
assert.strictEqual(escapeHtml(''), '');
|
|
assert.strictEqual(escapeHtml(null), '');
|
|
assert.strictEqual(escapeHtml(undefined), '');
|
|
assert.strictEqual(escapeHtml('hello'), 'hello');
|
|
assert.strictEqual(escapeHtml('<script>'), '<script>');
|
|
assert.strictEqual(escapeHtml('a & b'), 'a & b');
|
|
assert.strictEqual(escapeHtml('"quoted"'), '"quoted"');
|
|
assert.strictEqual(escapeHtml('<a href="x">&</a>'), '<a href="x">&</a>');
|
|
console.log(' ✓ escapeHtml');
|
|
}
|
|
|
|
// ── Test state object structure ────────────────────────────────────────────
|
|
// Replicate state.js structure for testing
|
|
const expectedStateKeys = [
|
|
'currentVault', 'currentPath', 'allVaults', 'selectedContextVault',
|
|
'searchTimeout', 'searchAbortController', 'advancedSearchOffset', 'advancedSearchTotal',
|
|
'advancedSearchSort', 'advancedSearchLastQuery', 'suggestAbortController',
|
|
'dropdownActiveIndex', 'dropdownItems', 'currentSearchId', 'selectedTags',
|
|
'searchCaseSensitive', 'searchWholeWord', 'searchRegex', 'searchFilterVisible',
|
|
'SEARCH_HISTORY_KEY', 'MAX_HISTORY_ENTRIES', 'SUGGEST_DEBOUNCE_MS',
|
|
'ADVANCED_SEARCH_LIMIT', 'MIN_SEARCH_LENGTH', 'SEARCH_TIMEOUT_MS',
|
|
'showingSource', 'cachedRawSource', 'editorView', 'editorVault', 'editorPath',
|
|
'fallbackEditorEl', '_iconDebounceTimer', 'outlineObserver', 'activeHeadingId',
|
|
'headingsCache', 'rightSidebarVisible', 'rightSidebarWidth',
|
|
'sidebarFilterCaseSensitive', 'activeSidebarTab', 'filterDebounce', 'vaultSettings',
|
|
];
|
|
|
|
function testStateKeys() {
|
|
const state = {
|
|
currentVault: null, currentPath: null, allVaults: [],
|
|
selectedContextVault: "all", searchTimeout: null, searchAbortController: null,
|
|
advancedSearchOffset: 0, advancedSearchTotal: 0, advancedSearchSort: "relevance",
|
|
advancedSearchLastQuery: "", suggestAbortController: null, dropdownActiveIndex: -1,
|
|
dropdownItems: [], currentSearchId: 0, selectedTags: [], searchCaseSensitive: false,
|
|
searchWholeWord: false, searchRegex: false, searchFilterVisible: false,
|
|
SEARCH_HISTORY_KEY: "obsigate_search_history", MAX_HISTORY_ENTRIES: 50,
|
|
SUGGEST_DEBOUNCE_MS: 150, ADVANCED_SEARCH_LIMIT: 50, MIN_SEARCH_LENGTH: 2,
|
|
SEARCH_TIMEOUT_MS: 30000, showingSource: false, cachedRawSource: null,
|
|
editorView: null, editorVault: null, editorPath: null, fallbackEditorEl: null,
|
|
_iconDebounceTimer: null, outlineObserver: null, activeHeadingId: null,
|
|
headingsCache: [], rightSidebarVisible: true, rightSidebarWidth: 280,
|
|
sidebarFilterCaseSensitive: false, activeSidebarTab: "vaults", filterDebounce: null,
|
|
vaultSettings: {},
|
|
};
|
|
|
|
for (const key of expectedStateKeys) {
|
|
assert.ok(key in state, `State key missing: ${key}`);
|
|
}
|
|
|
|
// Verify mutability
|
|
state.currentVault = 'test';
|
|
assert.strictEqual(state.currentVault, 'test');
|
|
state.rightSidebarVisible = false;
|
|
assert.strictEqual(state.rightSidebarVisible, false);
|
|
|
|
console.log(' ✓ state object — all %d keys present and mutable', expectedStateKeys.length);
|
|
}
|
|
|
|
// ── Test module file structure ─────────────────────────────────────────────
|
|
import { readdirSync, readFileSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { execSync } from 'child_process';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const JS_DIR = join(__dirname, '../../frontend/js');
|
|
|
|
function testAllModulesParse() {
|
|
const files = readdirSync(JS_DIR).filter(f => f.endsWith('.js') && f !== 'sidebar_raw.js');
|
|
let ok = 0, fail = 0;
|
|
|
|
for (const file of files) {
|
|
try {
|
|
execSync(`node --check "${join(JS_DIR, file)}"`, { stdio: 'pipe' });
|
|
ok++;
|
|
} catch {
|
|
console.error(` ✗ ${file} — syntax error`);
|
|
fail++;
|
|
}
|
|
}
|
|
|
|
assert.strictEqual(fail, 0, `${fail} module(s) have syntax errors`);
|
|
console.log(` ✓ All ${ok} modules parse without syntax errors`);
|
|
}
|
|
|
|
function testModulesHaveImports() {
|
|
const files = readdirSync(JS_DIR).filter(f => f.endsWith('.js') && f !== 'sidebar_raw.js');
|
|
const expected = [
|
|
{ file: 'state.js', reason: 'export-only module' },
|
|
{ file: 'autocomplete.js', reason: 'standalone — globals' },
|
|
{ file: 'i18n.js', reason: 'standalone — globals' },
|
|
{ file: 'offline-db.js', reason: 'standalone — IndexedDB' },
|
|
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
|
|
{ file: 'editor-inline.js', reason: 'standalone — DOM-only helpers for the inline editor host (#93)' },
|
|
{ file: 'themes.js', reason: 'standalone — no deps' },
|
|
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
|
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
|
];
|
|
const expectNoImport = new Set(expected.map(e => e.file));
|
|
|
|
for (const file of files) {
|
|
const content = readFileSync(join(JS_DIR, file), 'utf-8');
|
|
const hasExport = /^export\s/m.test(content);
|
|
const hasImport = /^import\s/m.test(content);
|
|
|
|
if (!expectNoImport.has(file)) {
|
|
assert.ok(hasImport, `${file} should have at least one import`);
|
|
}
|
|
if (file !== 'state.js' && file !== 'app.js') {
|
|
assert.ok(hasExport, `${file} should have at least one export`);
|
|
}
|
|
}
|
|
console.log(' ✓ All modules have imports and exports (except state.js)');
|
|
}
|
|
|
|
// ── Test admin.js exports (ROADMAP #71) ─────────────────────────────────
|
|
// Static analysis of frontend/js/admin.js — confirms the ES module shape
|
|
// without depending on a DOM environment.
|
|
const ADMIN_PATH = join(JS_DIR, 'admin.js');
|
|
const ADMIN_REQUIRED_EXPORTS = [
|
|
'init',
|
|
'connectSSE',
|
|
'loadStatsOnce',
|
|
'loadAuditLogs',
|
|
'loadBackupStats',
|
|
'renderStatsWidget',
|
|
'renderAuditTable',
|
|
'renderBackups',
|
|
'getAuthHeaders',
|
|
'formatBytes',
|
|
'formatUptime',
|
|
'severityColor',
|
|
];
|
|
|
|
function _collectExportedNames(content) {
|
|
const names = new Set();
|
|
// Multi-line export { ... } blocks
|
|
for (const m of content.matchAll(/^export\s*\{([\s\S]*?)\}/gm)) {
|
|
for (const part of m[1].split(',')) {
|
|
const n = part.trim().split(/\s+as\s+/)[0].trim();
|
|
if (n) names.add(n);
|
|
}
|
|
}
|
|
// export function/const/let/class/async function NAME
|
|
for (const m of content.matchAll(/^export\s+(?:async\s+)?(?:function|const|let|class)\s+(\w+)/gm)) {
|
|
names.add(m[1]);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function testAdminModuleExists() {
|
|
const content = readFileSync(ADMIN_PATH, 'utf-8');
|
|
assert.ok(content.length > 0, 'admin.js must be a non-empty file');
|
|
// Sanity: it must import from auth.js, state.js, utils.js, i18n.js
|
|
assert.ok(/from\s+['"]\.\/auth\.js['"]/.test(content), 'admin.js must import from ./auth.js');
|
|
assert.ok(/from\s+['"]\.\/state\.js['"]/.test(content), 'admin.js must import from ./state.js');
|
|
assert.ok(/from\s+['"]\.\/utils\.js['"]/.test(content), 'admin.js must import from ./utils.js');
|
|
assert.ok(/from\s+['"]\.\/i18n\.js['"]/.test(content), 'admin.js must import from ./i18n.js');
|
|
console.log(' ✓ admin.js exists and imports the expected modules');
|
|
}
|
|
|
|
function testAdminModuleExports() {
|
|
const content = readFileSync(ADMIN_PATH, 'utf-8');
|
|
const exported = _collectExportedNames(content);
|
|
const missing = ADMIN_REQUIRED_EXPORTS.filter((name) => !exported.has(name));
|
|
assert.strictEqual(missing.length, 0, `admin.js missing exports: ${missing.join(', ')}`);
|
|
console.log(` ✓ admin.js exports all ${ADMIN_REQUIRED_EXPORTS.length} expected names`);
|
|
}
|
|
|
|
function testAdminModuleSyntax() {
|
|
// node --check verifies parseability without executing side effects.
|
|
execSync(`node --check "${ADMIN_PATH}"`, { stdio: 'pipe' });
|
|
console.log(' ✓ admin.js parses without syntax errors');
|
|
}
|
|
|
|
// ── Test config TOC icons ─────────────────────────────────────────────────
|
|
const FRONTEND_DIR = join(__dirname, '../../frontend');
|
|
const INDEX_HTML = join(FRONTEND_DIR, 'index.html');
|
|
const ICON_RE = /^\p{Extended_Pictographic}/u;
|
|
|
|
function testConfigTocIcons() {
|
|
const html = readFileSync(INDEX_HTML, 'utf-8');
|
|
const keys = [
|
|
...html.matchAll(
|
|
/<a\s+href="#cfg-[^"]*"\s+class="help-nav-link"\s+data-i18n="([^"]+)"/g,
|
|
),
|
|
].map((m) => m[1]);
|
|
assert.ok(keys.length > 0, 'config TOC links found in index.html');
|
|
|
|
for (const lang of ['fr', 'en']) {
|
|
const locale = JSON.parse(
|
|
readFileSync(join(FRONTEND_DIR, 'locales', `${lang}.json`), 'utf-8'),
|
|
);
|
|
for (const key of keys) {
|
|
const value = locale[key];
|
|
assert.ok(value, `${lang}: missing TOC label for ${key}`);
|
|
assert.ok(
|
|
ICON_RE.test(value),
|
|
`${lang}: TOC label for ${key} has no icon ("${value}")`,
|
|
);
|
|
}
|
|
}
|
|
console.log(
|
|
` ✓ config TOC labels (${keys.length}) all carry an icon in FR and EN`,
|
|
);
|
|
}
|
|
|
|
// ── Test ai-fab.js (FAB — Floating Action Button) ─────────────────────────
|
|
const AIFAB_PATH = join(JS_DIR, 'ai-fab.js');
|
|
|
|
function testAIFabModuleExists() {
|
|
const content = readFileSync(AIFAB_PATH, 'utf-8');
|
|
assert.ok(content.length > 0, 'ai-fab.js must be a non-empty file');
|
|
assert.ok(/from\s+['"]\.\/bookslm\.js['"]/.test(content), 'ai-fab.js must import from ./bookslm.js');
|
|
assert.ok(/from\s+['"]\.\/utils\.js['"]/.test(content), 'ai-fab.js must import from ./utils.js');
|
|
assert.ok(/from\s+['"]\.\/i18n\.js['"]/.test(content), 'ai-fab.js must import from ./i18n.js');
|
|
console.log(' ✓ ai-fab.js exists and imports the expected modules');
|
|
}
|
|
|
|
function testAIFabModuleExports() {
|
|
const content = readFileSync(AIFAB_PATH, 'utf-8');
|
|
const exported = _collectExportedNames(content);
|
|
assert.ok(exported.has('initAIFab'), 'ai-fab.js must export initAIFab');
|
|
console.log(' ✓ ai-fab.js exports initAIFab');
|
|
}
|
|
|
|
// ── Test header trimming + sidebar account section (#112) ──────────────────
|
|
const AUTH_PATH = join(JS_DIR, 'auth.js');
|
|
const STYLE_PATH = join(FRONTEND_DIR, 'style.css');
|
|
|
|
function testSidebarUserSection() {
|
|
const html = readFileSync(INDEX_HTML, 'utf-8');
|
|
const auth = readFileSync(AUTH_PATH, 'utf-8');
|
|
const css = readFileSync(STYLE_PATH, 'utf-8');
|
|
|
|
// The header no longer carries the user block / version badge…
|
|
assert.doesNotMatch(html, /id="user-menu"/, 'header #user-menu must be removed');
|
|
assert.doesNotMatch(html, /id="logout-btn"/, 'header sign-out button must be removed');
|
|
const headerRight = html.slice(
|
|
html.indexOf('class="header-right"'),
|
|
html.indexOf('class="header-menu"'),
|
|
);
|
|
assert.doesNotMatch(headerRight, /version-badge/, 'version badge must leave the header-right');
|
|
assert.doesNotMatch(headerRight, /user-menu/, 'user block must leave the header-right');
|
|
|
|
// …the version lives in the options dropdown…
|
|
assert.match(html, /class="menu-list-row menu-list-version"[\s\S]*?id="version-badge"/);
|
|
|
|
// …and the account section sits at the bottom of the sidebar.
|
|
assert.match(html, /id="sidebar-user"/);
|
|
assert.match(html, /id="sidebar-user-logout"/);
|
|
assert.match(html, /id="sidebar-user-name"/);
|
|
assert.match(html, /id="sidebar-user-avatar"/);
|
|
|
|
// auth.js drives it.
|
|
assert.match(auth, /renderUserSection\(\)/);
|
|
assert.match(auth, /getElementById\("sidebar-user"\)/);
|
|
|
|
// styles + i18n exist in FR and EN.
|
|
assert.match(css, /\.sidebar-user\s*\{/);
|
|
assert.match(css, /\.menu-list-version\s*\{/);
|
|
for (const lang of ['fr', 'en']) {
|
|
const locale = JSON.parse(readFileSync(join(FRONTEND_DIR, 'locales', `${lang}.json`), 'utf-8'));
|
|
for (const key of ['header.menu_version', 'sidebar.user_role_admin', 'sidebar.user_role_user']) {
|
|
assert.ok(locale[key], `${lang}: missing i18n key ${key}`);
|
|
}
|
|
}
|
|
console.log(' ✓ header trimmed + sidebar account section (#112)');
|
|
}
|
|
|
|
// ── Test syntax highlighting follows the theme mode (BUG-078) ──────────────
|
|
function testSyntaxHighlightTheme() {
|
|
const themes = readFileSync(join(JS_DIR, 'themes.js'), 'utf-8');
|
|
const ui = readFileSync(join(JS_DIR, 'ui.js'), 'utf-8');
|
|
|
|
// themes.js must swap the highlight.js stylesheets based on the *mode*.
|
|
const applyTheme = themes.match(/function applyTheme\(themeKey, mode\)\s*\{([\s\S]*?)\n\}/);
|
|
assert.ok(applyTheme, 'themes.js applyTheme not found');
|
|
assert.match(applyTheme[1], /hljs-theme-dark/, 'applyTheme must toggle the dark hljs sheet');
|
|
assert.match(applyTheme[1], /hljs-theme-light/, 'applyTheme must toggle the light hljs sheet');
|
|
assert.match(applyTheme[1], /mode === 'dark'|mode === "dark"/, 'sheet choice must key on the mode');
|
|
|
|
// ui.js must resolve the persisted *mode*, never treat the theme key as one.
|
|
assert.match(ui, /obsigate-theme-mode/, 'ui.js must read the persisted theme mode');
|
|
assert.doesNotMatch(
|
|
ui,
|
|
/darkSheet\.disabled\s*=\s*theme !== "dark"[\s\S]{0,120}lightSheet\.disabled\s*=\s*theme !== "light"/,
|
|
'ui.js must not compare the theme key to "dark"/"light" (BUG-078 regression)',
|
|
);
|
|
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
|
|
}
|
|
|
|
// ── Run all tests ──────────────────────────────────────────────────────────
|
|
async function main() {
|
|
let passed = 0, failed = 0;
|
|
|
|
const tests = [
|
|
['escapeHtml', testEscapeHtml],
|
|
['state keys', testStateKeys],
|
|
['module syntax', testAllModulesParse],
|
|
['module structure', testModulesHaveImports],
|
|
['admin module exists', testAdminModuleExists],
|
|
['admin module exports', testAdminModuleExports],
|
|
['admin module syntax', testAdminModuleSyntax],
|
|
['ai-fab module exists', testAIFabModuleExists],
|
|
['ai-fab module exports', testAIFabModuleExports],
|
|
['config TOC icons', testConfigTocIcons],
|
|
['sidebar user section', testSidebarUserSection],
|
|
['syntax highlight theme', testSyntaxHighlightTheme],
|
|
];
|
|
|
|
for (const [name, fn] of tests) {
|
|
try {
|
|
fn();
|
|
passed++;
|
|
} catch (err) {
|
|
console.error(` ✗ ${name}: ${err.message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
main();
|