Files
ObsiGate/tests/frontend/unit.test.mjs
T
bruno d6fdcef4d4
CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat(ai): assistant AI multi-contexte + ergonomie sidebar et clavier
Contexte:
- Repertoire (mode historique BooksLM, via menu contextuel dossier)
- Documents (bouton flottant quand des documents sont ouverts)
- General (bouton flottant sans document: aide app + creation de fichiers)
- Le contexte est affiche dans le header; suggestions adaptees au mode
- Blocs obsigate-action rendus en carte avec bouton Appliquer (pas d'execution auto)

Backend:
- /api/ai/bookslm/context et /chat acceptent mode + context_files
- collect_files_context(), empty_context(), build_general_system_prompt()
- mode documents degrade vers general si aucun fichier lisible

Corrige:
- bouton flottant passait le chemin d'un fichier comme repertoire -> erreur
  'Aucun fichier markdown trouve dans ce dossier'
- panneau precedent masque (localStorage) ne se rouvrait plus
- selecteur fournisseur/modele deplace sur une ligne dediee (header actions visibles)
- Entree envoie, Ctrl+Entree insere un saut de ligne

Tests: test_bookslm.py (+13) et tests/frontend/ai.test.mjs (+8)
2026-09-11 09:11:00 -04:00

241 lines
10 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
};
function testEscapeHtml() {
assert.strictEqual(escapeHtml(''), '');
assert.strictEqual(escapeHtml(null), '');
assert.strictEqual(escapeHtml(undefined), '');
assert.strictEqual(escapeHtml('hello'), 'hello');
assert.strictEqual(escapeHtml('<script>'), '&lt;script&gt;');
assert.strictEqual(escapeHtml('a & b'), 'a &amp; b');
assert.strictEqual(escapeHtml('"quoted"'), '&quot;quoted&quot;');
assert.strictEqual(escapeHtml('<a href="x">&</a>'), '&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;');
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: '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 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');
}
// ── 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],
];
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();