Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions src/__tests__/assembler.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<string, string>;
devDependencies: Record<string, string>;
scripts: Record<string, string>;
};

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<string, string>;
};
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 });
});
});
159 changes: 159 additions & 0 deletions src/__tests__/cli-index.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
84 changes: 84 additions & 0 deletions src/__tests__/cli-parser.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Loading