/** * Parsing Ansible's PLAY RECAP, and the one classification that must not be got * wrong. * * `--check` is how `verifyAspectCoverage` asks a HOST whether an aspect is * applied, instead of consulting a stored claim that it once ran (celilo#902 * design D6). The trap is that check mode does not evaluate a task it cannot * support — it SKIPS it — so a role built from `command:` / `shell:` tasks can * finish a check run reporting `changed=0` having never been applied at all. * * Read as a boolean ("no changes, therefore applied") that is a confidently * clean answer about an unconverged host: the same failure shape as the stored * verdict this approach exists to avoid, arriving by a different route. Hence * three outcomes, and hence this file. */ import { describe, expect, it } from 'bun:test'; import { parseAnsibleRecap } from './deploy-ansible'; const RECAP = ` PLAY RECAP ********************************************************************* caddy-int : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 vpn : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 legacy-box : ok=1 changed=0 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0 dead-host : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0 `; describe('parseAnsibleRecap', () => { it('reads every host line with its counters', () => { const recaps = parseAnsibleRecap(RECAP); expect(recaps.map((r) => r.host)).toEqual(['caddy-int', 'vpn', 'legacy-box', 'dead-host']); expect(recaps[0]).toEqual({ host: 'caddy-int', ok: 3, changed: 0, unreachable: 0, failed: 0, skipped: 0, }); }); it('SKIPPED IS NOT UNCHANGED — the case that must never read as applied', () => { // THE assertion this file exists for. `legacy-box` reports zero changes, // which a two-state reading calls "applied". It is not: two of its tasks // were never evaluated, so nothing about its convergence was measured. // If this ever passes while a caller treats it as applied, celilo is once // again confidently reporting a host it has not looked at. const legacy = parseAnsibleRecap(RECAP).find((r) => r.host === 'legacy-box'); expect(legacy).toBeDefined(); expect(legacy?.changed).toBe(0); expect(legacy?.skipped).toBeGreaterThan(0); }); it('separates a host that could not be reached from one that was measured clean', () => { const recaps = parseAnsibleRecap(RECAP); expect(recaps.find((r) => r.host === 'dead-host')?.unreachable).toBe(1); expect(recaps.find((r) => r.host === 'caddy-int')?.unreachable).toBe(0); }); it('survives ANSI colour, which the progress filter leaves in', () => { const coloured = '\x1b[0;32mweb-01\x1b[0m : ok=5 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0'; const [recap] = parseAnsibleRecap(coloured); expect(recap.host).toBe('web-01'); expect(recap.changed).toBe(2); expect(recap.skipped).toBe(1); }); it('returns nothing for output with no recap, rather than inventing a clean one', () => { // A run that produced no recap measured nothing. Callers must not read an // empty array as "every host is fine" — verifyAspectCoverage classifies a // host with no recap line as unknown for exactly this reason. expect(parseAnsibleRecap('fatal: could not connect\n')).toEqual([]); expect(parseAnsibleRecap('')).toEqual([]); }); });