import { describe, it, expect } from 'vitest'; import { parseComponentContract, isContractFile } from '../contract-parser.js'; import { componentContractSchema } from '../component-contract.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeContract(overrides: Record = {}): string { return JSON.stringify({ $schema: 'https://usefragments.com/schemas/contract.v1.json', name: 'Button', description: 'A clickable button component', category: 'actions', sourcePath: 'src/components/Button/index.tsx', exportName: 'Button', propsSummary: [ 'variant: primary|secondary|ghost (default: primary)', 'size: sm|md|lg (default: md)', 'disabled: boolean (default: false)', ], props: { variant: { type: 'enum', values: ['primary', 'secondary', 'ghost'], default: 'primary', description: 'Visual style variant', }, size: { type: 'enum', values: ['sm', 'md', 'lg'], default: 'md', description: 'Button size', }, disabled: { type: 'boolean', default: false, description: 'Whether the button is disabled', }, }, usage: { when: ['User needs to trigger an action'], whenNot: ['Navigation — use Link instead'], }, provenance: { source: 'manual', verified: false, }, ...overrides, }); } // --------------------------------------------------------------------------- // isContractFile // --------------------------------------------------------------------------- describe('isContractFile', () => { it('detects .contract.json extension', () => { expect(isContractFile('Button.contract.json')).toBe(true); expect(isContractFile('src/components/Card.contract.json')).toBe(true); }); it('rejects non-contract files', () => { expect(isContractFile('Button.fragment.tsx')).toBe(false); expect(isContractFile('contract.json')).toBe(false); expect(isContractFile('button.json')).toBe(false); expect(isContractFile('button.contract.ts')).toBe(false); }); }); // --------------------------------------------------------------------------- // Schema validation // --------------------------------------------------------------------------- describe('componentContractSchema', () => { const minimalValid = { $schema: 'https://usefragments.com/schemas/contract.v1.json', name: 'Button', description: 'A button', category: 'actions', sourcePath: 'src/Button.tsx', exportName: 'Button', propsSummary: [], props: {}, usage: { when: ['click'], whenNot: ['nav'] }, provenance: { source: 'manual', verified: false }, }; it('validates a minimal contract', () => { const result = componentContractSchema.safeParse(minimalValid); expect(result.success).toBe(true); }); it('rejects contract missing required fields', () => { const data = { name: 'Button' }; const result = componentContractSchema.safeParse(data); expect(result.success).toBe(false); }); it('rejects contract missing $schema', () => { const { $schema: _, ...noSchema } = minimalValid; const result = componentContractSchema.safeParse(noSchema); expect(result.success).toBe(false); }); it('rejects contract missing sourcePath', () => { const { sourcePath: _, ...noSource } = minimalValid; const result = componentContractSchema.safeParse(noSource); expect(result.success).toBe(false); }); it('rejects contract missing exportName', () => { const { exportName: _, ...noExport } = minimalValid; const result = componentContractSchema.safeParse(noExport); expect(result.success).toBe(false); }); it('rejects contract missing provenance', () => { const { provenance: _, ...noProv } = minimalValid; const result = componentContractSchema.safeParse(noProv); expect(result.success).toBe(false); }); it('rejects contract with invalid status', () => { const result = componentContractSchema.safeParse({ ...minimalValid, status: 'invalid-status' }); expect(result.success).toBe(false); }); it('accepts all valid status values', () => { for (const status of ['stable', 'beta', 'deprecated', 'experimental']) { const result = componentContractSchema.safeParse({ ...minimalValid, status }); expect(result.success).toBe(true); } }); it('accepts all valid framework values', () => { for (const framework of ['react', 'vue', 'svelte', 'web-components', 'angular']) { const result = componentContractSchema.safeParse({ ...minimalValid, framework }); expect(result.success).toBe(true); } }); it('rejects invalid relationship type', () => { const result = componentContractSchema.safeParse({ ...minimalValid, relations: [{ component: 'Link', relationship: 'replaces', note: 'test' }], }); expect(result.success).toBe(false); }); it('accepts all valid relationship types', () => { for (const relationship of ['alternative', 'parent', 'child', 'sibling']) { const result = componentContractSchema.safeParse({ ...minimalValid, relations: [{ component: 'Link', relationship, note: 'test' }], }); expect(result.success).toBe(true); } }); it('validates ai metadata', () => { const result = componentContractSchema.safeParse({ ...minimalValid, ai: { compositionPattern: 'compound', subComponents: ['Header', 'Body', 'Footer'], requiredChildren: ['Body'], commonPatterns: [''], }, }); expect(result.success).toBe(true); }); it('validates preview config', () => { const result = componentContractSchema.safeParse({ ...minimalValid, preview: { setupModule: './preview-setup.ts', wrapperModule: './ThemeProvider.tsx', wrapperExport: 'ThemeProvider', css: ['./globals.css'], theme: 'dark', }, }); expect(result.success).toBe(true); }); it('validates provenance with all fields', () => { const result = componentContractSchema.safeParse({ ...minimalValid, provenance: { source: 'extracted', verified: true, frameworkSupport: 'native', sourceHash: 'abc123', extractedAt: '2026-03-13T00:00:00Z', }, }); expect(result.success).toBe(true); }); }); // --------------------------------------------------------------------------- // parseComponentContract // --------------------------------------------------------------------------- describe('parseComponentContract', () => { describe('basic parsing', () => { it('parses a minimal contract', () => { const content = makeContract(); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.meta.name).toBe('Button'); expect(result.meta.description).toBe('A clickable button component'); expect(result.meta.category).toBe('actions'); expect(result.filePath).toBe('Button.contract.json'); }); it('maps props correctly', () => { const content = makeContract(); const result = parseComponentContract(content, 'Button.contract.json'); expect(Object.keys(result.props)).toHaveLength(3); expect(result.props.variant.type).toBe('enum'); expect(result.props.variant.values).toEqual(['primary', 'secondary', 'ghost']); expect(result.props.variant.default).toBe('primary'); expect(result.props.variant.description).toBe('Visual style variant'); }); it('maps usage correctly', () => { const content = makeContract(); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.usage.when).toEqual(['User needs to trigger an action']); expect(result.usage.whenNot).toEqual(['Navigation — use Link instead']); }); }); describe('optional fields', () => { it('handles tags', () => { const content = makeContract({ tags: ['interactive', 'form'] }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.meta.tags).toEqual(['interactive', 'form']); }); it('handles status', () => { const content = makeContract({ status: 'stable' }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.meta.status).toBe('stable'); }); it('handles guidelines and accessibility in usage', () => { const content = makeContract({ usage: { when: ['Action needed'], whenNot: ['No action'], guidelines: ['Always provide a label'], accessibility: ['Must have role="button"'], }, }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.usage.guidelines).toEqual(['Always provide a label']); expect(result.usage.accessibility).toEqual(['Must have role="button"']); }); it('handles contract metadata', () => { const content = makeContract({ contract: { propsSummary: ['variant: primary|secondary|ghost (default: primary)'], scenarioTags: ['form', 'action'], a11yRules: ['no-empty-buttons'], bans: [{ pattern: 'onClick without type', message: 'Always specify button type' }], }, }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.contract).toBeDefined(); expect(result.contract!.propsSummary).toHaveLength(1); expect(result.contract!.scenarioTags).toEqual(['form', 'action']); expect(result.contract!.bans).toHaveLength(1); }); it('handles tokens list', () => { const content = makeContract({ tokens: ['--ds-color-primary', '--ds-space-4'], }); // tokens is part of the contract but not mapped to CompiledFragment output // Just verify it parses without error const result = parseComponentContract(content, 'Button.contract.json'); expect(result.meta.name).toBe('Button'); }); }); describe('examples → variants mapping', () => { it('maps examples to variants', () => { const content = makeContract({ examples: [ { name: 'Primary', description: 'Primary button style', code: '', }, { name: 'With icon', description: 'Button with leading icon', code: '', args: { variant: 'secondary' }, }, ], }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.variants).toHaveLength(2); expect(result.variants[0].name).toBe('Primary'); expect(result.variants[0].code).toBe(''); expect(result.variants[1].args).toEqual({ variant: 'secondary' }); }); it('returns empty variants when no examples', () => { const content = makeContract(); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.variants).toEqual([]); }); }); describe('relations mapping', () => { it('maps all relationship types', () => { const content = makeContract({ relations: [ { component: 'Link', relationship: 'alternative', note: 'For navigation' }, { component: 'Form', relationship: 'parent', note: 'Used inside forms' }, { component: 'Icon', relationship: 'child', note: 'Can contain icons' }, { component: 'Input', relationship: 'sibling', note: 'Often paired' }, ], }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.relations).toHaveLength(4); expect(result.relations![0].relationship).toBe('alternative'); expect(result.relations![1].relationship).toBe('parent'); expect(result.relations![2].relationship).toBe('child'); expect(result.relations![3].relationship).toBe('sibling'); }); }); describe('figma mappings', () => { it('maps figma nodeUrl to meta.figma', () => { const content = makeContract({ figma: { nodeUrl: 'https://www.figma.com/file/abc123/Design?node-id=1-2', }, }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.meta.figma).toBe('https://www.figma.com/file/abc123/Design?node-id=1-2'); }); it('maps figma propMappings to meta.figmaProps', () => { const content = makeContract({ figma: { propMappings: { variant: { type: 'enum', figmaProperty: 'Style', values: { primary: 'Primary', secondary: 'Secondary' }, }, disabled: { type: 'boolean', figmaProperty: 'Disabled', }, }, }, }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.meta.figmaProps).toBeDefined(); const mappings = result.meta.figmaProps as Record; expect(mappings.variant).toBeDefined(); }); }); describe('prop constraints', () => { it('preserves prop constraints', () => { const content = makeContract({ props: { maxLength: { type: 'number', description: 'Maximum character length', constraints: ['Must be positive', 'Max 1000'], }, }, }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.props.maxLength.constraints).toEqual(['Must be positive', 'Max 1000']); }); it('preserves required flag', () => { const content = makeContract({ props: { label: { type: 'string', description: 'Button label', required: true, }, }, }); const result = parseComponentContract(content, 'Button.contract.json'); expect(result.props.label.required).toBe(true); }); }); describe('error handling', () => { it('throws on invalid JSON', () => { expect(() => parseComponentContract('not json', 'test.contract.json')).toThrow(); }); it('throws on missing required fields', () => { const content = JSON.stringify({ name: 'Button' }); expect(() => parseComponentContract(content, 'test.contract.json')).toThrow(); }); it('throws on invalid prop type structure', () => { const content = JSON.stringify({ name: 'Button', description: 'A button', category: 'actions', props: { variant: 'string', // Should be an object, not a string }, usage: { when: [], whenNot: [] }, }); expect(() => parseComponentContract(content, 'test.contract.json')).toThrow(); }); }); describe('CompiledFragment compatibility', () => { it('output has all required CompiledFragment fields', () => { const content = makeContract(); const result = parseComponentContract(content, 'Button.contract.json'); // Required fields expect(result).toHaveProperty('filePath'); expect(result).toHaveProperty('meta'); expect(result).toHaveProperty('usage'); expect(result).toHaveProperty('props'); expect(result).toHaveProperty('variants'); // Meta required fields expect(result.meta).toHaveProperty('name'); expect(result.meta).toHaveProperty('description'); expect(result.meta).toHaveProperty('category'); // Usage required fields expect(result.usage).toHaveProperty('when'); expect(result.usage).toHaveProperty('whenNot'); }); }); describe('real-world contracts', () => { it('parses a Vue component contract', () => { const content = JSON.stringify({ $schema: 'https://usefragments.com/schemas/contract.v1.json', name: 'VButton', description: 'Vuetify button component', category: 'actions', framework: 'vue', status: 'stable', tags: ['vuetify', 'material'], sourcePath: 'src/VButton.vue', exportName: 'VButton', propsSummary: ['color: string', 'outlined: boolean', 'block: boolean'], props: { color: { type: 'string', description: 'Button color', default: 'primary' }, outlined: { type: 'boolean', description: 'Outlined style', default: false }, block: { type: 'boolean', description: 'Full width', default: false }, }, usage: { when: ['User action needed'], whenNot: ['Navigation — use v-link'], }, examples: [ { name: 'Default', description: 'Default button', code: 'Click', }, ], provenance: { source: 'manual', verified: false }, }); const result = parseComponentContract(content, 'VButton.contract.json'); expect(result.meta.name).toBe('VButton'); expect(result.meta.tags).toContain('vuetify'); expect(result.framework).toBe('vue'); expect(result.variants).toHaveLength(1); expect(result.variants[0].code).toContain(' { const content = JSON.stringify({ $schema: 'https://usefragments.com/schemas/contract.v1.json', name: 'MyButton', description: 'Custom element button', category: 'actions', framework: 'web-components', sourcePath: 'src/my-button.ts', exportName: 'MyButton', propsSummary: ['variant: primary|secondary'], props: { variant: { type: 'string', description: 'Visual variant', values: ['primary', 'secondary'], }, }, usage: { when: ['Need a custom button'], whenNot: ['Use native