import { expect, it } from 'vitest'; import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, } from 'node:fs'; import { parse } from 'yaml'; import { workerFlow, renderMarkdown as renderWorkerMarkdown, } from '@beehexa/hexasync-template-worker-flow'; import { frontendFlow, renderDocumentMarkdown, } from '@beehexa/hexasync-template-frontend-flow'; import { goldenSuite, GOLDEN_FIXTURES_DIR } from './goldenCheckout.js'; /** * The golden-diagram suite — Story 4.4. * * ### Why it lives in `apps/cli` and not in a package * * It renders through **both** flow packages, so it belongs to whichever layer may import both — and an app may. More * to the point, `corpusCheckout.spec.ts`'s discipline (an absent checkout is a **failure**, never a skip) is enforced * only over `apps/cli/src/__tests__`, and AC-3 demands exactly that behaviour: *"the suite fails loudly rather than * skipping green."* Putting it anywhere else would put it out of reach of the rule it has to obey. * * ⚠️ **Three specs of mine are out of that reach**, found while writing this: `renderCorpus.spec.ts`, * `referenceParity.spec.ts` and `stageSchemaParity.spec.ts` all read a sibling checkout from inside `packages/`, all * use `describe.skip`, and none is registered in `vitest.config.ts`'s `CORPUS`. The enforcement scans one directory, * so it passed by not looking — the same shape as the dead compose-contract guard and the unguarded `hexasync.layer`. * They are registered now, and `corpusCheckout.spec.ts` is widened to see them. * * ### AC-1 needed a machine-comparable expectation, and none existed * * `epic-4-ground-truth.md` records this as the story's largest gap: `baseline/` holds **prose Markdown** with inline * fenced mermaid across three READMEs, and *"there is no `.snap`, `.json` or otherwise diffable artefact"*. So the * expectation is DERIVED here: rendered once, written to `__golden__/`, and compared byte-for-byte thereafter. Set * `UPDATE_GOLDEN=1` to re-derive, which is the deliberate act AC-2 requires of a difference. * * ### AC-2 is the point of the suite, not a side note * * Every difference from the recorded baseline needs an explicit **adopt / preserve / accept**. Story 4.3's review found * ten shipped with none. This suite asserts the verdicts are *written down* — it cannot judge whether a verdict is * right, but it can refuse to let an unrecorded difference through. */ const GOLDEN_DIR = `${import.meta.dirname}/__golden__`; const UPDATE = process.env.UPDATE_GOLDEN === '1'; /** The five recorded fixtures AC-1 names, and which model each is. */ const FIXTURES = [ { file: '01-pusher.yaml', runtime: 'worker', collection: 'pushers' }, { file: '02-puller-legacy.yaml', runtime: 'worker', collection: 'pullers' }, { file: '03-puller-newkind.yaml', runtime: 'worker', collection: 'pullers' }, { file: '04-creationSteps.yaml', runtime: 'frontend' }, { file: '05-connector-workflows.yaml', runtime: 'frontend' }, ] as const; /** * The eleven verdicts, each of which the suite finds IN the record — by id, not by vibe. * * ⛔ Story 4.4 review, HIGH-1. The first version looped over these and asserted only * `expect(record).toContain(verdict)` — three global substring checks repeated eleven times, while the failure message * claimed *"`${id}` is not explained in the story record"*. The **id was never searched for**, and 0 of the 10 appeared * anywhere in the record. Replacing every id with `this-difference-does-not-exist` left the suite green. * * What that hid: two verdicts contradicted the record (`DEFERRED` where the spec said `preserve`, `RETIRE` where it * said `adopt` — neither word expressible in the three-word vocabulary), and `toHaveLength(10)` locked out the * eleventh, which the commit message had headlined as *"found by deriving"*. * * So the record now carries an id column and this parses it. A verdict the record does not state, or states with a * different word, fails. */ const VERDICTS: readonly { id: string; verdict: 'ADOPT' | 'PRESERVE' | 'ACCEPT'; }[] = [ { id: 'worker-node-label-carries-name', verdict: 'PRESERVE' }, { id: 'node-id-spelling-ad33', verdict: 'ACCEPT' }, { id: 'stage-edge-geometry-cli-terminal-to-entry', verdict: 'ADOPT' }, { id: 'frontend-diamond-count-dashboard', verdict: 'PRESERVE' }, { id: 'lone-stage-not-boxed', verdict: 'ACCEPT' }, { id: 'subgraph-titles-prose', verdict: 'ADOPT' }, { id: 'escaping-widened-amp-lt-gt', verdict: 'ADOPT' }, { id: 'empty-flow-placeholder', verdict: 'ADOPT' }, { id: 'cross-stage-onerror-phantom', verdict: 'PRESERVE' }, { id: 'edges-outside-subgraph', verdict: 'ACCEPT' }, { id: 'unresolved-target-digest-address', verdict: 'ACCEPT' }, { id: 'direction-td-not-lr', verdict: 'ACCEPT' }, { id: 'expression-diamond-drawn', verdict: 'ADOPT' }, { id: 'stage-edge-labels', verdict: 'ADOPT' }, { id: 'data-if-junction-drawn', verdict: 'ADOPT' }, { id: 'end-terminal-per-step', verdict: 'PRESERVE' }, { id: 'stage-edges-node-not-subgraph', verdict: 'ADOPT' }, { id: 'mermaid-flow-steps-payload-map-retired', verdict: 'ADOPT' }, /** * Story 4.7's three, added 2026-08-12 (Epic 4 close review). * * ⛔ They were recorded in Story 4.7's own record only, and this suite parses exactly ONE file — Story 4.3's — with a * bidirectional exact-set assertion. So the three ids existed in no source file in either repository, and Story 4.7's * AC-4 (*"any difference from the baseline is one of the verdicts recorded in Story 4.4"*) was met by nothing. The * rows now live in the parsed table; a verdict is recorded only if the enforced set holds it. */ { id: 'linkstyle-line-per-edge', verdict: 'ACCEPT' }, { id: 'pusher-stage-title-prose', verdict: 'ADOPT' }, { id: 'pusher-node-ids-ad33', verdict: 'ADOPT' }, ]; /** Every `| \`id\` | … | **VERDICT** | … |` row of the record's table. */ function verdictsInRecord(record: string): Map { const found = new Map(); for (const [, id, verdict] of record.matchAll( /^\|\s*`([a-z0-9-]+)`\s*\|[^|]*\|\s*\*\*([A-Z]+)/gm, )) { found.set(id!, verdict!); } return found; } function fixture(name: string): Record { const parsed = parse(readFileSync(`${GOLDEN_FIXTURES_DIR}/${name}`, 'utf8')); return (parsed ?? {}) as Record; } /** Every component of a worker fixture, rendered as Markdown — the form the report and an MCP resource both serve. */ function renderWorkerFixture( document: Record, collection: 'pullers' | 'pushers', ): string { const list = Array.isArray(document[collection]) ? (document[collection] as unknown[]) : []; return list .filter((c): c is Record => !!c && typeof c === 'object') .map((component) => renderWorkerMarkdown( workerFlow({ collection, component, componentId: component.id }), { multiStage: true }, ), ) .join('\n'); } function renderFrontendFixture(document: Record): string { /** * A connector definition declares its workflows at the DOCUMENT root; fixture 04 wraps `creationSteps` in a template * manifest. Both shapes are passed as they are — the model reads what is there and reports nothing for what is not. */ return renderDocumentMarkdown( frontendFlow({ document, documentId: document.id }), ); } /** ⛔ Throws at MODULE LOAD when the sibling checkout is absent — AC-3. */ const suite = goldenSuite( 'golden-flows', 'The five recorded fixtures and the pre-migration baseline cannot be read.', 'Every claim this suite makes about "one generator" is derived from them, so a skip here would report green ' + 'for a property nothing checked.', ); suite('every fixture renders to its checked-in expectation (AC-1)', () => { it('found all five fixtures', () => { // Non-vacuity: a mis-rooted path would otherwise make every case below pass by not running. for (const { file } of FIXTURES) { expect(existsSync(`${GOLDEN_FIXTURES_DIR}/${file}`), file).toBe(true); } }); it.each(FIXTURES.map((f) => [f.file, f] as const))('%s', (_name, spec) => { const document = fixture(spec.file); const rendered = spec.runtime === 'worker' ? renderWorkerFixture(document, spec.collection) : renderFrontendFixture(document); /** * Non-vacuity per fixture, and NOT a length floor. * * ⚠️ It was `length > 80` plus a fence check, and the worker empty-flow **placeholder** clears both — 90 characters * for fixture 03's id, more for 01 and 02 (Story 4.4 review, MED-2). So three of the five fixtures could have * rendered nothing at all and still been blessed. What makes a render non-empty is that it drew real nodes. */ expect(rendered, `${spec.file} rendered no diagram`).toContain( '```mermaid', ); expect( rendered, `${spec.file} rendered the EMPTY-FLOW placeholder, not a flow`, ).not.toContain('empty["No'); const declared = [...rendered.matchAll(/^ {4}\S+[[({]/gm)].length; // Fixture 02 is a genuine ONE-step puller, so one node is legitimate; what must not happen is zero, or the // placeholder above standing in for a flow. expect(declared, `${spec.file} declared ${declared} nodes`).toBeGreaterThan( 0, ); const expectationFile = `${GOLDEN_DIR}/${spec.file.replace(/\.yaml$/, '.md')}`; /** * ⛔ The expectation is written ONLY under `UPDATE_GOLDEN=1` (Story 4.4 review, HIGH-1). * * It used to be written on the failing path too — so run 1 failed AND created the file, and run 2 compared against * what run 1 had just produced and passed. Proven end to end by both reviewers: drop two of fixture 05's three * workflows, delete its snapshot, run twice — green, with a snapshot nobody decided and no verdict recorded. And * because CI never runs the `corpus` project, nobody ever sees run 1. * * That is exactly what this file claimed to refuse. A rejected render is written beside the expectation as * `.actual` instead, so it can be diffed without ever becoming the answer. */ if (UPDATE) { mkdirSync(GOLDEN_DIR, { recursive: true }); writeFileSync(expectationFile, rendered, 'utf8'); return; } if (!existsSync(expectationFile)) { mkdirSync(GOLDEN_DIR, { recursive: true }); writeFileSync(`${expectationFile}.actual`, rendered, 'utf8'); expect( false, `${spec.file}: no checked-in expectation. What was rendered is beside it as ` + `${spec.file.replace(/\.yaml$/, '.md')}.actual — read it, then re-run with UPDATE_GOLDEN=1 and commit, ` + `stating the verdict for every difference from the baseline.`, ).toBe(true); return; } expect(rendered).toBe(readFileSync(expectationFile, 'utf8')); }); }); suite('every difference from the baseline carries a verdict (AC-2)', () => { const storyRecord = `${GOLDEN_FIXTURES_DIR}/../../stories/4-3-one-renderer-set-with-run-highlighting-as-argument.md`; it('the story record exists, which is where the reasoning lives', () => { /** * Story 4.3 shipped with no record at all, so ten differences had nowhere to carry a verdict — the third story * running where that was itself a finding. The suite refuses to be the only place they are written down. */ expect(existsSync(storyRecord), storyRecord).toBe(true); }); it('finds every verdict in the record, BY ID, with the same word', () => { const inRecord = verdictsInRecord(readFileSync(storyRecord, 'utf8')); // Non-vacuity: a regex matching nothing would make every check below pass by not running. expect( inRecord.size, "no verdict rows found — the record's table shape changed", ).toBe(VERDICTS.length); for (const { id, verdict } of VERDICTS) { expect( inRecord.get(id), `${id} is not in the record's verdict table`, ).toBe(verdict); } }); it('has a verdict for EVERY row, and no orphan either way', () => { // Both directions: a row added to the record without one here is as much a gap as the reverse. const inRecord = verdictsInRecord(readFileSync(storyRecord, 'utf8')); expect([...inRecord.keys()].sort()).toEqual( VERDICTS.map((v) => v.id).sort(), ); }); it('the recorded baseline is still present to compare against', () => { // The baseline is prose Markdown, so the suite cannot diff it mechanically — Story 4.4's own gap, recorded in // `epic-4-ground-truth.md`. What it CAN do is refuse to let the baseline disappear unnoticed. for (const surface of [ 'cli-compose-report', 'vscode-extension', 'dashboard-v2', ]) { const readme = `${GOLDEN_FIXTURES_DIR}/../baseline/${surface}/README.md`; expect(existsSync(readme), readme).toBe(true); expect(readFileSync(readme, 'utf8')).toContain('```mermaid'); } }); }); suite('one generator is a PROPERTY, not an intention', () => { it('renders both runtimes through the same emission algebra', () => { /** * The claim the epic exists to make true. Asserted structurally: both runtimes' output uses the same chart header, * the same node-id charset and the same fence, because both go through `flowRender`. */ const worker = renderWorkerFixture(fixture('01-pusher.yaml'), 'pushers'); const frontend = renderFrontendFixture( fixture('05-connector-workflows.yaml'), ); for (const [name, output] of [ ['worker', worker], ['frontend', frontend], ] as const) { expect(output, name).toMatch(/```mermaid\nflowchart (TD|LR)\n/); // Every declared node id is `[A-Za-z0-9_]`, which is what makes AD-33's projection safe. for (const [, id] of output.matchAll(/^ {4}([^\s[({]+)[[({]/gm)) { expect(id, `${name}: ${id}`).toMatch(/^[A-Za-z0-9_]+$/); } } }); it('never emits an empty label in any fixture', () => { // CRITICAL-1: an empty quoted label is a parse error that costs the whole chart. for (const spec of FIXTURES) { const document = fixture(spec.file); const rendered = spec.runtime === 'worker' ? renderWorkerFixture(document, spec.collection) : renderFrontendFixture(document); expect(rendered, spec.file).not.toMatch( /\[""\]|\{""\}|\(\[""\]\)|\|""\|/, ); } }); }); suite('the suite cannot be fooled by its own machinery', () => { it('renders the same bytes twice, in one process (MED-3)', () => { // Nothing related the two renders each fixture already gets, so determinism was observed rather than asserted — // and AD-29's golden expectation is worthless without it. for (const spec of FIXTURES) { const once = spec.runtime === 'worker' ? renderWorkerFixture(fixture(spec.file), spec.collection) : renderFrontendFixture(fixture(spec.file)); const twice = spec.runtime === 'worker' ? renderWorkerFixture(fixture(spec.file), spec.collection) : renderFrontendFixture(fixture(spec.file)); expect(twice, spec.file).toBe(once); } }); it('covers EVERY fixture on disk, and leaves no orphan expectation (MED-4)', () => { /** * `found all five fixtures` only checked the five it names, so a sixth recorded fixture would sit unrendered while * the AC says "the five recorded fixtures". A census closes it in both directions. */ const onDisk = readdirSync(GOLDEN_FIXTURES_DIR) .filter((name) => name.endsWith('.yaml')) .sort(); expect(onDisk, 'a fixture exists that this suite does not render').toEqual( FIXTURES.map((f) => f.file).sort(), ); const expectations = existsSync(GOLDEN_DIR) ? readdirSync(GOLDEN_DIR) .filter((n) => n.endsWith('.md')) .sort() : []; expect(expectations, 'an expectation exists for no fixture').toEqual( FIXTURES.map((f) => f.file.replace(/\.yaml$/, '.md')).sort(), ); }); it('does not box a LONE stage by default, which is verdict `lone-stage-not-boxed` (MED-5)', () => { /** * Every snapshot is taken with `multiStage: true`, so the accepted DEFAULT had no coverage at all. Fixture 02 is a * one-stage puller, which is exactly the shape the verdict is about. */ const flow = workerFlow({ collection: 'pullers', component: ( fixture('02-puller-legacy.yaml').pullers as Record[] )[0]!, componentId: undefined, }); expect(renderWorkerMarkdown(flow)).not.toContain('subgraph'); expect(renderWorkerMarkdown(flow, { multiStage: true })).toContain( 'subgraph', ); }); it('gives every node in every snapshot a UNIQUE mermaid id', () => { /** * ⛔ A real collision exists and nothing was looking for it: two of fixture 05's unresolved-Liquid targets differ * only by `==` versus `!=`, and the sanitiser maps both to the same id — so mermaid merges them into one node, * takes the second label, and invents a join. The charset check that was here ran on two fixtures, never saw * subgraph ids, and never checked uniqueness. */ for (const spec of FIXTURES) { const rendered = spec.runtime === 'worker' ? renderWorkerFixture(fixture(spec.file), spec.collection) : renderFrontendFixture(fixture(spec.file)); const ids = [ ...rendered.matchAll(/^ {2,4}(?:subgraph )?([A-Za-z0-9_]+)[[({]/gm), ].map((m) => m[1]!); expect(ids.length, spec.file).toBeGreaterThan(0); const duplicated = ids.filter((id, i) => ids.indexOf(id) !== i); expect([...new Set(duplicated)], `${spec.file}: ids collide`).toEqual([]); for (const id of ids) expect(id, `${spec.file}: ${id}`).toMatch(/^[A-Za-z0-9_]+$/); } }); });