Skip to content
Closed
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
73 changes: 73 additions & 0 deletions src/__tests__/architecture-generator.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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.');
});
});
101 changes: 101 additions & 0 deletions src/__tests__/cli-parser.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
53 changes: 53 additions & 0 deletions src/__tests__/installer.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
49 changes: 49 additions & 0 deletions src/__tests__/plugin-loader.test.ts
Original file line number Diff line number Diff line change
@@ -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:'
);
});
});
Loading
Loading