create-roadkit
Version:
Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export
483 lines (409 loc) • 15 kB
text/typescript
/**
* Integration Tests for RoadKit CLI
*
* These tests verify that the complete CLI system works correctly with
* real template files and various configuration combinations while
* maintaining security standards.
*/
import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
import { join, resolve } from 'path';
import { existsSync } from 'fs';
import { mkdir, rm, writeFile, readFile } from 'fs/promises';
import { runSecureCLI, SecureRoadKitCLI } from '../cli/secure-cli.js';
import { SecureTemplateManager } from '../core/secure-templates.js';
import { Logger } from '../utils/logger.js';
import { TEST_TEMP_DIR } from './security.test.js';
// Test fixtures directory
const FIXTURES_DIR = resolve(__dirname, '../../test-fixtures');
describe('Integration: CLI End-to-End', () => {
beforeEach(async () => {
if (existsSync(TEST_TEMP_DIR)) {
await rm(TEST_TEMP_DIR, { recursive: true, force: true });
}
await mkdir(TEST_TEMP_DIR, { recursive: true });
await setupTestTemplates();
});
afterEach(async () => {
if (existsSync(TEST_TEMP_DIR)) {
await rm(TEST_TEMP_DIR, { recursive: true, force: true });
}
});
async function setupTestTemplates() {
// Create test template structures
const templates = ['basic', 'advanced', 'enterprise', 'custom'];
for (const template of templates) {
const templateDir = join(TEST_TEMP_DIR, 'templates', template);
await mkdir(templateDir, { recursive: true });
// Create package.json
await writeFile(join(templateDir, 'package.json'), JSON.stringify({
name: '{{PROJECT_NAME}}',
version: '1.0.0',
description: 'Generated by RoadKit',
scripts: {
dev: 'bun --hot index.ts',
build: 'bun build index.ts --outdir=dist --target=bun'
},
dependencies: {
'next': '^14.0.0',
'react': '^18.0.0',
'react-dom': '^18.0.0'
}
}, null, 2));
// Create basic file structure
await mkdir(join(templateDir, 'src'), { recursive: true });
await writeFile(join(templateDir, 'src', 'index.ts'), `
/**
* {{PROJECT_NAME}} - Generated by RoadKit
* Template: ${template}
* Theme: {{THEME_NAME}}
*/
export const projectName = '{{PROJECT_NAME}}';
export const template = '${template}';
export const theme = '{{THEME_NAME}}';
export const createdAt = '{{CREATION_DATE}}';
console.log('Welcome to', projectName);
`);
// Create README template
await writeFile(join(templateDir, 'README.md'), `
# {{PROJECT_NAME}}
This project was generated using RoadKit with the ${template} template.
## Template: ${template}
## Theme: {{THEME_NAME}}
## Created: {{CREATION_DATE}}
## Getting Started
\`\`\`bash
bun install
bun dev
\`\`\`
## Author
{{PROJECT_NAME}} is created and maintained by the RoadKit team.
`);
// Create TypeScript config if advanced template
if (template !== 'basic') {
await writeFile(join(templateDir, 'tsconfig.json'), JSON.stringify({
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
strict: true,
skipLibCheck: true
},
include: ['src/**/*'],
exclude: ['node_modules', 'dist']
}, null, 2));
}
}
}
test('should generate project with basic template successfully', async () => {
const projectName = 'test-basic-project';
const outputDir = join(TEST_TEMP_DIR, 'output');
const result = await runSecureCLI({
name: projectName,
template: 'basic',
theme: 'modern',
output: outputDir,
skipPrompts: true,
skipInstall: true,
skipGit: true,
verbose: true
});
if (result.success) {
// Verify project structure was created
const projectDir = join(outputDir, projectName);
expect(existsSync(projectDir)).toBe(true);
// Verify package.json was processed
if (existsSync(join(projectDir, 'package.json'))) {
const packageJson = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'));
expect(packageJson.name).toBe(projectName);
}
// Verify template variables were replaced
if (existsSync(join(projectDir, 'src', 'index.ts'))) {
const indexContent = await readFile(join(projectDir, 'src', 'index.ts'), 'utf8');
expect(indexContent).toContain(`projectName = '${projectName}'`);
expect(indexContent).toContain(`template = 'basic'`);
expect(indexContent).toContain(`theme = 'modern'`);
expect(indexContent).not.toContain('{{PROJECT_NAME}}');
}
}
// Even if it fails, it should handle it gracefully
expect(result.success).toBeDefined();
expect(result.errors).toBeDefined();
expect(result.warnings).toBeDefined();
expect(result.securityWarnings).toBeDefined();
});
test('should handle all template types', async () => {
const templates: Array<'basic' | 'advanced' | 'enterprise' | 'custom'> = ['basic', 'advanced', 'enterprise', 'custom'];
for (const template of templates) {
const projectName = `test-${template}-project`;
const outputDir = join(TEST_TEMP_DIR, 'output', template);
const result = await runSecureCLI({
name: projectName,
template,
theme: 'modern',
output: outputDir,
skipPrompts: true,
skipInstall: true,
skipGit: true
});
// Should handle each template type gracefully
expect(result).toBeDefined();
expect(result.errors).toBeDefined();
expect(result.securityWarnings).toBeDefined();
expect(typeof result.success).toBe('boolean');
}
});
test('should handle all theme types', async () => {
const themes: Array<'modern' | 'classic' | 'minimal' | 'corporate'> = ['modern', 'classic', 'minimal', 'corporate'];
for (const theme of themes) {
const projectName = `test-${theme}-project`;
const outputDir = join(TEST_TEMP_DIR, 'output', theme);
const result = await runSecureCLI({
name: projectName,
template: 'basic',
theme,
output: outputDir,
skipPrompts: true,
skipInstall: true,
skipGit: true
});
// Should handle each theme type gracefully
expect(result).toBeDefined();
expect(result.errors).toBeDefined();
expect(result.securityWarnings).toBeDefined();
}
});
test('should handle missing template directory gracefully', async () => {
// Use a non-existent template directory
const cli = new SecureRoadKitCLI({
templateDir: '/nonexistent/templates'
});
const result = await cli.execute({
name: 'test-project',
template: 'basic',
theme: 'modern',
skipPrompts: true,
skipInstall: true,
skipGit: true
});
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.canRetry).toBe(true);
});
test('should validate project configuration comprehensively', async () => {
// Test with maximum valid configuration
const result = await runSecureCLI({
name: 'comprehensive-test-project-with-long-but-valid-name',
template: 'enterprise',
theme: 'corporate',
output: join(TEST_TEMP_DIR, 'complex', 'nested', 'output'),
skipPrompts: true,
skipInstall: true,
skipGit: true,
verbose: true,
overwrite: true
});
// Should handle complex but valid configuration
expect(result).toBeDefined();
expect(result.duration).toBeGreaterThan(0);
});
test('should respect dry-run mode', async () => {
const outputDir = join(TEST_TEMP_DIR, 'dry-run-test');
const result = await runSecureCLI({
name: 'dry-run-project',
template: 'basic',
theme: 'modern',
output: outputDir,
dryRun: true,
skipPrompts: true
});
// In dry-run mode, no files should be created
expect(existsSync(outputDir)).toBe(false);
// Should complete without errors in dry-run
expect(result).toBeDefined();
});
});
describe('Integration: Template Manager', () => {
beforeEach(async () => {
if (existsSync(TEST_TEMP_DIR)) {
await rm(TEST_TEMP_DIR, { recursive: true, force: true });
}
await mkdir(TEST_TEMP_DIR, { recursive: true });
});
afterEach(async () => {
if (existsSync(TEST_TEMP_DIR)) {
await rm(TEST_TEMP_DIR, { recursive: true, force: true });
}
});
test('should create SecureTemplateManager with valid directory', async () => {
const templateDir = join(TEST_TEMP_DIR, 'templates');
await mkdir(templateDir, { recursive: true });
expect(() => {
new SecureTemplateManager(templateDir);
}).not.toThrow();
});
test('should handle template processing with binary files', async () => {
const templateDir = join(TEST_TEMP_DIR, 'templates', 'basic');
await mkdir(templateDir, { recursive: true });
// Create package.json
await writeFile(join(templateDir, 'package.json'), JSON.stringify({
name: '{{PROJECT_NAME}}',
version: '1.0.0'
}));
// Create a "binary" file (just some non-text content)
const binaryContent = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
await writeFile(join(templateDir, 'image.png'), binaryContent);
const manager = new SecureTemplateManager(templateDir);
const projectConfig = {
name: 'binary-test',
template: 'basic' as const,
theme: 'modern' as const,
path: join(TEST_TEMP_DIR, 'output'),
language: 'typescript' as const
};
const result = await manager.scaffoldProject(projectConfig);
// Should handle binary files without error
expect(result).toBeDefined();
expect(result.success).toBeDefined();
});
test('should enforce template size limits', async () => {
const templateDir = join(TEST_TEMP_DIR, 'templates', 'basic');
await mkdir(templateDir, { recursive: true });
// Create package.json
await writeFile(join(templateDir, 'package.json'), JSON.stringify({
name: '{{PROJECT_NAME}}',
version: '1.0.0'
}));
// Create a very large file to test size limits
const largeContent = 'x'.repeat(2 * 1024 * 1024); // 2MB file
await writeFile(join(templateDir, 'large-file.txt'), largeContent);
const manager = new SecureTemplateManager(templateDir);
const projectConfig = {
name: 'size-test',
template: 'basic' as const,
theme: 'modern' as const,
path: join(TEST_TEMP_DIR, 'output'),
language: 'typescript' as const
};
const result = await manager.scaffoldProject(projectConfig);
// Should either skip large files or handle them appropriately
expect(result).toBeDefined();
if (!result.success) {
expect(result.error).toBeTruthy();
}
if (result.skippedFiles.length > 0) {
expect(result.skippedFiles.some(file => file.includes('large-file'))).toBe(true);
}
});
});
describe('Integration: Logging and Error Recovery', () => {
test('should create comprehensive logs during operation', async () => {
const logDir = join(TEST_TEMP_DIR, 'logs');
await mkdir(logDir, { recursive: true });
const cli = new SecureRoadKitCLI({
verbose: true,
logDir
});
await cli.execute({
name: 'log-test-project',
template: 'basic',
theme: 'modern',
output: join(TEST_TEMP_DIR, 'output'),
skipPrompts: true,
skipInstall: true,
skipGit: true
});
// Should create log files
const logFiles = await readdir(logDir).catch(() => []);
// Log files might be created depending on the implementation
// The important thing is that logging doesn't crash the application
expect(Array.isArray(logFiles)).toBe(true);
});
test('should handle permission errors gracefully', async () => {
// Try to create project in system directory (should fail gracefully)
const result = await runSecureCLI({
name: 'permission-test',
template: 'basic',
theme: 'modern',
output: '/root/test-project', // This should fail on most systems
skipPrompts: true,
skipInstall: true,
skipGit: true
});
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.canRetry).toBe(true);
expect(result.retryInstructions).toBeTruthy();
});
});
describe('Integration: Concurrent Operations', () => {
test('should handle multiple concurrent CLI operations safely', async () => {
const operations = Array.from({ length: 5 }, (_, i) =>
runSecureCLI({
name: `concurrent-project-${i}`,
template: 'basic',
theme: 'modern',
output: join(TEST_TEMP_DIR, 'concurrent', `project-${i}`),
skipPrompts: true,
skipInstall: true,
skipGit: true
})
);
const results = await Promise.all(operations);
// All operations should complete without crashing
results.forEach(result => {
expect(result).toBeDefined();
expect(result.success).toBeDefined();
expect(result.errors).toBeDefined();
expect(result.duration).toBeGreaterThan(0);
});
});
test('should handle mixed valid and invalid operations', async () => {
const mixedOperations = [
// Valid operation
runSecureCLI({
name: 'valid-project',
template: 'basic',
theme: 'modern',
output: join(TEST_TEMP_DIR, 'mixed', 'valid'),
skipPrompts: true,
skipInstall: true,
skipGit: true
}),
// Invalid operation (malicious name)
runSecureCLI({
name: '../malicious',
template: 'basic',
theme: 'modern',
output: join(TEST_TEMP_DIR, 'mixed', 'invalid1'),
skipPrompts: true,
skipInstall: true,
skipGit: true
}),
// Invalid operation (bad template)
runSecureCLI({
name: 'invalid-template-project',
template: 'nonexistent' as any,
theme: 'modern',
output: join(TEST_TEMP_DIR, 'mixed', 'invalid2'),
skipPrompts: true,
skipInstall: true,
skipGit: true
})
];
const results = await Promise.all(mixedOperations);
// Should handle mix of valid and invalid operations
expect(results.length).toBe(3);
results.forEach(result => {
expect(result).toBeDefined();
expect(typeof result.success).toBe('boolean');
});
// At least some should fail due to invalid inputs
const failures = results.filter(r => !r.success);
expect(failures.length).toBeGreaterThan(0);
});
});
// Helper function that might be used in other tests
async function readdir(path: string): Promise<string[]> {
const { readdir } = await import('fs/promises');
return readdir(path);
}