Skip to content

Commit 7f987ec

Browse files
authored
test: improve code coverage (#33)
1 parent a779254 commit 7f987ec

8 files changed

Lines changed: 857 additions & 0 deletions

src/__tests__/assembler.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import { join } from 'path';
4+
import { describe, expect, it } from 'vitest';
5+
import type { ProjectConfig } from '../config/schema';
6+
import { ProjectAssembler } from '../assembler';
7+
8+
function createConfig(overrides: Partial<ProjectConfig> = {}): ProjectConfig {
9+
return {
10+
name: 'test-app',
11+
path: '/tmp/test-app',
12+
runtime: 'vite',
13+
language: 'typescript',
14+
styling: { solution: 'tailwind' },
15+
stateManagement: 'none',
16+
dataFetching: { enabled: false, library: 'tanstack-query' },
17+
testing: {
18+
enabled: false,
19+
unit: { enabled: false, runner: 'vitest' },
20+
component: { enabled: false, library: 'testing-library' },
21+
e2e: { enabled: false, runner: 'none' },
22+
},
23+
linting: { prettier: true },
24+
packageManager: 'npm',
25+
git: { init: false, initialCommit: false },
26+
plugins: [],
27+
...overrides,
28+
};
29+
}
30+
31+
describe('ProjectAssembler', () => {
32+
it('should process template variables and expose immutable file map', () => {
33+
const tempDir = mkdtempSync(join(tmpdir(), 'forge-assembler-vars-'));
34+
const config = createConfig({ path: tempDir, name: 'awesome-app' });
35+
const assembler = new ProjectAssembler(tempDir, config);
36+
37+
assembler.addFile(
38+
'README.md',
39+
'{{PROJECT_NAME}} | {{PROJECT_DESCRIPTION}} | {{AUTHOR}} | {{LICENSE}}'
40+
);
41+
const files = assembler.getFiles();
42+
43+
expect(files.get('README.md')).toBe('awesome-app | A production-ready React application | | MIT');
44+
45+
files.set('another.md', 'changed');
46+
expect(assembler.getFiles().has('another.md')).toBe(false);
47+
48+
rmSync(tempDir, { recursive: true, force: true });
49+
});
50+
51+
it('should merge dependencies/scripts and write text and binary files', () => {
52+
const tempDir = mkdtempSync(join(tmpdir(), 'forge-assembler-write-'));
53+
const config = createConfig({ path: tempDir });
54+
const assembler = new ProjectAssembler(tempDir, config);
55+
56+
const sourceBinaryPath = join(tempDir, 'source.bin');
57+
writeFileSync(sourceBinaryPath, Buffer.from([0xde, 0xad, 0xbe, 0xef]));
58+
59+
assembler.addFiles(
60+
new Map([
61+
['src/index.ts', 'export const app = "{{PROJECT_NAME}}";'],
62+
['assets/copied.bin', `__BINARY__:${sourceBinaryPath}`],
63+
])
64+
);
65+
66+
assembler.addDependencies({ zeta: '^1.0.0', alpha: '^1.0.0' });
67+
assembler.addDevDependencies({ tsx: '^4.0.0' });
68+
assembler.addScripts({ dev: 'vite', build: 'vite build' });
69+
assembler.mergeTemplateDeps({
70+
dependencies: { react: '^19.0.0' },
71+
devDependencies: { typescript: '^5.0.0' },
72+
scripts: { test: 'vitest' },
73+
});
74+
75+
const writeResult = assembler.writeFiles();
76+
77+
expect(writeResult.errors).toEqual([]);
78+
expect(writeResult.filesWritten).toBe(3);
79+
80+
const textFile = readFileSync(join(tempDir, 'src/index.ts'), 'utf-8');
81+
expect(textFile).toContain('test-app');
82+
83+
const copiedBinary = readFileSync(join(tempDir, 'assets/copied.bin'));
84+
expect(copiedBinary.equals(Buffer.from([0xde, 0xad, 0xbe, 0xef]))).toBe(true);
85+
86+
const pkg = JSON.parse(readFileSync(join(tempDir, 'package.json'), 'utf-8')) as {
87+
dependencies: Record<string, string>;
88+
devDependencies: Record<string, string>;
89+
scripts: Record<string, string>;
90+
};
91+
92+
expect(Object.keys(pkg.dependencies)).toEqual(['alpha', 'react', 'zeta']);
93+
expect(pkg.devDependencies).toMatchObject({
94+
tsx: '^4.0.0',
95+
typescript: '^5.0.0',
96+
});
97+
expect(pkg.scripts).toMatchObject({
98+
dev: 'vite',
99+
build: 'vite build',
100+
test: 'vitest',
101+
});
102+
103+
rmSync(tempDir, { recursive: true, force: true });
104+
});
105+
106+
it('should capture file write errors and still write remaining files', () => {
107+
const tempDir = mkdtempSync(join(tmpdir(), 'forge-assembler-errors-'));
108+
const config = createConfig({ path: tempDir });
109+
const assembler = new ProjectAssembler(tempDir, config);
110+
111+
assembler.addFile('ok.txt', 'ok');
112+
assembler.addFile(`bad\u0000name.txt`, 'bad');
113+
114+
const result = assembler.writeFiles();
115+
116+
expect(result.errors.length).toBe(1);
117+
expect(result.errors[0]).toContain('Failed to write');
118+
expect(result.filesWritten).toBe(2);
119+
expect(readFileSync(join(tempDir, 'ok.txt'), 'utf-8')).toBe('ok');
120+
121+
rmSync(tempDir, { recursive: true, force: true });
122+
});
123+
124+
it('should support package json override and expose config/path', () => {
125+
const tempDir = mkdtempSync(join(tmpdir(), 'forge-assembler-pkg-'));
126+
const config = createConfig({ path: tempDir, name: 'pkg-app' });
127+
const assembler = new ProjectAssembler(tempDir, config);
128+
129+
assembler.setPackageJson({
130+
name: 'custom-name',
131+
version: '2.0.0',
132+
scripts: { start: 'node index.js' },
133+
dependencies: { b: '1.0.0', a: '1.0.0' },
134+
devDependencies: {},
135+
});
136+
assembler.addFile('index.js', 'console.log("hello");');
137+
138+
const pkgBeforeWrite = assembler.getPackageJson();
139+
expect(pkgBeforeWrite.name).toBe('custom-name');
140+
141+
const result = assembler.writeFiles();
142+
expect(result.errors).toEqual([]);
143+
144+
const pkg = JSON.parse(readFileSync(join(tempDir, 'package.json'), 'utf-8')) as {
145+
dependencies: Record<string, string>;
146+
};
147+
expect(Object.keys(pkg.dependencies)).toEqual(['a', 'b']);
148+
149+
expect(assembler.getConfig().name).toBe('pkg-app');
150+
expect(assembler.getProjectPath()).toBe(tempDir);
151+
152+
rmSync(tempDir, { recursive: true, force: true });
153+
});
154+
});

src/__tests__/cli-index.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { PromptAnswers } from '../cli/prompts';
3+
4+
const { promptForProjectDetailsMock, generateProjectMock } = vi.hoisted(() => ({
5+
promptForProjectDetailsMock: vi.fn(),
6+
generateProjectMock: vi.fn(),
7+
}));
8+
9+
vi.mock('../cli/prompts', () => ({
10+
promptForProjectDetails: promptForProjectDetailsMock,
11+
}));
12+
13+
vi.mock('../generator/index', () => ({
14+
generateProject: generateProjectMock,
15+
}));
16+
17+
import { main, promptAnswersToConfig, validateProjectName } from '../cli/index';
18+
19+
function createAnswers(overrides: Partial<PromptAnswers> = {}): PromptAnswers {
20+
return {
21+
projectName: 'test-app',
22+
projectPath: './test-app',
23+
runtime: 'vite',
24+
language: 'typescript',
25+
styling: 'styled-components',
26+
stateManagement: 'none',
27+
testing: 'none',
28+
unitRunner: 'vitest',
29+
e2eRunner: 'none',
30+
dataFetching: true,
31+
packageManager: 'npm',
32+
git: true,
33+
prettier: true,
34+
...overrides,
35+
};
36+
}
37+
38+
describe('CLI Index', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
});
42+
43+
it('should convert prompt answers into project config', () => {
44+
const config = promptAnswersToConfig(
45+
createAnswers({
46+
testing: 'full',
47+
runtime: 'nextjs',
48+
language: 'javascript',
49+
stateManagement: 'jotai',
50+
packageManager: 'pnpm',
51+
git: false,
52+
dataFetching: false,
53+
e2eRunner: 'cypress',
54+
})
55+
);
56+
57+
expect(config.name).toBe('test-app');
58+
expect(config.path).toContain('/test-app');
59+
expect(config.runtime).toBe('nextjs');
60+
expect(config.language).toBe('javascript');
61+
expect(config.styling.solution).toBe('styled-components');
62+
expect(config.stateManagement).toBe('jotai');
63+
expect(config.packageManager).toBe('pnpm');
64+
expect(config.git.init).toBe(false);
65+
expect(config.dataFetching.enabled).toBe(false);
66+
expect(config.testing.enabled).toBe(true);
67+
expect(config.testing.unit.runner).toBe('vitest');
68+
expect(config.testing.e2e.runner).toBe('cypress');
69+
});
70+
71+
it('should validate project name', () => {
72+
expect(validateProjectName('')).toEqual({
73+
valid: false,
74+
error: 'Project name is required',
75+
});
76+
expect(validateProjectName('Invalid Name')).toEqual({
77+
valid: false,
78+
error: 'Project name must be lowercase alphanumeric with hyphens',
79+
});
80+
expect(validateProjectName('valid-name-123')).toEqual({ valid: true });
81+
});
82+
83+
it('should run successful main flow and print warnings', async () => {
84+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
85+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
86+
87+
promptForProjectDetailsMock.mockResolvedValue(createAnswers());
88+
generateProjectMock.mockResolvedValue({
89+
success: true,
90+
projectPath: '/tmp/test-app',
91+
filesWritten: 10,
92+
errors: [],
93+
warnings: ['warn-one'],
94+
});
95+
96+
await main();
97+
98+
expect(promptForProjectDetailsMock).toHaveBeenCalledTimes(1);
99+
expect(generateProjectMock).toHaveBeenCalledTimes(1);
100+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('⚠️ Warnings:'));
101+
expect(errorSpy).not.toHaveBeenCalled();
102+
103+
logSpy.mockRestore();
104+
errorSpy.mockRestore();
105+
});
106+
107+
it('should exit with code 1 when generation fails', async () => {
108+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
109+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
110+
const exitSpy = vi
111+
.spyOn(process, 'exit')
112+
.mockImplementation(((code?: number) => {
113+
throw new Error(`exit ${code}`);
114+
}) as never);
115+
116+
promptForProjectDetailsMock.mockResolvedValue(createAnswers());
117+
generateProjectMock.mockResolvedValue({
118+
success: false,
119+
projectPath: '/tmp/test-app',
120+
filesWritten: 1,
121+
errors: ['generation failed'],
122+
warnings: [],
123+
});
124+
125+
await expect(main()).rejects.toThrow('exit 1');
126+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Project generation failed:'));
127+
128+
logSpy.mockRestore();
129+
errorSpy.mockRestore();
130+
exitSpy.mockRestore();
131+
});
132+
133+
it('should exit with code 0 when user cancels prompt', async () => {
134+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
135+
const exitSpy = vi
136+
.spyOn(process, 'exit')
137+
.mockImplementation(((code?: number) => {
138+
throw new Error(`exit ${code}`);
139+
}) as never);
140+
141+
promptForProjectDetailsMock.mockRejectedValue(new Error('User force closed the prompt'));
142+
143+
await expect(main()).rejects.toThrow('exit 0');
144+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Setup cancelled'));
145+
146+
logSpy.mockRestore();
147+
exitSpy.mockRestore();
148+
});
149+
150+
it('should rethrow unexpected errors from main flow', async () => {
151+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
152+
153+
promptForProjectDetailsMock.mockRejectedValue(new Error('unexpected failure'));
154+
155+
await expect(main()).rejects.toThrow('unexpected failure');
156+
157+
logSpy.mockRestore();
158+
});
159+
});

