chore(qa): isolated per-run python env and optional coverage for sonar

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 19:29:37 +02:00
parent aa92ed30b3
commit c7d5134734
5 changed files with 29 additions and 4 deletions

2
.gitignore vendored
View File

@ -64,3 +64,5 @@ TODO.md
# local experiments; two files here once held key-like literals
scratch/test_openai.py
scratch/test_google_imagen.py
coverage.xml
.coverage

View File

@ -10,6 +10,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **`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
- `npm run qa`: pyunit gets a fresh temp HOME/CONFIG_DIR/TMPDIR per run (cleaned afterwards); `--sonar` stage also produces Python `coverage.xml` when `coverage` is installed (else SKIPPED).
- `guided_installer.py`: command execution extracted into a testable `run_command` helper (lowers cognitive complexity, python:S3776); no behaviour change.
- **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.

View File

@ -30,6 +30,12 @@ npm run qa -- --list
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.
## Isolation and coverage
- `pyunit` and the coverage run each get a fresh `mkdtemp` HOME/CONFIG_DIR/TMPDIR (`isolatedPyEnv` in `scripts/qa.mjs`), removed afterwards. Stages run strictly sequentially, so e2e and pyunit never overlap.
- Sonar stage: if `python3 -c 'import coverage'` works, it runs `coverage run -m unittest discover ...` and writes `coverage.xml` (read via `sonar.python.coverage.reportPaths`). Otherwise it prints SKIPPED; install with `pip install coverage`.
- JS coverage is NOT collected. Playwright-driven coverage needs c8/V8 instrumentation of the served bundle (e.g. `page.coverage` + `v8-to-istanbul` -> `sonar.javascript.lcov.reportPaths`). Owner decision pending.
## 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.

View File

@ -3,7 +3,7 @@
// 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 { 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';
@ -17,6 +17,12 @@ const fast = flag('--fast'), withSonar = flag('--sonar'), only = opt('--only'),
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 }) };
};
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}
@ -47,9 +53,9 @@ const stages = [
}],
['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');
const { env, cleanup } = isolatedPyEnv();
try { return result(inherit('python3', ['-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)', () => {
@ -98,6 +104,14 @@ const stages = [
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('python3', ['-c', 'import coverage']).status === 0) {
const { env, cleanup } = isolatedPyEnv();
try {
const c = inherit('python3', ['-m', 'coverage', 'run', '-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'], { env });
if (c.status === 0) sh('python3', ['-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'));

View File

@ -23,6 +23,7 @@ sonar.exclusions=\
workflows/*.json,\
config/**,\
scratch/**,\
coverage.xml,\
venv/**,\
.venv/**
@ -30,6 +31,7 @@ sonar.exclusions=\
sonar.cpd.exclusions=static/dist/**,workflows/*.json
sonar.python.version=3.11
sonar.python.coverage.reportPaths=coverage.xml
sonar.sourceEncoding=UTF-8
# The JS analyzer runs out of memory at the 4 GB default on this codebase.