From 4416976057072107a8bf1ef7ddb1e30442767ac6 Mon Sep 17 00:00:00 2001 From: Chirag Date: Fri, 20 Feb 2026 14:24:11 +0530 Subject: [PATCH 1/2] fix pipeline --- package-lock.json | 15 ++++++++++++--- src/cli/parser.ts | 4 +++- src/config/defaults.ts | 4 ++-- src/config/schema.ts | 7 ++++--- .../overlays/styling/css-modules/manifest.json | 17 +++++++++++++++++ src/templates/registry.ts | 17 ++++++++++------- 6 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 src/templates/overlays/styling/css-modules/manifest.json diff --git a/package-lock.json b/package-lock.json index 51f11c8..c4d515e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "vitest": "^2.1.8" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.9.0" } }, "node_modules/@ampproject/remapping": { @@ -1666,8 +1666,9 @@ "version": "22.19.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1707,6 +1708,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2042,6 +2044,7 @@ "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "2.1.9", "fflate": "^0.8.2", @@ -2079,6 +2082,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2585,6 +2589,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3865,6 +3870,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4436,6 +4442,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4472,7 +4479,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -4512,6 +4519,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5025,6 +5033,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", diff --git a/src/cli/parser.ts b/src/cli/parser.ts index c73dbca..dc63572 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -39,10 +39,12 @@ export function createCommand(): Command { 'none', 'tailwind', 'styled-components', + 'css-modules', + 'css', ]) ) .addOption( - new Option('--state ', 'State management').choices(['none', 'redux', 'zustand']) + new Option('--state ', 'State management').choices(['none', 'redux', 'zustand', 'jotai']) ) .addOption( new Option('--testing ', 'Testing setup').choices(['full', 'unit-component', 'custom', 'none']) diff --git a/src/config/defaults.ts b/src/config/defaults.ts index aa1fade..eebaacc 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -8,9 +8,10 @@ export const RUNTIME_DESCRIPTIONS: Record = { }; export const STYLING_DESCRIPTIONS: Record = { - none: 'None - Plain CSS classes', tailwind: 'Tailwind CSS - Utility-first framework', 'styled-components': 'Styled Components - CSS-in-JS', + 'css-modules': 'CSS Modules - Scoped component styles', + css: 'Plain CSS - Traditional stylesheet approach', }; export const STATE_DESCRIPTIONS: Record = { @@ -48,4 +49,3 @@ export const PACKAGE_MANAGER_COMMANDS: Record; export const LanguageSchema = z.enum(['javascript', 'typescript']); export type Language = z.infer; -export const StylingSchema = z.enum(['none', 'tailwind', 'styled-components']); +export const StylingSchema = z.enum(['tailwind', 'styled-components', 'css-modules', 'css']); export type Styling = z.infer; +export const StylingSolutionSchema = z.union([StylingSchema, z.literal('none')]); +export type StylingSolution = z.infer; export const StateManagementSchema = z.enum(['none', 'redux', 'zustand', 'jotai']); export type StateManagement = z.infer; @@ -61,7 +63,7 @@ export const GitConfigSchema = z.object({ export type GitConfig = z.infer; export const StylingConfigSchema = z.object({ - solution: StylingSchema.default('styled-components'), + solution: StylingSolutionSchema.default('styled-components'), }); export type StylingConfigType = z.infer; @@ -129,4 +131,3 @@ export const DEFAULT_CONFIG: ProjectConfig = { - diff --git a/src/templates/overlays/styling/css-modules/manifest.json b/src/templates/overlays/styling/css-modules/manifest.json new file mode 100644 index 0000000..c3577aa --- /dev/null +++ b/src/templates/overlays/styling/css-modules/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "styling-css-modules", + "version": "1.0.0", + "description": "CSS Modules styling solution", + "compatibleWith": ["runtime-vite", "runtime-nextjs"], + "dependencies": {}, + "devDependencies": {}, + "scripts": {}, + "filePatterns": { + "include": ["**/*"], + "exclude": ["manifest.json", "_vite", "_nextjs"] + }, + "runtimeOverrides": { + "vite": "_vite", + "nextjs": "_nextjs" + } +} diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 037d80c..d5301fe 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -239,14 +239,17 @@ export class TemplateRegistry { templates.push(this.loadAndRegister(`runtime/${config.runtime}`)); // Load styling template (pass runtime for runtime-specific overlays) - // Vite: always styled-components - // Next.js: tailwind or none (none = no styling overlay, use runtime defaults) - if (config.styling.solution === 'tailwind') { - templates.push(this.loadAndRegister('styling/tailwind', config.runtime)); - } else if (config.styling.solution === 'styled-components') { - templates.push(this.loadAndRegister('styling/styled-components', config.runtime)); + // 'none' intentionally skips adding a styling overlay. + const stylingTemplateMap: Record = { + tailwind: 'styling/tailwind', + 'styled-components': 'styling/styled-components', + 'css-modules': 'styling/css-modules', + css: 'styling/css', + }; + const stylingTemplate = stylingTemplateMap[config.styling.solution]; + if (stylingTemplate) { + templates.push(this.loadAndRegister(stylingTemplate, config.runtime)); } - // 'none' - don't load any styling overlay, use runtime defaults // Load state management template if (config.stateManagement && config.stateManagement !== 'none') { From ba741f65e5a9b8e322d7a1b57e9b7cc1b8b7212d Mon Sep 17 00:00:00 2001 From: Chirag Date: Fri, 20 Feb 2026 14:39:04 +0530 Subject: [PATCH 2/2] test: enhance test coverage --- src/__tests__/architecture-generator.test.ts | 73 ++++++++++ src/__tests__/cli-parser.test.ts | 101 ++++++++++++++ src/__tests__/installer.test.ts | 53 ++++++++ src/__tests__/plugin-loader.test.ts | 49 +++++++ src/__tests__/plugin-manager.test.ts | 136 +++++++++++++++++++ src/__tests__/template-utils.test.ts | 130 ++++++++++++++++++ 6 files changed, 542 insertions(+) create mode 100644 src/__tests__/architecture-generator.test.ts create mode 100644 src/__tests__/cli-parser.test.ts create mode 100644 src/__tests__/installer.test.ts create mode 100644 src/__tests__/plugin-loader.test.ts create mode 100644 src/__tests__/plugin-manager.test.ts create mode 100644 src/__tests__/template-utils.test.ts 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']); + }); +});