import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { extractFencedJson, parsePagespecFile, parsePagespecsForSection } from '../parse-pagespec.js' import { renamePagespecRefs } from '../../../../../../lib/ba-menu-tree.js' describe('extractFencedJson', () => { it('extracts the JSON block between ```json and ```', () => { const md = '# Title\n\n```json\n{"x":1}\n```\n\nfooter' expect(extractFencedJson(md)).toBe('{"x":1}') }) it('returns null when no fenced ```json block is present', () => { expect(extractFencedJson('# Title\n\nplain markdown body')).toBeNull() expect(extractFencedJson('# Title\n\n```\n{"x":1}\n```')).toBeNull() // no `json` tag }) it('handles multiline JSON with arrays and nested objects', () => { const md = '```json\n{\n "a": [1, 2, 3],\n "b": { "c": "d" }\n}\n```' const out = extractFencedJson(md)! expect(out).toMatch(/"a": \[1, 2, 3\]/) expect(JSON.parse(out).b.c).toBe('d') }) it('only matches the FIRST fenced block when multiple are present', () => { const md = '```json\n{"first":1}\n```\n\n```json\n{"second":2}\n```' expect(extractFencedJson(md)).toBe('{"first":1}') }) it('FIRES (lot 14) — an indented or inline ```json mention is prose: it opens nothing and no longer swallows the real block after it', () => { // fix-bug/SKILL.md shape — the indented illustration used to open a phantom that ran to the real // block's opener, so the FIRST block this reader returned was unparsable and the page was skipped. const indented = '1. Emit:\n ```json\n {"phantom":true}\n ```\n\n```json\n{"real":true}\n```\n' expect(extractFencedJson(indented)).toBe('{"real":true}') // audit-prd/SKILL.md shape — a prose line ENDING with the mention. const inline = '- each a fenced ```json\n block, one per screen.\n\n```json\n{"real":true}\n```\n' expect(extractFencedJson(inline)).toBe('{"real":true}') // Alone, neither is a block. expect(extractFencedJson(' ```json\n {"x":1}\n ```\n')).toBeNull() expect(extractFencedJson('see ```json\n{"x":1}\n```\n')).toBeNull() }) it('reads the grammar of lib/pagespec-blocks — CRLF accepted without its trailing CR, the closer a bare ``` on a line of its own', () => { expect(extractFencedJson('```json\r\n{"x":1}\r\n```\r\n')).toBe('{"x":1}') expect(extractFencedJson('```json\r\n{\r\n "x": 1\r\n}\r\n```')).toBe('{\r\n "x": 1\r\n}') expect(extractFencedJson('```json \n{"x":1}\n```\t\n')).toBe('{"x":1}') // trailing blanks tolerated on either fence line expect(extractFencedJson('```json\n{"x":1}\n``` trailing\n')).toBeNull() // text after the closer keeps the block open expect(extractFencedJson('```json\n{"x":1}```\n')).toBeNull() // closer not at column 0 }) }) const VALID_PAGESPEC = { screenCode: 'SCR-CRM-PIPELINE-OPPORTUNITES-001', appCode: 'crm', module: 'pipeline', section: 'opportunites', entity: 'Opportunity', view: 'list', permission: 'pipeline.opportunites.read', columns: [], actions: [], } describe('parsePagespecFile', () => { it('parses a valid pagespec into a PageSpec', () => { const md = '# Title\n\n```json\n' + JSON.stringify(VALID_PAGESPEC) + '\n```' const { ok, warning } = parsePagespecFile('any.md', md) expect(warning).toBeNull() expect(ok?.screenCode).toBe('SCR-CRM-PIPELINE-OPPORTUNITES-001') expect(ok?.view).toBe('list') }) it('emits a warning when no fenced block is found', () => { const { ok, warning } = parsePagespecFile('any.md', '# Just markdown') expect(ok).toBeNull() expect(warning?.reason).toMatch(/no fenced/) }) it('emits a warning when the JSON is malformed', () => { const { ok, warning } = parsePagespecFile('any.md', '```json\n{ not json }\n```') expect(ok).toBeNull() expect(warning?.reason).toMatch(/invalid JSON/) }) it('emits a warning when the schema is violated (bad screenCode)', () => { const bad = { ...VALID_PAGESPEC, screenCode: 'NOT-A-SCR-CODE' } const md = '```json\n' + JSON.stringify(bad) + '\n```' const { ok, warning } = parsePagespecFile('any.md', md) expect(ok).toBeNull() expect(warning?.reason).toMatch(/schema violation/) }) it('accepts unknown fields (passthrough — i18nKeys, refinement, etc.)', () => { const enriched = { ...VALID_PAGESPEC, i18nKeys: { fr: { foo: 'bar' } }, needsRefinement: false } const md = '```json\n' + JSON.stringify(enriched) + '\n```' const { ok, warning } = parsePagespecFile('any.md', md) expect(warning).toBeNull() expect(ok).not.toBeNull() }) it('SILENT (lot 14) — a pagespec re-serialised by renamePagespecRefs (a real SmartStack writer) parses to the block it wrote, the prose shapes below it ignored', () => { const authored = [ '# Opportunity.list', '', '```json', JSON.stringify(VALID_PAGESPEC, null, 2), '```', '', '## Notes', '', '1. The machine block above is the fenced ```json', ' ```json', ' { "example": "an indented illustration, not the spec" }', ' ```', '', ].join('\n') const emitted = renamePagespecRefs(authored, { app: 'crm', mod: 'pipeline', oldSec: 'opportunites', newSec: 'deals', ownModule: true }) expect(emitted.error).toBeUndefined() expect(emitted.count).toBeGreaterThan(0) for (const content of [emitted.content, emitted.content.replace(/\n/g, '\r\n')]) { // as written, and after a CRLF checkout const { ok, warning } = parsePagespecFile('Opportunity.list.md', content) expect(warning).toBeNull() expect(ok).toMatchObject({ section: 'deals', permission: 'pipeline.deals.read', entity: 'Opportunity', view: 'list' }) } }) }) describe('parsePagespecsForSection', () => { let baseDir: string beforeEach(() => { baseDir = mkdtempSync(join(tmpdir(), 'parse-pagespec-')) mkdirSync(join(baseDir, 'pagespecs'), { recursive: true }) }) afterEach(() => rmSync(baseDir, { recursive: true, force: true })) function write(name: string, spec: object): void { writeFileSync(join(baseDir, 'pagespecs', name), '```json\n' + JSON.stringify(spec) + '\n```', 'utf8') } it('returns only pagespecs whose section matches', () => { write('A.md', { ...VALID_PAGESPEC, section: 'opportunites' }) write('B.md', { ...VALID_PAGESPEC, section: 'offers', screenCode: 'SCR-CRM-PIPELINE-OFFERS-001' }) const r = parsePagespecsForSection(baseDir, 'opportunites') expect(r.pagespecs.length).toBe(1) expect(r.pagespecs[0].section).toBe('opportunites') }) it('additionally filters by entity when provided', () => { write('A.md', { ...VALID_PAGESPEC, entity: 'Opportunity' }) write('B.md', { ...VALID_PAGESPEC, entity: 'Pipeline', screenCode: 'SCR-CRM-PIPELINE-OPPORTUNITES-002' }) const r = parsePagespecsForSection(baseDir, 'opportunites', 'Opportunity') expect(r.pagespecs.length).toBe(1) expect(r.pagespecs[0].entity).toBe('Opportunity') }) it('sorts pagespecs by view order (list → detail → form → …)', () => { write('F.md', { ...VALID_PAGESPEC, view: 'form', screenCode: 'SCR-X-001' }) write('L.md', { ...VALID_PAGESPEC, view: 'list', screenCode: 'SCR-X-002' }) write('D.md', { ...VALID_PAGESPEC, view: 'detail', screenCode: 'SCR-X-003' }) const r = parsePagespecsForSection(baseDir, 'opportunites') expect(r.pagespecs.map(p => p.view)).toEqual(['list', 'detail', 'form']) }) it('returns empty result when the pagespecs/ directory is missing', () => { const empty = mkdtempSync(join(tmpdir(), 'parse-pagespec-empty-')) try { const r = parsePagespecsForSection(empty, 'opportunites') expect(r.pagespecs).toEqual([]) expect(r.warnings).toEqual([]) } finally { rmSync(empty, { recursive: true, force: true }) } }) it('skips non-md files and collects warnings for malformed ones', () => { write('A.md', VALID_PAGESPEC) writeFileSync(join(baseDir, 'pagespecs', 'B.md'), '# no fenced block here', 'utf8') writeFileSync(join(baseDir, 'pagespecs', 'notes.txt'), 'ignored', 'utf8') const r = parsePagespecsForSection(baseDir, 'opportunites') expect(r.pagespecs.length).toBe(1) expect(r.warnings.length).toBe(1) expect(r.warnings[0].file).toMatch(/B\.md$/) }) })