189 lines
12 KiB
JavaScript
189 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// Central QA command: `npm run qa [-- --fast] [--sonar] [--only <stage>] [--container] [--grep <pat>] [--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, mkdirSync, rmSync, 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 container = flag('--container') || process.env.QA_PY_CONTAINER === '1';
|
|
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;
|
|
// Fresh isolated HOME/CONFIG_DIR/TMPDIR per call (never touches live data or shared /tmp names); caller must cleanup().
|
|
const isolatedPyEnv = () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'qa-'));
|
|
const tmp = join(dir, 'tmp'); mkdirSync(tmp);
|
|
return { env: { ...process.env, HOME: dir, CONFIG_DIR: dir, TMPDIR: tmp }, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
};
|
|
// The project's Python needs fastapi etc. Agents (multica daemon) can see /usr/bin/python3 first, which has none of it,
|
|
// so pick the first interpreter that imports fastapi: $PYTHON, python3 on PATH, ~/miniforge3, /usr/bin.
|
|
const PY = (() => {
|
|
const home = process.env.HOME || '';
|
|
const cands = [process.env.PYTHON, 'python3', home && `${home}/miniforge3/bin/python3`, '/usr/bin/python3'].filter(Boolean);
|
|
return cands.find((c) => sh(c, ['-c', 'import fastapi']).status === 0) || 'python3';
|
|
})();
|
|
// Opt-in (--container): run Python tests in a throwaway container built from the app image (production Python), repo mounted read-only.
|
|
const TEST_IMAGE = 'tts-voice-creator-test:local';
|
|
const containerArgs = (...cmd) => ['run', '--rm', '--network', 'none', '--read-only', '--tmpfs', '/tmp:rw,exec', '--tmpfs', '/home/app:rw',
|
|
'-e', 'HOME=/tmp/home', '-e', 'CONFIG_DIR=/tmp/cfg', '-e', 'TMPDIR=/tmp', '-e', 'PYTHONDONTWRITEBYTECODE=1',
|
|
'-v', `${root}:/src:ro`, '--entrypoint', 'sh', TEST_IMAGE, '-c', COPY_AND_RUN, 'sh', ...cmd];
|
|
// Repo is mounted read-only at /src; tests run in a writable throwaway copy (some tests touch files) without the big/irrelevant dirs.
|
|
const COPY_AND_RUN = 'mkdir /tmp/work && cd /src && tar -c --exclude=./node_modules --exclude=./.git --exclude=./tts-voice-creator-clone-and-design-2 --exclude=./.multica --exclude=./exports --exclude=./logs --exclude=./config --exclude=./test-results --exclude=./.scannerwork --exclude=./attachments . | tar -x -C /tmp/work && mkdir -p /tmp/work/config && cp -a /src/config/regression_snapshots /tmp/work/config/ && cd /tmp/work && exec python3 "$@"';
|
|
const containerReady = () => {
|
|
if (!have('docker')) return 'docker not available';
|
|
if (sh('docker', ['image', 'inspect', TEST_IMAGE]).status) return `image ${TEST_IMAGE} missing; build: docker build -f docker/Dockerfile.test -t ${TEST_IMAGE} docker`;
|
|
return '';
|
|
};
|
|
const pyMod = (m) => sh(PY, ['-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(PY, ['-m', 'compileall', '-q', 'core', 'routes', 'scripts', 'server.py', 'ttsvc_cli.py']);
|
|
if (r.status) return result(r, 'compileall');
|
|
if (pyMod('ruff')) { r = sh(PY, ['-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', () => {
|
|
if (container) {
|
|
const why = containerReady();
|
|
if (why) return skip(`--container: ${why}`);
|
|
return result(inherit('docker', containerArgs('-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py')), 'throwaway container (Python 3.11); see output above');
|
|
}
|
|
const { env, cleanup } = isolatedPyEnv();
|
|
try { return result(inherit(PY, ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'], { env }), 'see unittest output above'); }
|
|
finally { cleanup(); }
|
|
}],
|
|
['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(PY, ['-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)');
|
|
if (have('gitleaks')) {
|
|
const g = sh('gitleaks', ['detect', '--no-banner', '--redact', '--source', '.']);
|
|
notes.push(g.status === 0 ? 'gitleaks ok' : 'gitleaks FOUND');
|
|
if (g.status) bad = true;
|
|
} else { const 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');
|
|
// Python coverage for new-code gate (optional: only if `coverage` is installed). JS coverage is not collected (see architecture/qa.md).
|
|
if (sh(PY, ['-c', 'import coverage']).status === 0) {
|
|
const { env, cleanup } = isolatedPyEnv();
|
|
try {
|
|
const c = inherit(PY, ['-m', 'coverage', 'run', '-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'], { env });
|
|
if (c.status === 0) sh(PY, ['-m', 'coverage', 'xml', '-o', 'coverage.xml']);
|
|
} finally { cleanup(); }
|
|
} else console.log('coverage: SKIPPED (module missing; `pip install coverage` to report Python coverage to Sonar)');
|
|
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-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);
|