ObsiGate/tests/frontend/validate-imports.mjs
Bruno Charest ffc6dac172
All checks were successful
CI / lint (push) Successful in 14s
CI / security (push) Successful in 11s
CI / test (push) Successful in 17s
CI / build (push) Successful in 2s
feat: frontend tests — import/export validator + unit tests + CI integration
- tests/frontend/validate-imports.mjs: 0 errors on 13 modules, 79 exports
  Detects: missing exports, broken imports, const reassignments
- tests/frontend/unit.test.mjs: escapeHtml, state object, module syntax
- Added to CI lint job (runs after Ruff + Mypy)
2026-05-28 18:46:10 -04:00

192 lines
6.5 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(',')) {
const n = name.trim().replace(/\s+as\s+\w+.*/, '').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);
}