/** * Unit tests for FileSystemProjectScaffolder. * * Drives against a real temp directory so end-to-end file IO + path-traversal * defenses are exercised. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { FileSystemProjectScaffolder, validateProjectName, } from '../src/scaffolder/projectScaffolder'; function tmp(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-scaffold-test-')); } describe('validateProjectName', () => { test('accepts allowed names', () => { expect(validateProjectName('foo')).toBe('foo'); expect(validateProjectName('foo-bar_v2')).toBe('foo-bar_v2'); expect(validateProjectName(' trimmed ')).toBe('trimmed'); }); test('rejects too short', () => { expect(validateProjectName('a')).toBeNull(); }); test('rejects too long', () => { expect(validateProjectName('a'.repeat(41))).toBeNull(); }); test('rejects invalid chars', () => { expect(validateProjectName('foo bar')).toBeNull(); expect(validateProjectName('foo/bar')).toBeNull(); expect(validateProjectName('foo.bar')).toBeNull(); expect(validateProjectName('한글이름')).toBeNull(); }); test('rejects empty / non-string', () => { expect(validateProjectName('')).toBeNull(); expect(validateProjectName(undefined as any)).toBeNull(); }); }); describe('FileSystemProjectScaffolder', () => { let root: string; let scaffolder: FileSystemProjectScaffolder; beforeEach(() => { root = tmp(); scaffolder = new FileSystemProjectScaffolder(); }); afterEach(() => { try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } }); test('listTemplates returns the catalog', () => { const list = scaffolder.listTemplates(); const ids = list.map(t => t.id); expect(ids).toContain('static'); expect(ids).toContain('vite-vanilla'); expect(ids).toContain('vite-react'); }); test('static template writes index.html + README.md', async () => { const result = await scaffolder.scaffold({ name: 'demo-static', template: 'static', rootDir: root }); expect(result.ok).toBe(true); if (!result.ok) return; expect(fs.existsSync(path.join(result.projectPath, 'site', 'index.html'))).toBe(true); expect(fs.existsSync(path.join(result.projectPath, 'README.md'))).toBe(true); const html = fs.readFileSync(path.join(result.projectPath, 'site', 'index.html'), 'utf8'); expect(html).toContain('demo-static'); }); test('vite-vanilla template writes package.json + main.js', async () => { const result = await scaffolder.scaffold({ name: 'vv', template: 'vite-vanilla', rootDir: root }); expect(result.ok).toBe(true); if (!result.ok) return; const pkg = JSON.parse(fs.readFileSync(path.join(result.projectPath, 'site', 'package.json'), 'utf8')); expect(pkg.name).toBe('vv'); expect(pkg.devDependencies.vite).toBeDefined(); expect(fs.existsSync(path.join(result.projectPath, 'site', 'main.js'))).toBe(true); }); test('vite-react template includes tsconfig + main.tsx', async () => { const result = await scaffolder.scaffold({ name: 'vr', template: 'vite-react', rootDir: root }); expect(result.ok).toBe(true); if (!result.ok) return; expect(fs.existsSync(path.join(result.projectPath, 'site', 'tsconfig.json'))).toBe(true); expect(fs.existsSync(path.join(result.projectPath, 'site', 'src', 'main.tsx'))).toBe(true); const tsx = fs.readFileSync(path.join(result.projectPath, 'site', 'src', 'main.tsx'), 'utf8'); expect(tsx).toContain('

vr

'); }); test('rejects invalid name with INVALID_NAME', async () => { const result = await scaffolder.scaffold({ name: 'foo bar', template: 'static', rootDir: root }); expect(result).toEqual(expect.objectContaining({ ok: false, code: 'INVALID_NAME' })); }); test('rejects unknown template with UNKNOWN_TEMPLATE', async () => { const result = await scaffolder.scaffold({ name: 'foo', template: 'made-up' as any, rootDir: root }); expect(result).toEqual(expect.objectContaining({ ok: false, code: 'UNKNOWN_TEMPLATE' })); }); test('rejects empty rootDir with NO_ROOT_DIR', async () => { const result = await scaffolder.scaffold({ name: 'foo', template: 'static', rootDir: '' }); expect(result).toEqual(expect.objectContaining({ ok: false, code: 'NO_ROOT_DIR' })); }); test('rejects relative rootDir with ROOT_NOT_ABSOLUTE', async () => { const result = await scaffolder.scaffold({ name: 'foo', template: 'static', rootDir: 'relative/path' }); expect(result).toEqual(expect.objectContaining({ ok: false, code: 'ROOT_NOT_ABSOLUTE' })); }); test('rejects when target already exists with ALREADY_EXISTS', async () => { const r1 = await scaffolder.scaffold({ name: 'twice', template: 'static', rootDir: root }); expect(r1.ok).toBe(true); const r2 = await scaffolder.scaffold({ name: 'twice', template: 'static', rootDir: root }); expect(r2).toEqual(expect.objectContaining({ ok: false, code: 'ALREADY_EXISTS' })); }); test('reports filesWritten for downstream UI', async () => { const result = await scaffolder.scaffold({ name: 'count', template: 'vite-react', rootDir: root }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.filesWritten.length).toBeGreaterThanOrEqual(5); for (const file of result.filesWritten) { expect(fs.existsSync(file)).toBe(true); } }); });