src/__tests__/cli-parser.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createCommand } from '../cli/parser';
3+
4+
describe('CLI parser', () => {
5+
it('should create command with expected metadata', () => {
6+
const program = createCommand();
7+
8+
expect(program.name()).toBe('create-react-forge');
9+
expect(program.description()).toContain('Production-ready React CLI scaffolder');
10+
expect(program.version()).toMatch(/^\d+\.\d+\.\d+$/);
11+
});
12+
13+
it('should register create command options with supported choices', () => {
14+
const program = createCommand();
15+
const createCmd = program.commands.find((cmd) => cmd.name() === 'create');
16+
17+
expect(createCmd).toBeDefined();
18+
expect(createCmd?.description()).toBe('Create a new React project');
19+
20+
const stylingOption = createCmd?.options.find((o) => o.long === '--styling');
21+
const stateOption = createCmd?.options.find((o) => o.long === '--state');
22+
const runtimeOption = createCmd?.options.find((o) => o.long === '--runtime');
23+
const unitRunnerOption = createCmd?.options.find((o) => o.long === '--unit-runner');
24+
25+
expect(runtimeOption?.argChoices).toEqual(['vite', 'nextjs']);
26+
expect(stylingOption?.argChoices).toEqual([
27+
'none',
28+
'tailwind',
29+
'styled-components',
30+
'css-modules',
31+
'css',
32+
]);
33+
expect(stateOption?.argChoices).toEqual(['none', 'redux', 'zustand', 'jotai']);
34+
expect(unitRunnerOption?.argChoices).toEqual(['vitest', 'jest']);
35+
});
36+
37+
it('should parse create command options', async () => {
38+
const program = createCommand();
39+
40+
await program.parseAsync(
41+
[
42+
'node',
43+
'create-react-forge',
44+
'create',
45+
'demo-app',
46+
'--runtime',
47+
'nextjs',
48+
'--language',
49+
'javascript',
50+
'--styling',
51+
'tailwind',
52+
'--state',
53+
'redux',
54+
'--testing',
55+
'unit-component',
56+
'--unit-runner',
57+
'jest',
58+
'--e2e-runner',
59+
'none',
60+
'--pm',
61+
'pnpm',
62+
'--no-git',
63+
'--no-query',
64+
],
65+
{ from: 'user' }
66+
);
67+
68+
const createCmd = program.commands.find((cmd) => cmd.name() === 'create');
69+
const parsed = createCmd?.opts();
70+
71+
expect(parsed).toMatchObject({
72+
runtime: 'nextjs',
73+
language: 'javascript',
74+
styling: 'tailwind',
75+
state: 'redux',
76+
testing: 'unit-component',
77+
unitRunner: 'jest',
78+
e2eRunner: 'none',
79+
pm: 'pnpm',
80+
git: false,
81+
query: false,
82+
});
83+
});
84+
});

0 commit comments

Comments
 (0)