import { readFileSync } from 'fs'; import path from 'path'; /** * The guard for #174. * * `sonarqube.yml` runs every suite to completion and then fails at the end from * recorded step outcomes, so that a failing suite still produces coverage, a * scan and its summaries. Two halves make that work: a step carries * `continue-on-error: true` so it cannot abort the job, and the final gate names * it so that it can still fail the job. * * Both halves are needed and nothing connected them. The integration suite had * neither, so it aborted the job where the other two suites did not — and for * the whole of #154 that meant no SonarQube analysis at all, on any commit. * Worse is the other direction: a step guarded but left out of the gate cannot * fail the job *ever*, which turns a broken suite into a green run. * * So this asserts the pairing rather than a list of step names. A list would * pass forever while the next step added went unguarded in exactly the same way, * which is the mistake `composeEnvironment.test.ts` exists to prevent for * docker-compose and the one repeated here. */ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); const WORKFLOW = path.join(REPO_ROOT, '.gitea', 'workflows', 'sonarqube.yml'); interface Step { name: string; id: string | null; guarded: boolean; } /** * Parsed by hand rather than with a YAML library, because there is no YAML * dependency in this workspace and adding one to read four fields would be the * larger change. The shape being read is fixed and shallow. * * Indentation is what separates a step's own keys from anything nested inside * its `run:` block — a `continue-on-error:` written inside a shell script would * be more deeply indented and is not matched. */ function parseSteps(source: string): Step[] { const steps: Step[] = []; let current: Step | null = null; // Tolerates carriage returns: a checkout with CRLF endings would otherwise // leave a stray return that the end anchors cannot match, and every // assertion below would silently find nothing. That is what the "parsed // some steps at all" case exists to catch. for (const line of source.split(/\r?\n/)) { const named = /^ {6}- name: (.+?)\s*$/.exec(line); if (named?.[1] !== undefined) { current = { name: named[1], id: null, guarded: false }; steps.push(current); continue; } if (!current) continue; const id = /^ {8}id: (\S+)\s*$/.exec(line); if (id?.[1] !== undefined) current.id = id[1]; if (/^ {8}continue-on-error: true\s*$/.test(line)) current.guarded = true; } return steps; } /** * The gate's `if:` expression, as one string. * * Folded across lines in the file for readability, so this collects everything * indented under the `if:` rather than reading a single line. */ function gateCondition(source: string): string { const lines = source.split(/\r?\n/); const start = lines.findIndex((line) => /^ {6}- name: Fail if any guarded step failed\s*$/.test(line)); if (start === -1) return ''; const collected: string[] = []; let inside = false; for (const line of lines.slice(start + 1)) { if (/^ {8}if:/.test(line)) { inside = true; collected.push(line); continue; } if (!inside) continue; // The next key at step level ends the folded block. if (/^ {8}\S/.test(line)) break; collected.push(line); } return collected.join(' '); } const source = readFileSync(WORKFLOW, 'utf8'); const steps = parseSteps(source); const condition = gateCondition(source); describe('sonarqube.yml fails at the end rather than part way through', () => { // Guards the guard: a parser that matched nothing would make every assertion // below vacuously true. it('parsed the workflow at all', () => { expect(steps.length).toBeGreaterThan(10); expect(condition).toContain('always()'); }); it('has a gate step', () => { expect(condition).not.toBe(''); }); /** * `always()` is load-bearing, and its absence has bitten once already. A step * whose `if:` omits it still implicitly requires every previous step to have * succeeded, which would leave this gate as dead code in exactly the case it * exists for. See #142. */ it('evaluates the gate even after a failure', () => { expect(condition).toContain('always()'); }); it('gives every guarded step an id, or the gate cannot name it', () => { const anonymous = steps.filter((step) => step.guarded && step.id === null); expect(anonymous.map((step) => step.name)).toEqual([]); }); /** * The half that turns a broken suite into a green run. A guarded step missing * from the gate cannot fail the job at all. */ it('names every guarded step in the gate', () => { const guarded = steps.filter((step) => step.guarded && step.id !== null); expect(guarded.length).toBeGreaterThan(0); const unnamed = guarded.filter((step) => !condition.includes(`steps.${step.id}.outcome`)); expect(unnamed.map((step) => step.name)).toEqual([]); }); /** * The other direction: a gate naming a step that no longer exists reads as * coverage it does not have, and the expression silently evaluates that clause * to nothing. */ it('names nothing in the gate that is not a step', () => { const ids = new Set(steps.map((step) => step.id).filter((id): id is string => id !== null)); const named = [...condition.matchAll(/steps\.([A-Za-z0-9_-]+)\.outcome/g)].map((match) => match[1]); expect(named.length).toBeGreaterThan(0); const unknown = named.filter((id) => id !== undefined && !ids.has(id)); expect(unknown).toEqual([]); }); /** * Named individually, unlike the structural rules above, because these three * are the reason the design exists. Un-guarding any of them would restore the * behaviour #174 fixed — and it was the integration suite, absent from this * list for as long as it existed, that actually did it. */ it.each(['unit', 'integration', 'e2e'])('runs the %s suite to completion', (id) => { const step = steps.find((candidate) => candidate.id === id); expect(step).toBeDefined(); expect(step?.guarded).toBe(true); }); /** * The scan is the output this whole arrangement protects. Anything between the * first suite and it that can abort the job takes it down, which is what #174 * was. */ it('guards every step between the first suite and the scan', () => { const first = steps.findIndex((step) => step.id === 'unit'); const scan = steps.findIndex((step) => step.id === 'scan'); expect(first).toBeGreaterThan(-1); expect(scan).toBeGreaterThan(first); // `if:`-only steps run conditionally and cannot abort the job on their own, // so they need no guard. Backend log is the one such step in this range. const unguarded = steps .slice(first, scan + 1) .filter((step) => !step.guarded && step.name !== 'Backend log'); expect(unguarded.map((step) => step.name)).toEqual([]); }); });