import { describe, it, expect, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { composeProfile } from '../composeCommand'; import { analyzeWorkflow, flowRule } from '@beehexa/hexasync-template-validate'; import { ValidationContext } from '@beehexa/hexasync-template-validate'; 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 rules = (steps: any[]) => analyzeWorkflow(steps).map((f) => f.ruleId); describe('analyzeWorkflow', () => { it('a well-formed linear workflow yields no findings', () => { const steps = [ { key: 'A', rootStep: true, next: 'B' }, { key: 'B', next: 'C' }, { key: 'C', next: null }, ]; expect(analyzeWorkflow(steps)).toEqual([]); }); it('treats EVERY rootStep as a valid entry — multiple roots are not flagged', () => { // two independent entry chains (a real shipped pattern; flowChart.ts runs all roots) const steps = [ { key: 'A', rootStep: true, next: 'A2' }, { key: 'A2', next: null }, { key: 'B', rootStep: true, next: 'B2' }, { key: 'B2', next: null }, ]; expect(analyzeWorkflow(steps)).toEqual([]); }); it('no rootStep is a valid first-step-entry fallback (no finding)', () => { const steps = [ { key: 'A', next: 'B' }, { key: 'B', next: null }, ]; expect(analyzeWorkflow(steps)).toEqual([]); }); it('FLOW-4 dangling next target (high) — attributed to the source step', () => { const steps = [{ key: 'A', rootStep: true, next: 'MISSING' }]; const f = analyzeWorkflow(steps).find((x) => x.ruleId === 'FLOW-4'); expect(f?.severity).toBe('high'); expect(f?.locateValue).toBe('A'); // source step (locatable), message names MISSING expect(f?.message).toContain('MISSING'); }); it('FLOW-3 orphan step (high), suppressed when a dynamic next exists', () => { const orphan = [ { key: 'A', rootStep: true, next: 'B' }, { key: 'B', next: null }, { key: 'ORPHAN', next: null }, ]; expect(rules(orphan)).toContain('FLOW-3'); const withDynamic = [ { key: 'A', rootStep: true, next: '{{ dynamic }}' }, { key: 'B', next: null }, { key: 'ORPHAN', next: null }, ]; expect(rules(withDynamic)).not.toContain('FLOW-3'); }); /** * An OLD-KIND cycle is reported at `low` — reported by Jazz 2026-08-11, against a paginating * `SapoWeb_SalesOrder_Puller` whose loop through `CHECK_COUNT` does terminate. * * The severity is asserted, not just the rule id: `toContain('FLOW-5')` passed at every severity, which is how the * old `high` survived unexamined. The new-kind cases in `newKindPuller.spec.ts` still pin `high`, because there the * rule has actually decided something. */ it('FLOW-5 next cycle — reported, at LOW for an old-kind workflow', () => { const steps = [ { key: 'A', rootStep: true, next: 'B' }, { key: 'B', next: 'A' }, ]; const f = analyzeWorkflow(steps).find((x) => x.ruleId === 'FLOW-5'); expect(f?.severity).toBe('low'); expect(f?.locateValue).toBe('A'); // The wording no longer asserts the loop is broken, because this branch cannot know that. expect(f?.message).not.toContain('infinite'); expect(f?.message).toContain('cannot tell a paginating loop'); // And the family's "does not execute as authored" line is NOT borrowed for it. expect(f?.why).toContain('may well be fine'); }); it('FLOW-6 empty SWITCH and IF missing branch (medium)', () => { const sw = [{ key: 'A', rootStep: true, displayType: 'SWITCH', data: {} }]; expect(rules(sw)).toContain('FLOW-6'); const iff = [ { key: 'A', rootStep: true, displayType: 'IF', data: { if: 'x', then: 'B' }, // else missing }, { key: 'B' }, ]; expect(rules(iff)).toContain('FLOW-6'); }); it('resolves SWITCH branch keys and IF then/else as edges (no false dangling/orphan)', () => { const steps = [ { key: 'A', rootStep: true, displayType: 'SWITCH', data: { B: { if: 'x' }, C: { if: 'y' } }, }, { key: 'B', next: null }, { key: 'C', next: null }, ]; expect(analyzeWorkflow(steps)).toEqual([]); }); }); describe('composeProfile — FLOW integrated into the report', () => { it('flags a dangling next as HIGH FLOW-4', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-')); write(path.join(tmp, 'proj/partials/main.yaml'), `externals: []\n`); write( path.join(tmp, 'proj/partials/P.yaml'), `pullers:\n - id: P\n pullSteps:\n - key: A\n rootStep: true\n next: MISSING\n`, ); await composeProfile(path.join(tmp, 'proj'), false); const md = fs.readFileSync( path.join(tmp, 'proj/output.validation.report.md'), 'utf8', ); expect(md).toContain('FLOW-4'); expect(md).toContain('MISSING'); }); });