import { CodeTransformer } from '../../src/transformer.js'; import type { MakeStylesExtraction, TailwindConversion } from '../../src/types.js'; describe('CodeTransformer', () => { describe('constructor and parsing', () => { it('should parse valid TypeScript/JSX code', () => { const code = ` import React from 'react'; const Component = () =>
Hello
; `; expect(() => new CodeTransformer(code)).not.toThrow(); }); it('should throw error for invalid syntax', () => { const invalidCode = ` const invalid = { unclosed: "string }; `; expect(() => new CodeTransformer(invalidCode)).toThrow(); }); it('should handle empty code', () => { expect(() => new CodeTransformer('')).not.toThrow(); }); it('should handle JSX syntax', () => { const jsxCode = ` const Component = () => (
Hello World
); `; expect(() => new CodeTransformer(jsxCode)).not.toThrow(); }); }); describe('transform method', () => { it('should transform simple makeStyles to Tailwind', () => { const sourceCode = ` import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; const useStyles = makeStyles(() => ({ root: { display: 'flex', padding: 16, }, })); export const Component = () => { const classes = useStyles(); return
Hello
; }; `; const extractions: MakeStylesExtraction[] = [{ importName: 'makeStyles', hookName: 'useStyles', styles: [{ name: 'root', properties: { display: 'flex', padding: 16, }, sourceLocation: { start: 0, end: 0, line: 0, column: 0 } }], sourceFile: 'test.tsx' }]; const conversions = new Map(); conversions.set('useStyles.root', { original: { display: 'flex', padding: 16 }, tailwindClasses: ['flex', 'p-4'], warnings: [], unconvertible: [] }); const transformer = new CodeTransformer(sourceCode); const result = transformer.transform(extractions, conversions); expect(result.migratedCode).toContain('className="flex p-4"'); expect(result.migratedCode).not.toContain('makeStyles'); expect(result.migratedCode).not.toContain('const classes = useStyles()'); expect(result.classNameReplacements.get('classes.root')).toBe('flex p-4'); }); it('should handle multiple style classes', () => { const sourceCode = ` import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; const useStyles = makeStyles(() => ({ container: { display: 'flex', flexDirection: 'column', }, item: { padding: 8, margin: 4, }, })); export const Component = () => { const classes = useStyles(); return (
Item
); }; `; const extractions: MakeStylesExtraction[] = [{ importName: 'makeStyles', hookName: 'useStyles', styles: [ { name: 'container', properties: { display: 'flex', flexDirection: 'column' }, sourceLocation: { start: 0, end: 0, line: 0, column: 0 } }, { name: 'item', properties: { padding: 8, margin: 4 }, sourceLocation: { start: 0, end: 0, line: 0, column: 0 } } ], sourceFile: 'test.tsx' }]; const conversions = new Map(); conversions.set('useStyles.container', { original: { display: 'flex', flexDirection: 'column' }, tailwindClasses: ['flex', 'flex-col'], warnings: [], unconvertible: [] }); conversions.set('useStyles.item', { original: { padding: 8, margin: 4 }, tailwindClasses: ['p-2', 'm-1'], warnings: [], unconvertible: [] }); const transformer = new CodeTransformer(sourceCode); const result = transformer.transform(extractions, conversions); expect(result.migratedCode).toContain('className="flex flex-col"'); expect(result.migratedCode).toContain('className="p-2 m-1"'); expect(result.classNameReplacements.size).toBe(2); }); it('should preserve original code when styles have unconvertible properties', () => { const sourceCode = ` import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; const useStyles = makeStyles(() => ({ root: { display: 'flex', animation: 'fadeIn 0.3s ease-in-out', }, })); export const Component = () => { const classes = useStyles(); return
Hello
; }; `; const extractions: MakeStylesExtraction[] = [{ importName: 'makeStyles', hookName: 'useStyles', styles: [{ name: 'root', properties: { display: 'flex', animation: 'fadeIn 0.3s ease-in-out', }, sourceLocation: { start: 0, end: 0, line: 0, column: 0 } }], sourceFile: 'test.tsx' }]; const conversions = new Map(); conversions.set('useStyles.root', { original: { display: 'flex', animation: 'fadeIn 0.3s ease-in-out' }, tailwindClasses: ['flex'], warnings: [], unconvertible: [{ type: 'unconvertible', property: 'animation', value: 'fadeIn 0.3s ease-in-out', reason: 'CSS animations require manual conversion', manualAction: 'Use Tailwind animations or move to separate CSS file' }] }); const transformer = new CodeTransformer(sourceCode); const result = transformer.transform(extractions, conversions); // Should create mixed className using cn() function expect(result.migratedCode).toContain('className={cn(classes.root, "flex")}'); expect(result.stats.unconvertibleStyles).toBe(1); // Should add cn import when mixed classes are used expect(result.migratedCode).toContain('import { cn } from "@/lib/utils"'); // Check that the conversion included both convertible and unconvertible properties const conversion = result.conversions[0]; expect(conversion.tailwindClasses).toContain('flex'); expect(conversion.unconvertible).toBeDefined(); expect(conversion.unconvertible.length).toBe(1); expect(conversion.unconvertible[0].property).toBe('animation'); }); it('should not add cn import for fully unconvertible styles', () => { const sourceCode = ` import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; const useStyles = makeStyles(() => ({ root: { animation: 'fadeIn 0.3s ease-in-out', transform: 'scale(1.02)', '&:hover': { opacity: 0.8 } }, })); export const Component = () => { const classes = useStyles(); return
Hello
; }; `; const extractions: MakeStylesExtraction[] = [{ importName: 'makeStyles', hookName: 'useStyles', styles: [{ name: 'root', properties: { animation: 'fadeIn 0.3s ease-in-out', transform: 'scale(1.02)', '&:hover': { opacity: 0.8 } }, sourceLocation: { start: 0, end: 0, line: 0, column: 0 } }], sourceFile: 'test.tsx' }]; const conversions = new Map(); conversions.set('useStyles.root', { original: { animation: 'fadeIn 0.3s ease-in-out', transform: 'scale(1.02)', '&:hover': { opacity: 0.8 } }, tailwindClasses: [], // No convertible classes warnings: [], unconvertible: [ { type: 'unconvertible', property: 'animation', value: 'fadeIn 0.3s ease-in-out', reason: 'CSS animations require manual conversion', manualAction: 'Use Tailwind animations or move to separate CSS file' }, { type: 'unconvertible', property: 'transform', value: 'scale(1.02)', reason: 'Complex transforms require manual conversion', manualAction: 'Use Tailwind transforms or move to separate CSS file' }, { type: 'unconvertible', property: '&:hover', value: { opacity: 0.8 }, reason: 'Pseudo-selectors require manual conversion', manualAction: 'Use Tailwind hover utilities or move to separate CSS file' } ] }); const transformer = new CodeTransformer(sourceCode); const result = transformer.transform(extractions, conversions); // Should preserve original makeStyles without changes expect(result.migratedCode).toContain('const useStyles = makeStyles'); expect(result.migratedCode).toContain('className={classes.root}'); // Should NOT add cn import for fully unconvertible styles expect(result.migratedCode).not.toContain('import { cn } from "@/lib/utils"'); expect(result.stats.unconvertibleStyles).toBe(3); }); it('should handle code with no makeStyles', () => { const sourceCode = ` import React from 'react'; export const Component = () => { return
Hello
; }; `; const transformer = new CodeTransformer(sourceCode); const result = transformer.transform([], new Map()); expect(result.migratedCode).toBe(sourceCode); expect(result.classNameReplacements.size).toBe(0); expect(result.conversions).toHaveLength(0); expect(result.errors).toHaveLength(0); }); }); describe('generatePreview method', () => { it('should generate markdown preview of migration', () => { const sourceCode = ` const useStyles = makeStyles(() => ({ root: { display: 'flex', padding: 16 }, })); `; const extractions: MakeStylesExtraction[] = [{ importName: 'makeStyles', hookName: 'useStyles', styles: [{ name: 'root', properties: { display: 'flex', padding: 16 }, sourceLocation: { start: 0, end: 0, line: 0, column: 0 } }], sourceFile: 'test.tsx' }]; const conversions = new Map(); conversions.set('useStyles.root', { original: { display: 'flex', padding: 16 }, tailwindClasses: ['flex', 'p-4'], warnings: [], unconvertible: [] }); const transformer = new CodeTransformer(sourceCode); const preview = transformer.generatePreview(extractions, conversions); expect(preview).toContain('# Migration Preview'); expect(preview).toContain('useStyles'); expect(preview).toContain('flex p-4'); expect(preview).toContain('Tailwind'); }); it('should include warnings and unconvertible styles in preview', () => { const sourceCode = `const useStyles = makeStyles(() => ({ root: { animation: 'fadeIn 0.3s' } }));`; const extractions: MakeStylesExtraction[] = [{ importName: 'makeStyles', hookName: 'useStyles', styles: [{ name: 'root', properties: { animation: 'fadeIn 0.3s' }, sourceLocation: { start: 0, end: 0, line: 0, column: 0 } }], sourceFile: 'test.tsx' }]; const conversions = new Map(); conversions.set('useStyles.root', { original: { animation: 'fadeIn 0.3s' }, tailwindClasses: [], warnings: [{ type: 'warning', message: 'Complex animation detected' }], unconvertible: [{ type: 'unconvertible', property: 'animation', value: 'fadeIn 0.3s', reason: 'Animations not supported', manualAction: 'Use Tailwind animations' }] }); const transformer = new CodeTransformer(sourceCode); const preview = transformer.generatePreview(extractions, conversions); expect(preview).toContain('⚠️'); expect(preview).toContain('❌'); expect(preview).toContain('animation'); expect(preview).toContain('Animations not supported'); }); }); describe('error handling', () => { it('should handle malformed AST gracefully', () => { // This tests internal error handling const sourceCode = ` const useStyles = makeStyles(() => ({ root: { display: 'flex' }, })); `; const extractions: MakeStylesExtraction[] = [{ importName: 'makeStyles', hookName: 'useStyles', styles: [{ name: 'root', properties: { display: 'flex' }, sourceLocation: { start: 0, end: 0, line: 0, column: 0 } }], sourceFile: 'test.tsx' }]; const conversions = new Map(); conversions.set('useStyles.root', { original: { display: 'flex' }, tailwindClasses: ['flex'], warnings: [], unconvertible: [] }); const transformer = new CodeTransformer(sourceCode); // Should not throw, even with edge cases expect(() => transformer.transform(extractions, conversions)).not.toThrow(); }); it('should handle empty extractions array', () => { const sourceCode = `export const Component = () =>
Hello
;`; const transformer = new CodeTransformer(sourceCode); const result = transformer.transform([], new Map()); expect(result.migratedCode).toBe(sourceCode); expect(result.errors).toHaveLength(0); }); it('should handle missing conversions for extractions', () => { const sourceCode = `const useStyles = makeStyles(() => ({ root: { display: 'flex' } }));`; const extractions: MakeStylesExtraction[] = [{ importName: 'makeStyles', hookName: 'useStyles', styles: [{ name: 'root', properties: { display: 'flex' }, sourceLocation: { start: 0, end: 0, line: 0, column: 0 } }], sourceFile: 'test.tsx' }]; const transformer = new CodeTransformer(sourceCode); // Empty conversions map - should handle gracefully const result = transformer.transform(extractions, new Map()); expect(result.errors).toHaveLength(0); // Should preserve original when conversions are missing expect(result.migratedCode).toContain('makeStyles'); }); }); });