diff --git a/src/__tests__/architecture-generator.test.ts b/src/__tests__/architecture-generator.test.ts new file mode 100644 index 0000000..0da9b69 --- /dev/null +++ b/src/__tests__/architecture-generator.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import type { ProjectConfig } from '../config/schema'; +import { generateArchitectureDoc } from '../docs'; + +function createConfig(overrides: Partial = {}): ProjectConfig { + return { + name: 'test-app', + path: '/tmp/test-app', + runtime: 'vite', + language: 'typescript', + styling: { solution: 'tailwind' }, + stateManagement: 'zustand', + dataFetching: { enabled: true, library: 'tanstack-query' }, + testing: { + enabled: true, + unit: { enabled: true, runner: 'vitest' }, + component: { enabled: true, library: 'testing-library' }, + e2e: { enabled: true, runner: 'playwright' }, + }, + linting: { prettier: true }, + packageManager: 'npm', + git: { init: false, initialCommit: false }, + plugins: [], + ...overrides, + }; +} + +describe('Architecture Generator', () => { + it('should generate architecture docs for enabled features', () => { + const doc = generateArchitectureDoc(createConfig()); + + expect(doc).toContain('Runtime**: Vite (SPA)'); + expect(doc).toContain('Language**: TypeScript'); + expect(doc).toContain('Styling**: tailwind'); + expect(doc).toContain('State Management**: zustand'); + expect(doc).toContain('Data Fetching**: tanstack-query'); + expect(doc).toContain('Testing**: Enabled'); + expect(doc).toContain('├── lib/ # Third-party library configs'); + expect(doc).toContain('├── stores/ # State management stores'); + expect(doc).toContain('- **Unit Tests**: vitest'); + expect(doc).toContain('- **E2E Tests**: playwright'); + expect(doc).toContain('We use **tanstack-query** for server state management.'); + expect(doc).toContain('We use **zustand** for global client state.'); + }); + + it('should generate architecture docs for disabled features', () => { + const doc = generateArchitectureDoc( + createConfig({ + runtime: 'nextjs', + language: 'javascript', + styling: { solution: 'none' }, + 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' }, + }, + }) + ); + + expect(doc).toContain('Runtime**: Next.js (App Router)'); + expect(doc).toContain('Language**: JavaScript'); + expect(doc).toContain('Styling**: none'); + expect(doc).toContain('Testing**: Disabled'); + expect(doc).not.toContain('├── lib/ # Third-party library configs'); + expect(doc).not.toContain('├── stores/ # State management stores'); + expect(doc).toContain('Testing is currently disabled.'); + expect(doc).toContain('Standard `fetch` or `axios` is used for data fetching.'); + expect(doc).toContain('Local state (`useState`) is preferred.'); + }); +}); diff --git a/src/__tests__/cli-parser.test.ts b/src/__tests__/cli-parser.test.ts new file mode 100644 index 0000000..3f0ac2f --- /dev/null +++ b/src/__tests__/cli-parser.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; +import { createCommand } from '../cli/parser'; + +describe('CLI Parser', () => { + it('should configure root command metadata and version', () => { + const program = createCommand(); + const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')) as { + version: string; + }; + + expect(program.name()).toBe('create-react-forge'); + expect(program.description()).toContain('Production-ready React CLI scaffolder'); + expect(program.version()).toBe(packageJson.version); + }); + + it('should register create command with expected option choices', () => { + const program = createCommand(); + const createCommandDefinition = program.commands.find((command) => command.name() === 'create'); + + expect(createCommandDefinition).toBeDefined(); + expect(createCommandDefinition?.description()).toBe('Create a new React project'); + + const runtimeOption = createCommandDefinition?.options.find((option) => option.long === '--runtime'); + const languageOption = createCommandDefinition?.options.find((option) => option.long === '--language'); + const stylingOption = createCommandDefinition?.options.find((option) => option.long === '--styling'); + const stateOption = createCommandDefinition?.options.find((option) => option.long === '--state'); + const testingOption = createCommandDefinition?.options.find((option) => option.long === '--testing'); + const unitRunnerOption = createCommandDefinition?.options.find( + (option) => option.long === '--unit-runner' + ); + const e2eRunnerOption = createCommandDefinition?.options.find( + (option) => option.long === '--e2e-runner' + ); + const packageManagerOption = createCommandDefinition?.options.find((option) => option.long === '--pm'); + + expect(runtimeOption?.argChoices).toEqual(['vite', 'nextjs']); + expect(languageOption?.argChoices).toEqual(['javascript', 'typescript']); + expect(stylingOption?.argChoices).toEqual([ + 'none', + 'tailwind', + 'styled-components', + 'css-modules', + 'css', + ]); + expect(stateOption?.argChoices).toEqual(['none', 'redux', 'zustand', 'jotai']); + expect(testingOption?.argChoices).toEqual(['full', 'unit-component', 'custom', 'none']); + expect(unitRunnerOption?.argChoices).toEqual(['vitest', 'jest']); + expect(e2eRunnerOption?.argChoices).toEqual(['playwright', 'cypress', 'none']); + expect(packageManagerOption?.argChoices).toEqual(['npm', 'yarn', 'pnpm']); + }); + + it('should parse create command options', async () => { + const program = createCommand(); + + await program.parseAsync( + [ + 'node', + 'create-react-forge', + 'create', + 'my-app', + '--runtime', + 'vite', + '--language', + 'typescript', + '--styling', + 'css', + '--state', + 'jotai', + '--testing', + 'none', + '--unit-runner', + 'vitest', + '--e2e-runner', + 'none', + '--pm', + 'pnpm', + '--no-git', + '--query', + ], + { from: 'user' } + ); + + const createCommandDefinition = program.commands.find((command) => command.name() === 'create'); + const parsed = createCommandDefinition?.opts(); + + expect(parsed).toMatchObject({ + runtime: 'vite', + language: 'typescript', + styling: 'css', + state: 'jotai', + testing: 'none', + unitRunner: 'vitest', + e2eRunner: 'none', + pm: 'pnpm', + git: false, + query: true, + }); + }); +}); diff --git a/src/__tests__/installer.test.ts b/src/__tests__/installer.test.ts new file mode 100644 index 0000000..3b61a3e --- /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(), + }; + const ora = vi.fn(() => spinner); + const execa = vi.fn(); + + return { execaMock: execa, oraMock: ora, spinnerMock: spinner }; +}); + +vi.mock('execa', () => ({ + execa: execaMock, +})); + +vi.mock('ora', () => ({ + default: oraMock, +})); + +import { installDependencies } from '../lifecycle'; + +describe('installDependencies', () => { + beforeEach(() => { + vi.clearAllMocks(); + spinnerMock.start.mockReturnValue(spinnerMock); + }); + + it('should install dependencies and mark spinner as success', async () => { + execaMock.mockResolvedValueOnce({ exitCode: 0 }); + + await installDependencies('/tmp/test-project', 'npm'); + + expect(oraMock).toHaveBeenCalledWith('Installing dependencies with npm...'); + expect(spinnerMock.start).toHaveBeenCalledTimes(1); + expect(execaMock).toHaveBeenCalledWith('npm', ['install'], { cwd: '/tmp/test-project' }); + expect(spinnerMock.succeed).toHaveBeenCalledWith('Dependencies installed'); + expect(spinnerMock.fail).not.toHaveBeenCalled(); + }); + + it('should fail spinner and rethrow on installation error', async () => { + const error = new Error('install failed'); + execaMock.mockRejectedValueOnce(error); + + await expect(installDependencies('/tmp/test-project', 'pnpm')).rejects.toThrow('install failed'); + + expect(oraMock).toHaveBeenCalledWith('Installing dependencies with pnpm...'); + expect(spinnerMock.fail).toHaveBeenCalledWith('Failed to install dependencies'); + expect(spinnerMock.succeed).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/plugin-loader.test.ts b/src/__tests__/plugin-loader.test.ts new file mode 100644 index 0000000..2cf5299 --- /dev/null +++ b/src/__tests__/plugin-loader.test.ts @@ -0,0 +1,49 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; +import { PluginLoader } from '../plugins'; + +describe('PluginLoader', () => { + it('should load plugin default export', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'forge-plugin-default-')); + const pluginPath = join(tempDir, 'default-plugin.mjs'); + + writeFileSync( + pluginPath, + `export default { name: 'default-plugin', version: '1.0.0', hooks: {} };`, + 'utf-8' + ); + + const loader = new PluginLoader(); + const plugin = await loader.loadPlugin(pluginPath); + + expect(plugin.name).toBe('default-plugin'); + expect(plugin.version).toBe('1.0.0'); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should load plugin named exports when default is missing', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'forge-plugin-named-')); + const pluginPath = join(tempDir, 'named-plugin.mjs'); + + writeFileSync(pluginPath, `export const name = 'named-plugin'; export const version = '2.0.0';`, 'utf-8'); + + const loader = new PluginLoader(); + const plugin = await loader.loadPlugin(pluginPath); + + expect(plugin.name).toBe('named-plugin'); + expect(plugin.version).toBe('2.0.0'); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should throw a friendly error when plugin cannot be loaded', 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..f0f436b --- /dev/null +++ b/src/__tests__/plugin-manager.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ProjectConfig } from '../config/schema'; +import { PluginManager, type PluginContext, type ReactSetupPlugin } from '../plugins'; + +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: true, initialCommit: false }, + plugins: [], + ...overrides, + }; +} + +describe('PluginManager', () => { + it('should run registered lifecycle hooks', async () => { + const manager = new PluginManager(); + const hook = vi.fn(async () => undefined); + const context: PluginContext = { config: createConfig() }; + + const plugin: ReactSetupPlugin = { + name: 'test-plugin', + version: '1.0.0', + hooks: { + afterInstall: hook, + }, + }; + + manager.register(plugin); + await manager.runHook('afterInstall', context); + + expect(hook).toHaveBeenCalledTimes(1); + expect(hook).toHaveBeenCalledWith(context); + }); + + it('should continue running hooks when one plugin 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-plugin', + version: '1.0.0', + hooks: { + afterTemplateApply: async () => { + throw new Error('hook failed'); + }, + }, + }); + manager.register({ + name: 'successful-plugin', + version: '1.0.0', + hooks: { + afterTemplateApply: successHook, + }, + }); + + await manager.runHook('afterTemplateApply', context); + + expect(successHook).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + + warnSpy.mockRestore(); + }); + + it('should merge beforeCreate plugin results', async () => { + const manager = new PluginManager(); + + manager.register({ + name: 'set-styling', + version: '1.0.0', + hooks: { + beforeCreate: async (config) => ({ + ...config, + styling: { solution: 'css' }, + }), + }, + }); + manager.register({ + name: 'noop-plugin', + version: '1.0.0', + hooks: { + beforeCreate: async () => undefined, + }, + }); + + const updated = await manager.runBeforeCreate(createConfig()); + + expect(updated.styling.solution).toBe('css'); + }); + + it('should continue beforeCreate when plugin throws', 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: 'state-plugin', + version: '1.0.0', + hooks: { + beforeCreate: async (config) => ({ + ...config, + stateManagement: 'jotai', + }), + }, + }); + + const updated = await manager.runBeforeCreate(createConfig()); + + expect(updated.stateManagement).toBe('jotai'); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/template-utils.test.ts b/src/__tests__/template-utils.test.ts new file mode 100644 index 0000000..41d875a --- /dev/null +++ b/src/__tests__/template-utils.test.ts @@ -0,0 +1,130 @@ +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: true, initialCommit: false }, + plugins: [], + ...overrides, + }; +} + +describe('Template Utils', () => { + it('should resolve runtime template path', () => { + expect(getTemplatePathForRuntime('vite')).toBe('runtime/vite'); + expect(getTemplatePathForRuntime('nextjs')).toBe('runtime/nextjs'); + }); + + it('should resolve styling template paths', () => { + 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(getTemplatePathForStyling('css')).toBe('styling/css'); + }); + + it('should resolve state and testing template paths', () => { + expect(getTemplatePathForState('none')).toBe(''); + expect(getTemplatePathForState('redux')).toBe('state/redux'); + expect(getTemplatePathForTesting('none')).toBe(''); + expect(getTemplatePathForTesting('playwright')).toBe('testing/playwright'); + }); + + it('should resolve data fetching template path', () => { + expect(getTemplatePathForDataFetching('none')).toBe(''); + expect(getTemplatePathForDataFetching('tanstack-query')).toBe('features/tanstack-query'); + }); + + it('should build applicable template paths for a full-featured config', () => { + const paths = getApplicableTemplatePaths( + createConfig({ + runtime: 'vite', + styling: { solution: 'css-modules' }, + stateManagement: 'redux', + dataFetching: { enabled: true, library: 'tanstack-query' }, + testing: { + enabled: true, + unit: { enabled: true, runner: 'vitest' }, + component: { enabled: true, library: 'testing-library' }, + e2e: { enabled: true, runner: 'playwright' }, + }, + }) + ); + + expect(paths).toContain('base'); + expect(paths).toContain('runtime/vite'); + expect(paths).toContain('styling/css-modules'); + expect(paths).toContain('state/redux'); + expect(paths).toContain('testing/vitest'); + expect(paths).toContain('testing/playwright'); + expect(paths).toContain('features/tanstack-query'); + }); + + it('should omit optional templates when disabled', () => { + const paths = getApplicableTemplatePaths( + createConfig({ + runtime: 'nextjs', + 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/nextjs']); + }); + + it('should support legacy applicable template helper', () => { + const fullPaths = getApplicableTemplates({ + runtime: 'vite', + styling: 'css', + stateManagement: 'zustand', + testing: {}, + dataFetching: { enabled: true }, + }); + + expect(fullPaths).toContain('base'); + expect(fullPaths).toContain('runtime/vite'); + expect(fullPaths).toContain('styling/css'); + expect(fullPaths).toContain('state/zustand'); + expect(fullPaths).toContain('features/tanstack-query'); + + const minimalPaths = getApplicableTemplates({ + runtime: 'nextjs', + styling: 'none', + stateManagement: 'none', + testing: {}, + }); + + expect(minimalPaths).toEqual(['base', 'runtime/nextjs']); + }); +});