import { describe, it, expect, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { composeProfile } from '../composeCommand'; import { runValidation, ValidationRule, } from '@beehexa/hexasync-template-validate'; import { buildValidationReportModel } from '@beehexa/hexasync-template-validate'; import { renderValidationReport } from '@beehexa/hexasync-template-report-render'; import { renderValidationReportHtml } from '@beehexa/hexasync-template-report-render'; import { ValidationContext } from '@beehexa/hexasync-template-validate'; import { ValidationIssue } from '@beehexa/hexasync-template-report-model'; let tmp = ''; afterEach(() => { if (tmp && fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true, force: true }); tmp = ''; }); function write(p: string, content: string) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content); } const issue = (over: Partial = {}): ValidationIssue => ({ ruleId: 'REF-9', family: 'REF', severity: 'critical', parentKey: 'webhooks', idOrKey: 'W', message: 'Webhook target points at a missing object', why: 'the webhook is silently dead at runtime', ...over, }); const stubCtx = (): Pick< ValidationContext, 'projectTitle' | 'sourceLinkFor' > => ({ projectTitle: 'demo', sourceLinkFor: () => 'partials/W.yaml', }); describe('runValidation — rule registry', () => { it('returns [] for an empty registry', () => { expect(runValidation({} as ValidationContext, [])).toEqual([]); }); it('aggregates issues from all rules', () => { const rules: ValidationRule[] = [ { id: 'A', family: 'REF', run: () => [issue({ ruleId: 'A' })] }, { id: 'B', family: 'DUP', run: () => [issue({ ruleId: 'B' }), issue({ ruleId: 'B2' })], }, ]; const out = runValidation({} as ValidationContext, rules); expect(out.map((i) => i.ruleId)).toEqual(['A', 'B', 'B2']); }); it('swallows a throwing rule (never aborts compose)', () => { const rules: ValidationRule[] = [ { id: 'boom', family: 'X', run: () => { throw new Error('nope'); }, }, { id: 'ok', family: 'REF', run: () => [issue({ ruleId: 'ok' })] }, ]; const out = runValidation({} as ValidationContext, rules); expect(out.map((i) => i.ruleId)).toEqual(['ok']); }); }); describe('buildValidationReportModel', () => { it('counts by severity, groups by component, orders CRITICAL→LOW', () => { const issues = [ issue({ ruleId: 'LOW-1', severity: 'low', parentKey: 'objects', idOrKey: 'O', }), issue({ ruleId: 'CRIT-1', severity: 'critical', parentKey: 'objects', idOrKey: 'O', }), issue({ ruleId: 'MED-1', severity: 'medium', parentKey: 'tables', idOrKey: 'T', }), ]; const m = buildValidationReportModel(issues, stubCtx()); expect(m.total).toBe(3); // Every tier is a key, including `very-low` at zero — a count map missing a tier makes a summary line silently // omit it, which is how a whole class of finding disappears from a report without anyone editing the renderer. expect(m.counts).toEqual({ critical: 1, high: 0, major: 0, medium: 1, low: 1, 'very-low': 0, }); // object O (has a critical) sorts before table T (medium only) expect(m.issuesByComponent.map((g) => g.idOrKey)).toEqual(['O', 'T']); // within O, critical before low expect(m.issuesByComponent[0].issues.map((i) => i.severity)).toEqual([ 'critical', 'low', ]); expect(m.issuesByComponent[0].sourceLink).toBe('partials/W.yaml'); }); it('ranks `very-low` LAST, below `low`', () => { /** * The tier exists so deprecation and legacy notices stop competing with findings that break a run (Jazz, * 2026-08-11). Ordering is half of that — the renderers gather them into a trailing section, and this is the * ordering the model owes them. A tier absent from `SEVERITY_RANK` sorts as `undefined` and lands FIRST. */ const m = buildValidationReportModel( [ issue({ ruleId: 'PLK-1', severity: 'very-low', parentKey: 'pullers', idOrKey: 'P', }), issue({ ruleId: 'LOW-1', severity: 'low', parentKey: 'pullers', idOrKey: 'P', }), ], stubCtx(), ); expect(m.issuesByComponent[0].issues.map((i) => i.severity)).toEqual([ 'low', 'very-low', ]); expect(m.counts['very-low']).toBe(1); }); it('routes component-less issues to crossCutting', () => { const m = buildValidationReportModel( [ issue({ parentKey: undefined, idOrKey: undefined, ruleId: 'DUP-1', severity: 'high', }), ], stubCtx(), ); expect(m.issuesByComponent).toEqual([]); expect(m.crossCutting).toHaveLength(1); }); }); /** * Deprecation and legacy notices sit at the END of the report, in their own section. * * Reported by Jazz, 2026-08-11: *"the kind of deprecated/obsolete or legacy warning should be put at the very end of * the report, because it doesn't break the profile runtime. Just focus on real configuration issues."* * * GATHERED, not merely sorted last, and the difference is the whole point. Severity ordering alone still leaves a * legacy puller's notice inside that puller's own `###` block, so a reader going component by component keeps meeting * them — and a component whose ONLY finding is a notice gets a heading that promises a problem. Both renderers make the * same split, because two renderers of one report disagreeing about which section a finding lands in is the class of * defect this repo keeps paying for. */ describe('a migration notice goes to the trailing section, not the body', () => { const mixed = () => buildValidationReportModel( [ issue({ ruleId: 'REF-1', severity: 'high', parentKey: 'objects', idOrKey: 'ORDERS', }), issue({ ruleId: 'PLK-1', severity: 'very-low', parentKey: 'pullers', idOrKey: 'LEGACY_PULLER', message: 'legacy (old-kind) puller — uses pullSteps', }), ], stubCtx(), ); it('Markdown: the notice is below the real findings, under its own heading', () => { const md = renderValidationReport(mixed()); const body = md.indexOf('REF-1'); const section = md.indexOf('## Deprecations & legacy notices'); const notice = md.indexOf('legacy (old-kind) puller'); expect(section).toBeGreaterThan(body); expect(notice).toBeGreaterThan(section); // Before the appendix, which is a reference list rather than findings. expect(md.indexOf('## Appendix')).toBeGreaterThan(notice); // And it says out loud why it is not urgent, so the section is not just a place things are hidden. expect(md).toContain('None of these breaks the profile at runtime'); }); it('Markdown: a component with ONLY a notice gets no findings heading at all', () => { const md = renderValidationReport( buildValidationReportModel( [ issue({ ruleId: 'PLK-1', severity: 'very-low', parentKey: 'pullers', idOrKey: 'LEGACY_PULLER', }), ], stubCtx(), ), ); // The `### pullers — LEGACY_PULLER` heading would promise a configuration problem that is not there… expect(md).not.toContain('### pullers'); /** * …but the notice must still NAME the component, or it is not actionable without one. * * `LEGACY\_PULLER`, escaped: the component travels through `mdText`, which escapes `_` so a name with two of them * is not rendered as italics. Asserted in the escaped form deliberately — a test written against the raw name * would pass only if the escaping were removed, which is the wrong thing to hold still. */ expect(md).toContain('LEGACY\\_PULLER'); }); it('Markdown: a notice is still counted, so the summary cannot hide it', () => { expect(renderValidationReport(mixed())).toContain('1 VERY LOW'); }); it('HTML: makes the same split, with the same wording', () => { const html = renderValidationReportHtml(mixed()); const body = html.indexOf('REF-1'); const section = html.indexOf('Deprecations'); expect(section).toBeGreaterThan(body); expect(html.indexOf('legacy (old-kind) puller')).toBeGreaterThan(section); expect(html).toContain('sev-very-low'); expect(html).toContain('None of these breaks the profile at runtime'); // The component travels with the notice here too. expect(html).toContain('LEGACY_PULLER'); }); it('neither renderer emits an empty notices section when there are none', () => { const clean = buildValidationReportModel( [issue({ ruleId: 'REF-1', severity: 'high' })], stubCtx(), ); expect(renderValidationReport(clean)).not.toContain('Deprecations'); expect(renderValidationReportHtml(clean)).not.toContain('Deprecations'); }); }); describe('renderValidationReport (Markdown)', () => { it('renders an empty state when there are no issues', () => { const md = renderValidationReport( buildValidationReportModel([], stubCtx()), ); expect(md).toContain('✓ No validation issues'); }); it('renders a severity summary, component link, and ordered issues', () => { const md = renderValidationReport( buildValidationReportModel( [ issue({ ruleId: 'CRIT', severity: 'critical' }), issue({ ruleId: 'MED', severity: 'medium' }), ], stubCtx(), ), ); expect(md).toMatch(/## Summary — 1 CRITICAL · 1 MEDIUM/); expect(md).toContain('[W](partials/W.yaml)'); expect(md).toContain('[CRITICAL]'); // critical appears before medium in the body expect(md.indexOf('CRIT')).toBeLessThan(md.indexOf('MED')); }); it('renders a `code`-span in a message as a real Markdown code span (not escaped)', () => { const md = renderValidationReport( buildValidationReportModel( [issue({ ruleId: 'VAR-2', message: 'key does not match `**Name**`' })], stubCtx(), ), ); // the pattern stays a literal code span — backticks preserved, asterisks NOT escaped expect(md).toContain('`**Name**`'); expect(md).not.toContain('\\*\\*Name\\*\\*'); }); it('links each issue rule id to its appendix entry (trailing badge)', () => { const md = renderValidationReport( buildValidationReportModel([issue({ ruleId: 'REF-9' })], stubCtx()), ); expect(md).toContain('[`REF-9`](#ref-9)'); }); it('places the rule-id link inline when the message has a {{rule}} marker', () => { const md = renderValidationReport( buildValidationReportModel( [ issue({ ruleId: 'VAR-2', message: 'key does not match the `variable-key` rule in {{rule}}: `**Name**`', }), ], stubCtx(), ), ); expect(md).toContain('rule in [`VAR-2`](#var-2): `**Name**`'); expect(md).not.toContain('{{rule}}'); }); it('appends a rule-reference appendix (heading id matches the link) explaining each rule present', () => { const md = renderValidationReport( buildValidationReportModel([issue({ ruleId: 'REF-9' })], stubCtx()), ); expect(md).toContain('## Appendix — rule reference'); // heading is the code-wrapped rule id → GitHub slug `#ref-9` matches the link above expect(md).toContain('### `REF-9`'); expect(md).toContain('**Webhook target points at a missing object**'); expect(md).toContain('**What:**'); expect(md).toContain('**Why it matters:**'); // a rule NOT present is not documented expect(md).not.toContain('`FLOW-5`'); }); }); describe('renderValidationReportHtml', () => { it('renders empty state + is self-contained', () => { const html = renderValidationReportHtml( buildValidationReportModel([], stubCtx()), ); expect(html).toContain(''); expect(html).toContain('No validation issues'); }); it('renders severity pills and escapes content', () => { const html = renderValidationReportHtml( buildValidationReportModel( [issue({ message: 'bad