From c7d5134734e30fbb097cadc0eaef3c63f7ee18a3 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 4 Oct 2026 19:29:37 +0200 Subject: [PATCH] chore(qa): isolated per-run python env and optional coverage for sonar Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 2 ++ CHANGELOG.md | 1 + architecture/qa.md | 6 ++++++ scripts/qa.mjs | 22 ++++++++++++++++++---- sonar-project.properties | 2 ++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4c00f76..de7c888 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ebaf821..aa1078b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/architecture/qa.md b/architecture/qa.md index 9ae91ab..062cb8f 100644 --- a/architecture/qa.md +++ b/architecture/qa.md @@ -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. diff --git a/scripts/qa.mjs b/scripts/qa.mjs index b466d73..5122fde 100644 --- a/scripts/qa.mjs +++ b/scripts/qa.mjs @@ -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')); diff --git a/sonar-project.properties b/sonar-project.properties index feaff48..e4360a1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -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.