#!/usr/bin/env node // Central QA command: `npm run qa [-- --fast] [--sonar] [--only ] [--grep ] [--list]`. // Dependency-free; uses only tools that are already installed and reports the rest as SKIPPED. // Secrets: SONAR_TOKEN / SONAR_HOST_URL come from the environment only and are never printed or put in argv. import { spawnSync } from 'node:child_process'; import { readFileSync, mkdtempSync, existsSync, statSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const args = process.argv.slice(2); const flag = n => args.includes(n); const opt = n => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; }; const fast = flag('--fast'), withSonar = flag('--sonar'), only = opt('--only'), grep = opt('--grep'); const sh = (cmd, a, o = {}) => spawnSync(cmd, a, { cwd: root, encoding: 'utf8', maxBuffer: 64 << 20, ...o }); const have = (bin) => sh('sh', ['-c', `command -v ${bin}`]).status === 0; const pyMod = (m) => sh('python3', ['-m', m, '--version']).status === 0; const tail = (r, n = 15) => ((r.stdout || '') + (r.stderr || '')).trim().split('\n').slice(-n).join('\n'); // A stage returns {status:'PASS'|'FAIL'|'SKIPPED', note} const pass = (note = '') => ({ status: 'PASS', note }); const skip = (note) => ({ status: 'SKIPPED', note }); const result = (r, note = '') => { if (r.status === 0) return pass(note); console.log(tail(r, 40)); return { status: 'FAIL', note: note || `exit ${r.status}` }; }; const inherit = (cmd, a, o = {}) => sh(cmd, a, { stdio: 'inherit', encoding: undefined, ...o }); const tracked = (...pats) => sh('git', ['ls-files', ...pats]).stdout.split('\n').filter(Boolean); const stages = [ ['format', 'formatter check', () => skip('no formatter configured (no prettier/black config)')], ['lint', 'JS syntax + frontend gate + python compile/ruff', () => { for (const f of tracked('static/js/*.js', 'scripts/*.mjs', 'tests/*.mjs')) { const r = sh(process.execPath, ['--check', f]); if (r.status) return result(r, `syntax: ${f}`); } let r = sh('npm', ['run', '--silent', 'check:frontend']); if (r.status) return result(r, 'check:frontend'); r = sh('python3', ['-m', 'compileall', '-q', 'core', 'routes', 'scripts', 'server.py', 'ttsvc_cli.py']); if (r.status) return result(r, 'compileall'); if (pyMod('ruff')) { r = sh('python3', ['-m', 'ruff', 'check', 'core', 'routes', 'scripts', 'server.py']); if (r.status) return result(r, 'ruff'); return pass('with ruff'); } return pass('ruff not installed, skipped'); }], ['types', 'type check', () => skip('no type checker configured')], ['pyunit', 'python unittest', () => { const tmp = mkdtempSync(join(tmpdir(), 'qa-')); // isolated HOME/CONFIG_DIR: avoids /home/app permission errors, never touches live data const env = { ...process.env, HOME: tmp, CONFIG_DIR: tmp }; return result(inherit('python3', ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'], { env }), 'see unittest output above'); }], ['nodeunit', 'node unit tests', () => result(inherit('npm', ['run', '--silent', 'test:unit']))], ['e2e', 'Playwright (desktop + iphone)', () => { if (fast) return skip('--fast'); const a = ['playwright', 'test', ...(grep ? ['--grep', grep] : [])]; return result(inherit('npx', a)); }], ['a11y', 'accessibility (axe-core)', () => { const axe = existsSync(join(root, 'node_modules/@axe-core/playwright')) || existsSync(join(root, 'node_modules/axe-core')); return axe ? skip('axe-core installed but no a11y specs yet') : skip('tool missing: axe-core; recommend `npm i -D @axe-core/playwright` and add tests/a11y.spec.js'); }], ['security', 'npm audit, pip-audit, secret scan', () => { const notes = []; let bad = false; const a = sh('npm', ['audit', '--audit-level=high'], { timeout: 60000 }); const out = (a.stdout || '') + (a.stderr || ''); if (a.status === 0) notes.push('npm audit ok'); else if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|network|audit endpoint/i.test(out) || a.error) notes.push('npm audit SKIPPED (offline)'); else { bad = true; console.log(tail(a, 20)); notes.push('npm audit FOUND high+'); } if (existsSync(join(root, 'requirements.txt')) && pyMod('pip_audit')) { const p = sh('python3', ['-m', 'pip_audit', '-r', 'requirements.txt', '--progress-spinner', 'off'], { timeout: 120000 }); const po = (p.stdout || '') + (p.stderr || ''); if (p.status === 0) notes.push('pip-audit ok'); else if (/connection|resolve|network|timed out|ConnectionError/i.test(po) || p.error) notes.push('pip-audit SKIPPED (offline)'); else { bad = true; console.log(tail(p, 20)); notes.push('pip-audit FOUND vulns'); } } else notes.push('pip-audit SKIPPED (tool/requirements missing)'); let hits; if (have('gitleaks')) { const g = sh('gitleaks', ['detect', '--no-banner', '--redact', '--source', '.']); hits = g.status === 0 ? 0 : -1; notes.push(g.status === 0 ? 'gitleaks ok' : 'gitleaks FOUND'); if (g.status) bad = true; } else { hits = secretScan(); notes.push(hits ? `secret-scan ${hits} hit(s)` : 'secret-scan ok (built-in regex)'); if (hits) bad = true; } return { status: bad ? 'FAIL' : 'PASS', note: notes.join('; ') }; }], ['build', 'minify + bundle freshness', () => { const f = join(root, 'static/dist/main.min.js'); const h = () => existsSync(f) ? createHash('sha256').update(readFileSync(f)).digest('hex') : ''; const before = h(); const r = sh('npm', ['run', '--silent', 'minify']); if (r.status) return result(r, 'minify'); if (!existsSync(f) || statSync(f).size === 0) return { status: 'FAIL', note: 'main.min.js missing/empty' }; return h() === before ? pass('bundle fresh') : { status: 'FAIL', note: 'bundle was stale; regenerated, review and commit static/dist/main.min.js' }; }], ['perf', 'performance budget', () => skip('DOM budget lives in tests/constrained.spec.js (runs in e2e stage)')], ['sonar', 'SonarQube scan (opt-in --sonar)', () => { if (!withSonar) return skip('not requested (--sonar)'); const { SONAR_TOKEN: tok, SONAR_HOST_URL: host } = process.env; if (!tok || !host) return skip('set SONAR_HOST_URL and SONAR_TOKEN in the shell or CI secrets'); if (!have('sonar-scanner')) return skip('tool missing: sonar-scanner'); const r = sh('sonar-scanner', ['-Dsonar.qualitygate.wait=true', '-Dsonar.qualitygate.timeout=300'], { stdio: ['ignore', 'pipe', 'pipe'] }); const log = ((r.stdout || '') + (r.stderr || '')).split(tok).join('***'); console.log(log.split('\n').slice(-12).join('\n')); return r.status === 0 ? pass('quality gate passed') : { status: 'FAIL', note: 'scan or quality gate failed' }; }], ]; const PATTERNS = [ ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/], ['aws-key', /\bAKIA[0-9A-Z]{16}\b/], ['github-token', /\bgh[pousr]_[A-Za-z0-9]{30,}\b/], ['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}/], ['jwt', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/], ['url-credentials', /\bhttps?:\/\/[^\s/:@'"]+:[^\s/@'"]{3,}@[^\s'"]+/], ['generic-secret', /\b(?:api[_-]?key|secret|token|passw(?:or)?d)\b['"]?\s*[:=]\s*['"][A-Za-z0-9_\-+/=]{20,}['"]/i], ]; function secretScan() { let hits = 0; for (const f of tracked()) { if (/^(static\/(dist|vendor)\/|node_modules\/)|\.(png|jpe?g|gif|webp|wav|mp3|ogg|pdf|woff2?|ico|zip|gz|lock)$|package-lock\.json$|\.min\.js$/.test(f)) continue; let text; try { text = readFileSync(join(root, f), 'utf8'); } catch { continue; } if (text.includes('\0')) continue; text.split('\n').forEach((line, i) => { if (line.length > 2000) return; for (const [name, re] of PATTERNS) if (re.test(line)) { console.log(` ${f}:${i + 1} ${name}`); hits++; break; } // never print the match }); } return hits; } if (flag('--list')) { for (const [id, d] of stages) console.log(id.padEnd(10), d); process.exit(0); } if (only && !stages.some(s => s[0] === only)) { console.error(`unknown stage: ${only}`); process.exit(2); } const base = process.env.BASE_URL || 'http://localhost:7890'; console.log(`QA fast=${fast} sonar=${withSonar}`); console.log(`NOTE: Playwright baseURL is ${base} (the live container serves static files; tests mock /api routes). --fast skips Playwright.`); console.log('NOTE: python tests run with a temporary HOME/CONFIG_DIR.'); const rows = []; for (const [id, desc, fn] of stages) { if (only && only !== id) continue; console.log(`\n=== ${id}: ${desc}`); const t = Date.now(); let r; try { r = fn(); } catch (e) { r = { status: 'FAIL', note: String(e.message || e) }; } rows.push({ id, ...r, secs: ((Date.now() - t) / 1000).toFixed(1) }); } console.log('\n=== QA summary'); for (const r of rows) console.log(`${r.id.padEnd(10)} ${r.status.padEnd(8)} ${r.secs.padStart(6)}s ${r.note}`); const failed = rows.filter(r => r.status === 'FAIL'); console.log(failed.length ? `\nQA FAILED: ${failed.map(r => r.id).join(', ')}` : '\nQA OK'); process.exit(failed.length ? 1 : 0);