CI / lint (push) Successful in 41s
CI / security (push) Successful in 28s
CI / test (push) Successful in 48s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m5s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Trois bugs corrigés + une amélioration demandée :
1. **fix(admin): /admin.html redirigeait toujours vers /**
- admin.js _gateAdmin() lisait /api/auth/status qui ne contient PAS le rôle user
- Remplacé par /api/auth/me (retourne username, role, vaults)
- Le code distingue maintenant le cas 'auth désactivé' (admin anonyme) du
cas 'auth requise non admin' (affiche écran forbidden)
2. **fix(ai): les modèles Nvidia/Xiaomi ne se chargeaient pas dans les dropdowns**
- Xiaomi : l'endpoint public /v1/models est instable, échec réseau fréquent
- Toutes les erreurs réseau/d'API renvoyaient models=[] → dropdown vide
- Ajout d'un fallback curé : _FALLBACK_MODELS dict avec 4-8 modèles populaires
par provider, TOUJOURS retourné si la clé manque OU si l'API distante échoue
- Le frontend voit désormais 'fallback' vs 'live' comme hint pour l'utilisateur
- Gemini parsing : strip du préfixe 'models/' retourné par l'API Gemini
- 7 nouveaux tests pytest pour _FALLBACK_MODELS + endpoint /api/config/ai-models
3. **feat(ai): picker provider/model dans la toolbar AI + BooksLM**
- Plusieurs providers peuvent maintenant être activés simultanément
- Sélection provider+model par section (Forge, BooksLM, etc.) via dropdown
- État persisté en localStorage (le choix suit l'utilisateur entre sections)
- Chaque appel AI passe maintenant {provider, model} au backend
- ai.js : aiAction() lit le picker et l'injecte dans le body
- bookslm.js : envoie provider+model à /api/ai/bookslm/chat
- ai_routes.py + bookslm_routes.py : AIRequest et BooksLMChatRequest
acceptent provider+model, avec save/restore du modèle original
pour ne pas affecter les autres requêtes concurrentes
- 7 nouvelles clés i18n (ai.provider, ai.model, ai.model_loading, etc.)
- 1 nouveau test bookslm vérifie que le schema accepte provider+model
- validate-imports.mjs : fix faux positif sur 'export { X as Y }'
Vérifié :
- pytest : 502 passed, 5 skipped (494 baseline + 7 AI models + 1 BooksLM)
- frontend unit : 7 passed
- validate-imports : 30 modules / 204 exports / 0 erreur
- pane-manager JSDOM : 9/9
- ruff check backend/ : All checks passed
199 lines
6.8 KiB
JavaScript
199 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ObsiGate — Frontend import/export validator.
|
|
* Runs in CI to catch missing exports, broken imports, and syntax errors.
|
|
* Usage: node tests/frontend/validate-imports.mjs
|
|
*/
|
|
|
|
import { readFileSync, readdirSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const JS_DIR = join(__dirname, '../../frontend/js');
|
|
|
|
// ── Step 1: Collect all exports from every module ──────────────────────────
|
|
const allExports = {};
|
|
|
|
function collectExports(filePath, modName) {
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
const lines = content.split('\n');
|
|
const exports = new Set();
|
|
|
|
// Handle multi-line export { ... } blocks
|
|
let inExportBlock = false;
|
|
let exportBlockText = '';
|
|
|
|
for (const line of lines) {
|
|
// Start of multi-line export block
|
|
if (/^export\s*\{/.test(line) && !/\}/.test(line)) {
|
|
inExportBlock = true;
|
|
exportBlockText = line;
|
|
continue;
|
|
}
|
|
if (inExportBlock) {
|
|
exportBlockText += ' ' + line.trim();
|
|
if (/\}/.test(line)) {
|
|
inExportBlock = false;
|
|
const m = exportBlockText.match(/^export\s*\{([^}]+)\}/);
|
|
if (m) {
|
|
for (const name of m[1].split(',')) {
|
|
// Handle `original as exported` — export both names
|
|
const asMatch = name.trim().match(/^(\w+)\s+as\s+(\w+)$/);
|
|
if (asMatch) {
|
|
exports.add(asMatch[2]); // exported name
|
|
exports.add(asMatch[1]); // original name too (in case it's re-imported)
|
|
} else {
|
|
const n = name.trim().trim();
|
|
if (n && n !== '') exports.add(n);
|
|
}
|
|
}
|
|
}
|
|
exportBlockText = '';
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// export const/let/function/async function X
|
|
let m = line.match(/^export\s+(?:const|let|function|async\s+function|class)\s+(\w+)/);
|
|
if (m) exports.add(m[1]);
|
|
|
|
// Single-line export { a, b }
|
|
m = line.match(/^export\s*\{([^}]+)\}/);
|
|
if (m) {
|
|
for (const name of m[1].split(',')) {
|
|
const n = name.trim().replace(/\s+as\s+.*/, '');
|
|
if (n) exports.add(n);
|
|
}
|
|
}
|
|
|
|
// export { a } from './x.js'
|
|
m = line.match(/^export\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/);
|
|
if (m) {
|
|
const source = m[2];
|
|
for (const name of m[1].split(',')) {
|
|
const n = name.trim().replace(/\s+as\s+.*/, '').trim();
|
|
if (n) exports.add(n);
|
|
}
|
|
// Re-exports: verify source actually exports these
|
|
if (allExports[source]) {
|
|
for (const name of m[1].split(',')) {
|
|
const n = name.trim().replace(/\s+as\s+.*/, '').trim();
|
|
if (n && !allExports[source].has(n)) {
|
|
errors.push(`${modName}: re-exports '${n}' but ${source} doesn't export it`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
allExports[modName] = exports;
|
|
}
|
|
|
|
// ── Step 2: Check all imports resolve to actual exports ────────────────────
|
|
const errors = [];
|
|
|
|
function checkImports(filePath, modName) {
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
const lines = content.split('\n');
|
|
|
|
for (const line of lines) {
|
|
// import { a, b } from './source.js'
|
|
let m = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/);
|
|
if (m) {
|
|
const source = m[2];
|
|
const names = m[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim());
|
|
|
|
if (!allExports[source]) {
|
|
errors.push(`${modName}: imports from ${source} but couldn't read that file`);
|
|
continue;
|
|
}
|
|
|
|
for (const name of names) {
|
|
if (!allExports[source].has(name)) {
|
|
errors.push(`${modName}: imports '${name}' from ${source} but it's NOT exported`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// import * as NS from './source.js'
|
|
m = line.match(/^import\s+\*\s+as\s+(\w+)\s+from\s*['"]\.\/(\w+\.js)['"]/);
|
|
if (m) {
|
|
const ns = m[1];
|
|
const source = m[2];
|
|
|
|
if (!allExports[source]) {
|
|
errors.push(`${modName}: namespace imports from ${source} but couldn't read that file`);
|
|
continue;
|
|
}
|
|
|
|
// Check all NS.xxx usages in this file
|
|
const nsUsage = new RegExp(`\\b${ns}\\.(\\w+)`, 'g');
|
|
let usageMatch;
|
|
while ((usageMatch = nsUsage.exec(content)) !== null) {
|
|
const accessed = usageMatch[1];
|
|
if (!allExports[source].has(accessed)) {
|
|
errors.push(`${modName}: uses ${ns}.${accessed} but ${source} doesn't export '${accessed}'`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Main ───────────────────────────────────────────────────────────────────
|
|
const files = readdirSync(JS_DIR).filter(f => f.endsWith('.js') && f !== 'sidebar_raw.js');
|
|
|
|
// Phase 1: collect all exports
|
|
for (const file of files) {
|
|
collectExports(join(JS_DIR, file), file);
|
|
}
|
|
|
|
// Phase 2: verify all imports
|
|
for (const file of files) {
|
|
checkImports(join(JS_DIR, file), file);
|
|
}
|
|
|
|
// ── Step 3: Check for const reassignment patterns ──────────────────────────
|
|
for (const file of files) {
|
|
const content = readFileSync(join(JS_DIR, file), 'utf-8');
|
|
const lines = content.split('\n');
|
|
|
|
// Find all imported names
|
|
const importedNames = new Set();
|
|
for (const line of lines) {
|
|
const m = line.match(/^import\s*\{([^}]+)\}\s*from/);
|
|
if (m) {
|
|
for (const name of m[1].split(',')) {
|
|
importedNames.add(name.trim().split(/\s+as\s+/)[0].trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if any imported name is reassigned
|
|
for (const line of lines) {
|
|
for (const name of importedNames) {
|
|
// Check for: name = (not ===, !==, ==, !=)
|
|
if (new RegExp(`^\\s*${name}\\s*=[^=]`).test(line) ||
|
|
new RegExp(`^\\s*${name}\\s*=\\s*function`).test(line) ||
|
|
new RegExp(`^\\s*${name}\\s*=\\s*async`).test(line) ||
|
|
new RegExp(`^\\s*${name}\\s*=\\s*\\{`).test(line) ||
|
|
new RegExp(`^\\s*${name}\\s*=\\s*\\[`).test(line)) {
|
|
errors.push(`${file}:${lines.indexOf(line) + 1} — imported '${name}' is reassigned (const violation)`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Report ─────────────────────────────────────────────────────────────────
|
|
if (errors.length > 0) {
|
|
console.error(`\n❌ ${errors.length} frontend validation error(s):\n`);
|
|
for (const err of errors) {
|
|
console.error(` • ${err}`);
|
|
}
|
|
console.error('');
|
|
process.exit(1);
|
|
} else {
|
|
console.log(`✅ All ${files.length} modules validated — ${Object.values(allExports).reduce((s, e) => s + e.size, 0)} exports, 0 errors`);
|
|
process.exit(0);
|
|
}
|