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' 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}') }) }) 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() }) }) 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$/) }) })