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:
parent
aa92ed30b3
commit
c7d5134734
2
.gitignore
vendored
2
.gitignore
vendored
@ -64,3 +64,5 @@ TODO.md
|
|||||||
# local experiments; two files here once held key-like literals
|
# local experiments; two files here once held key-like literals
|
||||||
scratch/test_openai.py
|
scratch/test_openai.py
|
||||||
scratch/test_google_imagen.py
|
scratch/test_google_imagen.py
|
||||||
|
coverage.xml
|
||||||
|
.coverage
|
||||||
|
|||||||
@ -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`.
|
- **`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
|
### 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.
|
- `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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
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
|
## 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.
|
`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.
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
// Dependency-free; uses only tools that are already installed and reports the rest as SKIPPED.
|
// 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.
|
// 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 { 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 { createHash } from 'node:crypto';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
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 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 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 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');
|
const tail = (r, n = 15) => ((r.stdout || '') + (r.stderr || '')).trim().split('\n').slice(-n).join('\n');
|
||||||
// A stage returns {status:'PASS'|'FAIL'|'SKIPPED', note}
|
// A stage returns {status:'PASS'|'FAIL'|'SKIPPED', note}
|
||||||
@ -47,9 +53,9 @@ const stages = [
|
|||||||
}],
|
}],
|
||||||
['types', 'type check', () => skip('no type checker configured')],
|
['types', 'type check', () => skip('no type checker configured')],
|
||||||
['pyunit', 'python unittest', () => {
|
['pyunit', 'python unittest', () => {
|
||||||
const tmp = mkdtempSync(join(tmpdir(), 'qa-')); // isolated HOME/CONFIG_DIR: avoids /home/app permission errors, never touches live data
|
const { env, cleanup } = isolatedPyEnv();
|
||||||
const env = { ...process.env, HOME: tmp, CONFIG_DIR: tmp };
|
try { return result(inherit('python3', ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'], { env }), 'see unittest output above'); }
|
||||||
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']))],
|
['nodeunit', 'node unit tests', () => result(inherit('npm', ['run', '--silent', 'test:unit']))],
|
||||||
['e2e', 'Playwright (desktop + iphone)', () => {
|
['e2e', 'Playwright (desktop + iphone)', () => {
|
||||||
@ -98,6 +104,14 @@ const stages = [
|
|||||||
const { SONAR_TOKEN: tok, SONAR_HOST_URL: host } = process.env;
|
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 (!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');
|
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 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('***');
|
const log = ((r.stdout || '') + (r.stderr || '')).split(tok).join('***');
|
||||||
console.log(log.split('\n').slice(-12).join('\n'));
|
console.log(log.split('\n').slice(-12).join('\n'));
|
||||||
|
|||||||
@ -23,6 +23,7 @@ sonar.exclusions=\
|
|||||||
workflows/*.json,\
|
workflows/*.json,\
|
||||||
config/**,\
|
config/**,\
|
||||||
scratch/**,\
|
scratch/**,\
|
||||||
|
coverage.xml,\
|
||||||
venv/**,\
|
venv/**,\
|
||||||
.venv/**
|
.venv/**
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ sonar.exclusions=\
|
|||||||
sonar.cpd.exclusions=static/dist/**,workflows/*.json
|
sonar.cpd.exclusions=static/dist/**,workflows/*.json
|
||||||
|
|
||||||
sonar.python.version=3.11
|
sonar.python.version=3.11
|
||||||
|
sonar.python.coverage.reportPaths=coverage.xml
|
||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
# The JS analyzer runs out of memory at the 4 GB default on this codebase.
|
# The JS analyzer runs out of memory at the 4 GB default on this codebase.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user