import { describe, it, expect, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { composeProfile } from '../composeCommand'; import { locateComponentSpan, buildSnippet, attachSnippets, } from '@beehexa/hexasync-template-validate'; 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 SRC = `objects: - id: O1 name: task webhooks: - id: W connectionId: C targets: t: objectIds: - GHOST `; describe('locateComponentSpan', () => { it('locates an array item by id', () => { const span = locateComponentSpan(SRC, 'webhooks', 'W'); expect(span).toBeTruthy(); // "- id: W" is line 5 expect(span!.startLine).toBe(5); expect(span!.endLine).toBeGreaterThanOrEqual(5); }); it('locates a map entry by key', () => { const map = `transformations:\n O1:\n - id: X\n O_MISSING:\n - id: Y\n`; const span = locateComponentSpan(map, 'transformations', 'O_MISSING'); expect(span!.startLine).toBe(4); }); it('returns undefined for a missing component or collection', () => { expect(locateComponentSpan(SRC, 'webhooks', 'NOPE')).toBeUndefined(); expect(locateComponentSpan(SRC, 'pushers', 'X')).toBeUndefined(); expect( locateComponentSpan('not: valid: yaml: :', 'a', 'b'), ).toBeUndefined(); }); }); describe('buildSnippet', () => { it('highlights the line containing locateValue within the component span', () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loc-')); const f = path.join(tmp, 'W.yaml'); write(f, SRC); const snip = buildSnippet( fs.readFileSync(f, 'utf8'), 'webhooks', 'W', 'GHOST', )!; expect(snip).toContain('> '); const marked = snip.split('\n').find((l) => l.startsWith('> ')); expect(marked).toContain('GHOST'); }); it('falls back to the declaration line when locateValue is absent', () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loc-')); const f = path.join(tmp, 'W.yaml'); write(f, SRC); const snip = buildSnippet(fs.readFileSync(f, 'utf8'), 'objects', 'O1')!; expect(snip.split('\n').find((l) => l.startsWith('> '))).toContain( 'id: O1', ); }); it('returns undefined for a missing file or component (graceful)', () => { // A file that cannot be supplied is now `undefined` text rather than a failed read. expect(buildSnippet(undefined, 'objects', 'O1')).toBeUndefined(); tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loc-')); const f = path.join(tmp, 'W.yaml'); write(f, SRC); expect( buildSnippet(fs.readFileSync(f, 'utf8'), 'objects', 'NOPE'), ).toBeUndefined(); }); it('does not bleed into the next sibling and prefers a delimited value match', () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loc-')); const f = path.join(tmp, 'P.yaml'); // A's value list contains "GHOST" both embedded in a name and as a real list entry; // the next sibling B also mentions GHOST — the snippet must stay within A and mark // the delimited "- GHOST" line, not "name: sync-GHOST" and not B's line. write( f, `pushers:\n - id: A\n name: sync-GHOST\n tags:\n - GHOST\n - id: B\n note: about-GHOST\n`, ); const snip = buildSnippet( fs.readFileSync(f, 'utf8'), 'pushers', 'A', 'GHOST', )!; const marked = snip.split('\n').find((l) => l.startsWith('> '))!; expect(marked).toContain('- GHOST'); expect(marked).not.toContain('name:'); expect(marked).not.toContain('id: B'); }); }); describe('attachSnippets', () => { it('sets snippetDiff for a locatable issue and derives locateValue from the message', () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loc-')); const f = path.join(tmp, 'W.yaml'); write(f, SRC); const issues: ValidationIssue[] = [ { ruleId: 'REF-9', family: 'REF', severity: 'critical', parentKey: 'webhooks', idOrKey: 'W', message: 'target "t" objectIds references missing object "GHOST"', why: 'dead', }, ]; const ctx = { sourceFileForStrict: () => f, } as unknown as ValidationContext; attachSnippets(issues, ctx, new Map([[f, fs.readFileSync(f, 'utf8')]])); expect(issues[0].snippetDiff).toContain('GHOST'); }); it('leaves snippetDiff unset when the component cannot be located; never throws', () => { const issues: ValidationIssue[] = [ { ruleId: 'X', family: 'X', severity: 'low', parentKey: 'objects', idOrKey: 'O1', message: 'no quoted value', why: '', }, ]; const ctx = { sourceFileForStrict: () => '/no/such/file.yaml', } as unknown as ValidationContext; // An absent file is now simply an absent map entry — the same graceful degradation. expect(() => attachSnippets(issues, ctx, new Map())).not.toThrow(); expect(issues[0].snippetDiff).toBeUndefined(); }); }); describe('composeProfile — snippet in the report', () => { it('a dangling webhook objectId issue renders a highlighted source snippet', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loc-')); write(path.join(tmp, 'proj/partials/main.yaml'), `externals: []\n`); write(path.join(tmp, 'proj/partials/W.yaml'), SRC); await composeProfile(path.join(tmp, 'proj'), false); const md = fs.readFileSync( path.join(tmp, 'proj/output.validation.report.md'), 'utf8', ); // a highlighted line carries the offending value expect( md .split('\n') .some((l) => l.trim().startsWith('> ') && l.includes('GHOST')), ).toBe(true); }); it('resolves snippets for components authored INLINE in the root main.yaml', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loc-')); // everything in main.yaml (the merge base — no tracer event); seeding must still // let the snippet resolve to main.yaml. write( path.join(tmp, 'proj/partials/main.yaml'), `externals: []\nconnectors:\n - id: C\nobjects:\n - id: O1\n name: t\nwebhooks:\n - id: W\n connectionId: C\n targets:\n t:\n objectIds:\n - GHOST\n`, ); await composeProfile(path.join(tmp, 'proj'), false); const md = fs.readFileSync( path.join(tmp, 'proj/output.validation.report.md'), 'utf8', ); expect( md .split('\n') .some((l) => l.trim().startsWith('> ') && l.includes('GHOST')), ).toBe(true); }); });