From 1fd79866f88445a0b08a9cf59c1b54f3a84b6dda Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 4 Oct 2026 18:36:48 +0200 Subject: [PATCH] chore(qa): central npm run qa command with optional SonarQube stage Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/sonarqube.yml | 5 ++ CHANGELOG.md | 3 + PROJECT_MAP.md | 2 + architecture/qa.md | 45 ++++++++++ package.json | 1 + scripts/qa.mjs | 150 ++++++++++++++++++++++++++++++++ 6 files changed, 206 insertions(+) create mode 100644 architecture/qa.md create mode 100644 scripts/qa.mjs diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index fe278bc..af6dda6 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -54,6 +54,11 @@ jobs: - name: Build the frontend bundle run: node scripts/minify.mjs + # Advisory until pre-existing failures are fixed (see architecture/qa.md). + - name: Central QA (fast) + continue-on-error: true + run: npm run qa -- --fast + - name: SonarQube scan uses: SonarSource/sonarqube-scan-action@v8.1.0 env: diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc3176..ea56a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- ## [Unreleased] +### Added +- **`npm run qa`**: central QA command (`scripts/qa.mjs`) with timed stages (lint, python/node unit tests, Playwright, security scans, bundle freshness) and an opt-in SonarQube stage (`--sonar`, token from the environment only). See `architecture/qa.md`. + ### Changed - **Voice list rows no longer show the "Source unknown" badge** (it crowded the play buttons). The badge stays in the voice inspector, next to the Source & consent panel. diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index c690c4a..e456569 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -92,6 +92,8 @@ which is the writable volume. | `ALIGNER_URL` | forced-aligner sidecar for word timing (`http://tts-aligner:8000`; empty = off) | | `llm_url` / `llm_model` / `llm_api_key` | settings.json, OpenAI-compatible LLM | +QA: `npm run qa -- --fast` runs lint, unit, security, build checks (see `architecture/qa.md`); optional `--sonar` needs `SONAR_HOST_URL` + `SONAR_TOKEN` in the shell/CI secrets. + ## Pending decisions - Container registry: ghcr costs quota against a private package (≈427 MB per diff --git a/architecture/qa.md b/architecture/qa.md new file mode 100644 index 0000000..9ae91ab --- /dev/null +++ b/architecture/qa.md @@ -0,0 +1,45 @@ +# QA: `npm run qa` + +Developer-facing. Single entry point: `scripts/qa.mjs` (Node, no dependencies). Every stage is timed; a summary table is printed and the exit code is non-zero if any stage FAILs. SKIPPED never fails the run. Nothing is ever installed automatically. + +```bash +npm run qa -- --fast # everything except Playwright +npm run qa # full, incl. Playwright (desktop + iphone) +npm run qa -- --only e2e --grep voice-provenance +npm run qa -- --sonar # add the SonarQube stage +npm run qa -- --list +``` + +## Stages + +| id | what | skipped when | +|----|------|--------------| +| format | formatter check | always for now (no formatter configured) | +| lint | `node --check` over tracked static/js, scripts, tests mjs; `npm run check:frontend`; python `compileall`; `ruff` if installed | ruff missing -> noted only | +| types | type check | no type checker configured | +| pyunit | `unittest discover -s tests -p 'test_*.py'` with a temp `HOME`/`CONFIG_DIR` (avoids the `/home/app` permission error, never touches live data) | never | +| nodeunit | `npm run test:unit` | never | +| e2e | Playwright, projects `desktop` + `iphone`; `--grep` supported | `--fast` | +| a11y | axe-core specs | axe-core not installed (recommend `npm i -D @axe-core/playwright`, add `tests/a11y.spec.js`) | +| security | `npm audit --audit-level=high`, `pip-audit` (if installed + requirements.txt), secret scan (gitleaks if present, else built-in regexes over tracked files) | audits: network failure -> SKIPPED, not FAIL | +| build | `npm run minify`, fails if `static/dist/main.min.js` changed (stale bundle; commit the regenerated file) | never | +| perf | DOM budget is `tests/constrained.spec.js`, runs inside e2e | always (no separate stage) | +| sonar | `sonar-scanner` with `sonar-project.properties` | no `--sonar`, or `SONAR_HOST_URL`/`SONAR_TOKEN` unset, or scanner missing | + +## Test isolation + +Playwright `baseURL` defaults to the live container (`http://localhost:7890`, override with `BASE_URL`). The specs mock every `/api/**` route (the live server only serves static files and the page), so no live data is written; use a throwaway instance via `BASE_URL` when in doubt. `--fast` never touches it. The secret scan prints only `file:line pattern-name`, never the match. + +## SonarQube / secrets + +`SONAR_HOST_URL` and `SONAR_TOKEN` are read from the environment only (shell or CI secrets). They are never written to files, argv or output (the scanner reads the token itself; output is redacted). The quality gate is awaited via `-Dsonar.qualitygate.wait=true`; no separate web API call is made. + +## CI + +`.github/workflows/sonarqube.yml` already runs compile/unittest/frontend/build steps and the Sonar action (token from `${{ secrets.SONAR_TOKEN }}`). An advisory (`continue-on-error`) `npm run qa -- --fast` step was added. Make it blocking once the pre-existing failures below are fixed. + +## Known pre-existing failures (2026-10-04) + +- `check:frontend`: duplicate top-level `say` in `static/js/voice-archetypes.js` and `static/js/magazine.js`. +- `test_epub_export.EndpointTests.test_downloads_a_valid_epub`. +- Secret scan: tracked `scratch/test_openai.py` and `scratch/test_google_imagen.py` contain key-like literals; rotate if real and remove from history/tracking. diff --git a/package.json b/package.json index 163998f..de2fc67 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "install:browsers": "playwright install chromium", "minify": "node scripts/minify.mjs", "check:frontend": "node scripts/check-frontend.mjs", + "qa": "node scripts/qa.mjs", "test:unit": "node --test tests/*.test.mjs" }, "devDependencies": { diff --git a/scripts/qa.mjs b/scripts/qa.mjs new file mode 100644 index 0000000..b466d73 --- /dev/null +++ b/scripts/qa.mjs @@ -0,0 +1,150 @@ +#!/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);