diff --git a/src/__tests__/assembler.test.ts b/src/__tests__/assembler.test.ts new file mode 100644 index 0000000..42390c4 --- /dev/null +++ b/src/__tests__/assembler.test.ts @@ -0,0 +1,154 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; +import type { ProjectConfig } from '../config/schema'; +import { ProjectAssembler } from '../assembler'; + +function createConfig(overrides: Partial = {}): ProjectConfig { + return { + name: 'test-app', + path: '/tmp/test-app', + runtime: 'vite', + language: 'typescript', + styling: { solution: 'tailwind' }, + stateManagement: 'none', + dataFetching: { enabled: false, library: 'tanstack-query' }, + testing: { + enabled: false, + unit: { enabled: false, runner: 'vitest' }, + component: { enabled: false, library: 'testing-library' }, + e2e: { enabled: false, runner: 'none' }, + }, + linting: { prettier: true }, + packageManager: 'npm', + git: { init: false, initialCommit: false }, + plugins: [], + ...overrides, + }; +} + +describe('ProjectAssembler', () => { + it('should process template variables and expose immutable file map', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'forge-assembler-vars-')); + const config = createConfig({ path: tempDir, name: 'awesome-app' }); + const assembler = new ProjectAssembler(tempDir, config); + + assembler.addFile( + 'README.md', + '{{PROJECT_NAME}} | {{PROJECT_DESCRIPTION}} | {{AUTHOR}} | {{LICENSE}}' + ); + const files = assembler.getFiles(); + + expect(files.get('README.md')).toBe('awesome-app | A production-ready React application | | MIT'); + + files.set('another.md', 'changed'); + expect(assembler.getFiles().has('another.md')).toBe(false); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should merge dependencies/scripts and write text and binary files', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'forge-assembler-write-')); + const config = createConfig({ path: tempDir }); + const assembler = new ProjectAssembler(tempDir, config); + + const sourceBinaryPath = join(tempDir, 'source.bin'); + writeFileSync(sourceBinaryPath, Buffer.from([0xde, 0xad, 0xbe, 0xef])); + + assembler.addFiles( + new Map([ + ['src/index.ts', 'export const app = "{{PROJECT_NAME}}";'], + ['assets/copied.bin', `__BINARY__:${sourceBinaryPath}`], + ]) + ); + + assembler.addDependencies({ zeta: '^1.0.0', alpha: '^1.0.0' }); + assembler.addDevDependencies({ tsx: '^4.0.0' }); + assembler.addScripts({ dev: 'vite', build: 'vite build' }); + assembler.mergeTemplateDeps({ + dependencies: { react: '^19.0.0' }, + devDependencies: { typescript: '^5.0.0' }, + scripts: { test: 'vitest' }, + }); + + const writeResult = assembler.writeFiles(); + + expect(writeResult.errors).toEqual([]); + expect(writeResult.filesWritten).toBe(3); + + const textFile = readFileSync(join(tempDir, 'src/index.ts'), 'utf-8'); + expect(textFile).toContain('test-app'); + + const copiedBinary = readFileSync(join(tempDir, 'assets/copied.bin')); + expect(copiedBinary.equals(Buffer.from([0xde, 0xad, 0xbe, 0xef]))).toBe(true); + + const pkg = JSON.parse(readFileSync(join(tempDir, 'package.json'), 'utf-8')) as { + dependencies: Record; + devDependencies: Record; + scripts: Record; + }; + + expect(Object.keys(pkg.dependencies)).toEqual(['alpha', 'react', 'zeta']); + expect(pkg.devDependencies).toMatchObject({ + tsx: '^4.0.0', + typescript: '^5.0.0', + }); + expect(pkg.scripts).toMatchObject({ + dev: 'vite', + build: 'vite build', + test: 'vitest', + }); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should capture file write errors and still write remaining files', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'forge-assembler-errors-')); + const config = createConfig({ path: tempDir }); + const assembler = new ProjectAssembler(tempDir, config); + + assembler.addFile('ok.txt', 'ok'); + assembler.addFile(`bad\u0000name.txt`, 'bad'); + + const result = assembler.writeFiles(); + + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toContain('Failed to write'); + expect(result.filesWritten).toBe(2); + expect(readFileSync(join(tempDir, 'ok.txt'), 'utf-8')).toBe('ok'); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should support package json override and expose config/path', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'forge-assembler-pkg-')); + const config = createConfig({ path: tempDir, name: 'pkg-app' }); + const assembler = new ProjectAssembler(tempDir, config); + + assembler.setPackageJson({ + name: 'custom-name', + version: '2.0.0', + scripts: { start: 'node index.js' }, + dependencies: { b: '1.0.0', a: '1.0.0' }, + devDependencies: {}, + }); + assembler.addFile('index.js', 'console.log("hello");'); + + const pkgBeforeWrite = assembler.getPackageJson(); + expect(pkgBeforeWrite.name).toBe('custom-name'); + + const result = assembler.writeFiles(); + expect(result.errors).toEqual([]); + + const pkg = JSON.parse(readFileSync(join(tempDir, 'package.json'), 'utf-8')) as { + dependencies: Record; + }; + expect(Object.keys(pkg.dependencies)).toEqual(['a', 'b']); + + expect(assembler.getConfig().name).toBe('pkg-app'); + expect(assembler.getProjectPath()).toBe(tempDir); + + rmSync(tempDir, { recursive: true, force: true }); + }); +}); diff --git a/src/__tests__/cli-index.test.ts b/src/__tests__/cli-index.test.ts new file mode 100644 index 0000000..1f2b78c --- /dev/null +++ b/src/__tests__/cli-index.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PromptAnswers } from '../cli/prompts'; + +const { promptForProjectDetailsMock, generateProjectMock } = vi.hoisted(() => ({ + promptForProjectDetailsMock: vi.fn(), + generateProjectMock: vi.fn(), +})); + +vi.mock('../cli/prompts', () => ({ + promptForProjectDetails: promptForProjectDetailsMock, +})); + +vi.mock('../generator/index', () => ({ + generateProject: generateProjectMock, +})); + +import { main, promptAnswersToConfig, validateProjectName } from '../cli/index'; + +function createAnswers(overrides: Partial = {}): PromptAnswers { + return { + projectName: 'test-app', + projectPath: './test-app', + runtime: 'vite', + language: 'typescript', + styling: 'styled-components', + stateManagement: 'none', + testing: 'none', + unitRunner: 'vitest', + e2eRunner: 'none', + dataFetching: true, + packageManager: 'npm', + git: true, + prettier: true, + ...overrides, + }; +} + +describe('CLI Index', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should convert prompt answers into project config', () => { + const config = promptAnswersToConfig( + createAnswers({ + testing: 'full', + runtime: 'nextjs', + language: 'javascript', + stateManagement: 'jotai', + packageManager: 'pnpm', + git: false, + dataFetching: false, + e2eRunner: 'cypress', + }) + ); + + expect(config.name).toBe('test-app'); + expect(config.path).toContain('/test-app'); + expect(config.runtime).toBe('nextjs'); + expect(config.language).toBe('javascript'); + expect(config.styling.solution).toBe('styled-components'); + expect(config.stateManagement).toBe('jotai'); + expect(config.packageManager).toBe('pnpm'); + expect(config.git.init).toBe(false); + expect(config.dataFetching.enabled).toBe(false); + expect(config.testing.enabled).toBe(true); + expect(config.testing.unit.runner).toBe('vitest'); + expect(config.testing.e2e.runner).toBe('cypress'); + }); + + it('should validate project name', () => { + expect(validateProjectName('')).toEqual({ + valid: false, + error: 'Project name is required', + }); + expect(validateProjectName('Invalid Name')).toEqual({ + valid: false, + error: 'Project name must be lowercase alphanumeric with hyphens', + }); + expect(validateProjectName('valid-name-123')).toEqual({ valid: true }); + }); + + it('should run successful main flow and print warnings', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + promptForProjectDetailsMock.mockResolvedValue(createAnswers()); + generateProjectMock.mockResolvedValue({ + success: true, + projectPath: '/tmp/test-app', + filesWritten: 10, + errors: [], + warnings: ['warn-one'], + }); + + await main(); + + expect(promptForProjectDetailsMock).toHaveBeenCalledTimes(1); + expect(generateProjectMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('⚠️ Warnings:')); + expect(errorSpy).not.toHaveBeenCalled(); + + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('should exit with code 1 when generation fails', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`); + }) as never); + + promptForProjectDetailsMock.mockResolvedValue(createAnswers()); + generateProjectMock.mockResolvedValue({ + success: false, + projectPath: '/tmp/test-app', + filesWritten: 1, + errors: ['generation failed'], + warnings: [], + }); + + await expect(main()).rejects.toThrow('exit 1'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Project generation failed:')); + + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it('should exit with code 0 when user cancels prompt', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`); + }) as never); + + promptForProjectDetailsMock.mockRejectedValue(new Error('User force closed the prompt')); + + await expect(main()).rejects.toThrow('exit 0'); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Setup cancelled')); + + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it('should rethrow unexpected errors from main flow', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + promptForProjectDetailsMock.mockRejectedValue(new Error('unexpected failure')); + + await expect(main()).rejects.toThrow('unexpected failure'); + + logSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/cli-parser.test.ts b/src/__tests__/cli-parser.test.ts new file mode 100644 index 0000000..e677ac2 --- /dev/null +++ b/src/__tests__/cli-parser.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { createCommand } from '../cli/parser'; + +describe('CLI parser', () => { + it('should create command with expected metadata', () => { + const program = createCommand(); + + expect(program.name()).toBe('create-react-forge'); + expect(program.description()).toContain('Production-ready React CLI scaffolder'); + expect(program.version()).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('should register create command options with supported choices', () => { + const program = createCommand(); + const createCmd = program.commands.find((cmd) => cmd.name() === 'create'); + + expect(createCmd).toBeDefined(); + expect(createCmd?.description()).toBe('Create a new React project'); + + const stylingOption = createCmd?.options.find((o) => o.long === '--styling'); + const stateOption = createCmd?.options.find((o) => o.long === '--state'); + const runtimeOption = createCmd?.options.find((o) => o.long === '--runtime'); + const unitRunnerOption = createCmd?.options.find((o) => o.long === '--unit-runner'); + + expect(runtimeOption?.argChoices).toEqual(['vite', 'nextjs']); + expect(stylingOption?.argChoices).toEqual([ + 'none', + 'tailwind', + 'styled-components', + 'css-modules', + 'css', + ]); + expect(stateOption?.argChoices).toEqual(['none', 'redux', 'zustand', 'jotai']); + expect(unitRunnerOption?.argChoices).toEqual(['vitest', 'jest']); + }); + + it('should parse create command options', async () => { + const program = createCommand(); + + await program.parseAsync( + [ + 'node', + 'create-react-forge', + 'create', + 'demo-app', + '--runtime', + 'nextjs', + '--language', + 'javascript', + '--styling', + 'tailwind', + '--state', + 'redux', + '--testing', + 'unit-component', + '--unit-runner', + 'jest', + '--e2e-runner', + 'none', + '--pm', + 'pnpm', + '--no-git', + '--no-query', + ], + { from: 'user' } + ); + + const createCmd = program.commands.find((cmd) => cmd.name() === 'create'); + const parsed = createCmd?.opts(); + + expect(parsed).toMatchObject({ + runtime: 'nextjs', + language: 'javascript', + styling: 'tailwind', + state: 'redux', + testing: 'unit-component', + unitRunner: 'jest', + e2eRunner: 'none', + pm: 'pnpm', + git: false, + query: false, + }); + }); +}); diff --git a/src/__tests__/installer.test.ts b/src/__tests__/installer.test.ts new file mode 100644 index 0000000..a1c740e --- /dev/null +++ b/src/__tests__/installer.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execaMock, oraMock, spinnerMock } = vi.hoisted(() => { + const spinner = { + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + }; + + return { + execaMock: vi.fn(), + oraMock: vi.fn(() => spinner), + spinnerMock: spinner, + }; +}); + +vi.mock('execa', () => ({ + execa: execaMock, +})); + +vi.mock('ora', () => ({ + default: oraMock, +})); + +import { installDependencies } from '../lifecycle/installer'; + +describe('installDependencies', () => { + beforeEach(() => { + vi.clearAllMocks(); + spinnerMock.start.mockReturnValue(spinnerMock); + }); + + it('should install dependencies and succeed spinner', async () => { + execaMock.mockResolvedValueOnce({ exitCode: 0 }); + + await installDependencies('/tmp/project', 'npm'); + + expect(oraMock).toHaveBeenCalledWith('Installing dependencies with npm...'); + expect(spinnerMock.start).toHaveBeenCalledTimes(1); + expect(execaMock).toHaveBeenCalledWith('npm', ['install'], { cwd: '/tmp/project' }); + expect(spinnerMock.succeed).toHaveBeenCalledWith('Dependencies installed'); + expect(spinnerMock.fail).not.toHaveBeenCalled(); + }); + + it('should fail spinner and rethrow when install fails', async () => { + execaMock.mockRejectedValueOnce(new Error('install failed')); + + await expect(installDependencies('/tmp/project', 'pnpm')).rejects.toThrow('install failed'); + + expect(oraMock).toHaveBeenCalledWith('Installing dependencies with pnpm...'); + expect(spinnerMock.fail).toHaveBeenCalledWith('Failed to install dependencies'); + }); +}); diff --git a/src/__tests__/plugin-loader.test.ts b/src/__tests__/plugin-loader.test.ts new file mode 100644 index 0000000..54851e9 --- /dev/null +++ b/src/__tests__/plugin-loader.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; +import { PluginLoader } from '../plugins/loader'; + +describe('PluginLoader', () => { + it('should load plugin with default export', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'forge-plugin-default-')); + const pluginPath = join(tempDir, 'plugin.mjs'); + writeFileSync(pluginPath, `export default { name: 'plugin-a', version: '1.0.0' };`, 'utf-8'); + + const loader = new PluginLoader(); + const plugin = await loader.loadPlugin(pluginPath); + + expect(plugin.name).toBe('plugin-a'); + expect(plugin.version).toBe('1.0.0'); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should fallback to module object when default export is absent', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'forge-plugin-named-')); + const pluginPath = join(tempDir, 'plugin.mjs'); + writeFileSync(pluginPath, `export const name = 'plugin-b'; export const version = '2.0.0';`, 'utf-8'); + + const loader = new PluginLoader(); + const plugin = await loader.loadPlugin(pluginPath); + + expect(plugin.name).toBe('plugin-b'); + expect(plugin.version).toBe('2.0.0'); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should throw readable error for invalid plugin path', async () => { + const loader = new PluginLoader(); + + await expect(loader.loadPlugin('/tmp/does-not-exist-plugin.mjs')).rejects.toThrow( + 'Failed to load plugin at /tmp/does-not-exist-plugin.mjs:' + ); + }); +}); diff --git a/src/__tests__/plugin-manager.test.ts b/src/__tests__/plugin-manager.test.ts new file mode 100644 index 0000000..366f6c1 --- /dev/null +++ b/src/__tests__/plugin-manager.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ProjectConfig } from '../config/schema'; +import { PluginManager } from '../plugins/manager'; +import type { PluginContext, ReactSetupPlugin } from '../plugins/types'; + +function createConfig(overrides: Partial = {}): ProjectConfig { + return { + name: 'test-app', + path: '/tmp/test-app', + runtime: 'vite', + language: 'typescript', + styling: { solution: 'styled-components' }, + stateManagement: 'none', + dataFetching: { enabled: false, library: 'tanstack-query' }, + testing: { + enabled: false, + unit: { enabled: false, runner: 'vitest' }, + component: { enabled: false, library: 'testing-library' }, + e2e: { enabled: false, runner: 'none' }, + }, + linting: { prettier: true }, + packageManager: 'npm', + git: { init: false, initialCommit: false }, + plugins: [], + ...overrides, + }; +} + +describe('PluginManager', () => { + it('should execute registered hooks', async () => { + const manager = new PluginManager(); + const afterInstall = vi.fn(async () => undefined); + const context: PluginContext = { config: createConfig() }; + + const plugin: ReactSetupPlugin = { + name: 'test-plugin', + version: '1.0.0', + hooks: { afterInstall }, + }; + + manager.register(plugin); + await manager.runHook('afterInstall', context); + + expect(afterInstall).toHaveBeenCalledWith(context); + expect(afterInstall).toHaveBeenCalledTimes(1); + }); + + it('should continue when a hook throws', async () => { + const manager = new PluginManager(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const successHook = vi.fn(async () => undefined); + const context: PluginContext = { config: createConfig() }; + + manager.register({ + name: 'failing', + version: '1.0.0', + hooks: { + afterTemplateApply: async () => { + throw new Error('boom'); + }, + }, + }); + + manager.register({ + name: 'success', + version: '1.0.0', + hooks: { + afterTemplateApply: successHook, + }, + }); + + await manager.runHook('afterTemplateApply', context); + + expect(successHook).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it('should allow beforeCreate hook chain to transform config', async () => { + const manager = new PluginManager(); + + manager.register({ + name: 'styling-plugin', + version: '1.0.0', + hooks: { + beforeCreate: async (config) => ({ + ...config, + styling: { solution: 'tailwind' }, + }), + }, + }); + + manager.register({ + name: 'state-plugin', + version: '1.0.0', + hooks: { + beforeCreate: async (config) => ({ + ...config, + stateManagement: 'zustand', + }), + }, + }); + + const updated = await manager.runBeforeCreate(createConfig()); + expect(updated.styling.solution).toBe('tailwind'); + expect(updated.stateManagement).toBe('zustand'); + }); + + it('should handle beforeCreate errors and continue', async () => { + const manager = new PluginManager(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + manager.register({ + name: 'thrower', + version: '1.0.0', + hooks: { + beforeCreate: async () => { + throw new Error('beforeCreate failed'); + }, + }, + }); + + manager.register({ + name: 'noop', + version: '1.0.0', + hooks: { + beforeCreate: async () => undefined, + }, + }); + + const result = await manager.runBeforeCreate(createConfig()); + expect(result.name).toBe('test-app'); + expect(warnSpy).toHaveBeenCalledTimes(1); + + warnSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/prompts.test.ts b/src/__tests__/prompts.test.ts new file mode 100644 index 0000000..beeaae4 --- /dev/null +++ b/src/__tests__/prompts.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const { inputMock, selectMock, confirmMock } = vi.hoisted(() => ({ + inputMock: vi.fn(), + selectMock: vi.fn(), + confirmMock: vi.fn(), +})); + +vi.mock('@inquirer/prompts', () => ({ + input: inputMock, + select: selectMock, + confirm: confirmMock, +})); + +import { confirmProceed, promptForProjectDetails } from '../cli/prompts'; + +describe('CLI Prompts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should collect Vite answers with full testing flow', async () => { + inputMock.mockResolvedValueOnce('my-app').mockResolvedValueOnce('./my-app'); + + selectMock + .mockResolvedValueOnce('vite') + .mockResolvedValueOnce('typescript') + .mockResolvedValueOnce('css') + .mockResolvedValueOnce('jotai') + .mockResolvedValueOnce('full') + .mockResolvedValueOnce('jest') + .mockResolvedValueOnce('cypress') + .mockResolvedValueOnce('pnpm'); + + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const result = await promptForProjectDetails(); + + expect(result).toEqual({ + projectName: 'my-app', + projectPath: './my-app', + runtime: 'vite', + language: 'typescript', + styling: 'css', + stateManagement: 'jotai', + testing: 'full', + unitRunner: 'jest', + e2eRunner: 'cypress', + dataFetching: true, + packageManager: 'pnpm', + git: false, + prettier: true, + }); + + const projectNamePrompt = inputMock.mock.calls[0][0] as { validate: (value: string) => true | string }; + expect(projectNamePrompt.validate('')).toBe('Project name is required'); + expect(projectNamePrompt.validate('Invalid Name')).toBe( + 'Project name must be lowercase alphanumeric with hyphens' + ); + expect(projectNamePrompt.validate('valid-name-123')).toBe(true); + }); + + it('should auto-select tailwind for Next.js and skip test runner prompts when testing is none', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + inputMock.mockResolvedValueOnce('next-app').mockResolvedValueOnce('./next-app'); + + selectMock + .mockResolvedValueOnce('nextjs') + .mockResolvedValueOnce('javascript') + .mockResolvedValueOnce('none') + .mockResolvedValueOnce('none') + .mockResolvedValueOnce('npm'); + + confirmMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + const result = await promptForProjectDetails('suggested-name'); + + expect(result).toEqual({ + projectName: 'next-app', + projectPath: './next-app', + runtime: 'nextjs', + language: 'javascript', + styling: 'tailwind', + stateManagement: 'none', + testing: 'none', + unitRunner: 'vitest', + e2eRunner: 'playwright', + dataFetching: false, + packageManager: 'npm', + git: true, + prettier: false, + }); + + expect(logSpy).toHaveBeenCalledWith(' ✓ Styling: Tailwind CSS (recommended for Next.js)'); + expect(selectMock).toHaveBeenCalledTimes(5); + + logSpy.mockRestore(); + }); + + it('should ask for confirmation with default and custom message', async () => { + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + const defaultResult = await confirmProceed(); + const customResult = await confirmProceed('Continue setup?'); + + expect(defaultResult).toBe(true); + expect(customResult).toBe(false); + expect(confirmMock.mock.calls[0][0]).toEqual({ message: 'Proceed?', default: true }); + expect(confirmMock.mock.calls[1][0]).toEqual({ message: 'Continue setup?', default: true }); + }); +}); diff --git a/src/__tests__/template-utils.test.ts b/src/__tests__/template-utils.test.ts new file mode 100644 index 0000000..9723dbf --- /dev/null +++ b/src/__tests__/template-utils.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { ProjectConfig } from '../config/schema'; +import { + getApplicableTemplatePaths, + getApplicableTemplates, + getTemplatePathForDataFetching, + getTemplatePathForRuntime, + getTemplatePathForState, + getTemplatePathForStyling, + getTemplatePathForTesting, +} from '../templates/utils'; + +function createConfig(overrides: Partial = {}): ProjectConfig { + return { + name: 'test-app', + path: '/tmp/test-app', + runtime: 'vite', + language: 'typescript', + styling: { solution: 'styled-components' }, + stateManagement: 'none', + dataFetching: { enabled: false, library: 'tanstack-query' }, + testing: { + enabled: false, + unit: { enabled: false, runner: 'vitest' }, + component: { enabled: false, library: 'testing-library' }, + e2e: { enabled: false, runner: 'none' }, + }, + linting: { prettier: true }, + packageManager: 'npm', + git: { init: false, initialCommit: false }, + plugins: [], + ...overrides, + }; +} + +describe('template utils', () => { + it('should resolve individual template paths', () => { + expect(getTemplatePathForRuntime('vite')).toBe('runtime/vite'); + expect(getTemplatePathForRuntime('nextjs')).toBe('runtime/nextjs'); + + expect(getTemplatePathForStyling('none')).toBe(''); + expect(getTemplatePathForStyling('tailwind')).toBe('styling/tailwind'); + expect(getTemplatePathForStyling('styled-components')).toBe('styling/styled-components'); + expect(getTemplatePathForStyling('css-modules')).toBe('styling/css-modules'); + + expect(getTemplatePathForState('none')).toBe(''); + expect(getTemplatePathForState('zustand')).toBe('state/zustand'); + + expect(getTemplatePathForTesting('none')).toBe(''); + expect(getTemplatePathForTesting('playwright')).toBe('testing/playwright'); + + expect(getTemplatePathForDataFetching('none')).toBe(''); + expect(getTemplatePathForDataFetching('tanstack-query')).toBe('features/tanstack-query'); + }); + + it('should build applicable template paths for full config', () => { + const paths = getApplicableTemplatePaths( + createConfig({ + runtime: 'nextjs', + styling: { solution: 'tailwind' }, + stateManagement: 'redux', + dataFetching: { enabled: true, library: 'tanstack-query' }, + testing: { + enabled: true, + unit: { enabled: true, runner: 'jest' }, + component: { enabled: true, library: 'testing-library' }, + e2e: { enabled: true, runner: 'cypress' }, + }, + }) + ); + + expect(paths).toContain('base'); + expect(paths).toContain('runtime/nextjs'); + expect(paths).toContain('styling/tailwind'); + expect(paths).toContain('state/redux'); + expect(paths).toContain('testing/jest'); + expect(paths).toContain('testing/cypress'); + expect(paths).toContain('features/tanstack-query'); + }); + + it('should skip optional templates when disabled', () => { + const paths = getApplicableTemplatePaths( + createConfig({ + runtime: 'vite', + styling: { solution: 'none' }, + stateManagement: 'none', + dataFetching: { enabled: false, library: 'tanstack-query' }, + testing: { + enabled: true, + unit: { enabled: false, runner: 'vitest' }, + component: { enabled: false, library: 'testing-library' }, + e2e: { enabled: false, runner: 'none' }, + }, + }) + ); + + expect(paths).toEqual(['base', 'runtime/vite']); + }); + + it('should support legacy template path helper', () => { + const legacy = getApplicableTemplates({ + runtime: 'vite', + styling: 'styled-components', + stateManagement: 'zustand', + testing: {}, + dataFetching: { enabled: true }, + }); + + expect(legacy).toContain('base'); + expect(legacy).toContain('runtime/vite'); + expect(legacy).toContain('styling/styled-components'); + expect(legacy).toContain('state/zustand'); + expect(legacy).toContain('features/tanstack-query'); + }); +});