UNPKG

@stillrivercode/agentic-workflow-template

Version:

NPM package to create AI-powered GitHub workflow automation projects

436 lines (363 loc) 13.6 kB
const fs = require('fs-extra'); const { createProject } = require('../create-project'); // Mock inquirer to avoid interactive prompts in tests jest.mock('inquirer'); const inquirer = require('inquirer'); // Mock fs-extra for file operations jest.mock('fs-extra'); // Mock child_process for git operations jest.mock('child_process'); const { spawn } = require('child_process'); describe('createProject Function', () => { beforeEach(() => { jest.clearAllMocks(); // Default mock responses for inquirer inquirer.prompt.mockResolvedValue({ name: 'test-project', githubOrg: 'test-org', repositoryName: 'test-repo', description: 'Test project', features: ['ai-tasks', 'ai-pr-review'], template: 'default', }); // Mock fs-extra methods // Default behavior: pathExists returns false for target directory, true for source files fs.pathExists.mockImplementation((path) => { // If checking for README.md in project directory after copy, return true if ( path.endsWith('README.md') && (path.includes('test-project') || path.includes('valid-project-name') || path.includes('existing-project')) ) { return Promise.resolve(true); } // If checking for project directory, return false (doesn't exist) if ( path.includes('test-project') || path.includes('valid-project-name') || path.includes('existing-project') ) { return Promise.resolve(false); } // For template source files, return true (they exist) return Promise.resolve(true); }); fs.ensureDir.mockResolvedValue(); fs.copy.mockResolvedValue(); fs.writeJson.mockResolvedValue(); fs.readFile.mockResolvedValue('template content'); fs.writeFile.mockResolvedValue(); fs.remove.mockResolvedValue(); }); describe('Project Creation Flow', () => { test('creates project with valid name', async () => { await createProject('valid-project-name', { force: true }); expect(fs.ensureDir).toHaveBeenCalled(); expect(fs.copy).toHaveBeenCalled(); expect(fs.writeJson).toHaveBeenCalled(); }); test('prompts for name when not provided', async () => { await createProject(null, { force: true }); expect(inquirer.prompt).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ type: 'input', name: 'name', message: 'Project name:', }), ]) ); }); test('handles existing directory with force option', async () => { // Override the default mock for the first call to check if project exists fs.pathExists.mockImplementation((path) => { // First call checks if project directory exists - return true if (path.includes('existing-project')) { return Promise.resolve(true); } // For template source files, return true return Promise.resolve(true); }); await createProject('existing-project', { force: true }); expect(fs.remove).toHaveBeenCalled(); expect(fs.ensureDir).toHaveBeenCalled(); }); test('prompts for overwrite without force option', async () => { // Override pathExists for existing project check fs.pathExists .mockImplementationOnce(() => Promise.resolve(true)) .mockImplementation((_path) => { // For template source files, return true return Promise.resolve(true); }); // Mock inquirer to first return overwrite:true, then return the default config inquirer.prompt .mockResolvedValueOnce({ overwrite: true }) .mockResolvedValue({ name: 'test-project', githubOrg: 'test-org', repositoryName: 'test-repo', description: 'Test project', features: ['ai-tasks', 'ai-pr-review'], template: 'default', }); await createProject('existing-project', {}); expect(inquirer.prompt).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ type: 'confirm', name: 'overwrite', }), ]) ); }); }); describe('Template Handling', () => { test('uses default template when not specified', async () => { await createProject('test-project', {}); expect(inquirer.prompt).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ name: 'template', choices: expect.arrayContaining([ expect.objectContaining({ value: 'default' }), ]), }), ]) ); }); test('supports minimal template', async () => { inquirer.prompt.mockResolvedValue({ name: 'test-project', template: 'minimal', githubOrg: 'test-org', repositoryName: 'test-repo', description: 'Test project', features: ['ai-tasks'], }); await createProject('test-project', {}); // Verify template-specific behavior expect(fs.copy).toHaveBeenCalled(); }); test('supports enterprise template', async () => { inquirer.prompt.mockResolvedValue({ name: 'test-project', template: 'enterprise', githubOrg: 'test-org', repositoryName: 'test-repo', description: 'Test project', features: ['ai-tasks', 'ai-pr-review', 'cost-monitoring'], }); await createProject('test-project', {}); expect(fs.copy).toHaveBeenCalled(); }); }); describe('Feature Selection', () => { test('handles feature selection correctly', async () => { const features = ['ai-tasks', 'ai-pr-review', 'security']; inquirer.prompt.mockResolvedValue({ name: 'test-project', githubOrg: 'test-org', repositoryName: 'test-repo', description: 'Test project', features: features, template: 'default', }); await createProject('test-project', {}); expect(fs.writeJson).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ features: features, }), { spaces: 2 } ); }); }); describe('Git Integration', () => { test('initializes git when requested', async () => { const mockSpawn = { on: jest.fn((event, callback) => { if (event === 'close') { callback(0); // Success exit code } }), }; spawn.mockReturnValue(mockSpawn); await createProject('test-project', { gitInit: true, force: true, }); expect(spawn).toHaveBeenCalledWith('git', ['init'], expect.any(Object)); }); test('handles git initialization failure', async () => { const mockSpawn = { on: jest.fn((event, callback) => { if (event === 'close') { callback(1); // Error exit code } }), }; spawn.mockReturnValue(mockSpawn); await expect( createProject('test-project', { gitInit: true, force: true, }) ).rejects.toThrow('Git init failed'); }); }); describe('Configuration Validation', () => { test('validates GitHub organization format', async () => { await createProject('test-project', { force: true }); // The validation should happen within the inquirer validate function const promptCalls = inquirer.prompt.mock.calls; expect(promptCalls.length).toBeGreaterThan(0); // Find the prompt with GitHub org validation const githubOrgPrompt = promptCalls[0][0].find( (p) => p.name === 'githubOrg' ); expect(githubOrgPrompt).toBeDefined(); expect(githubOrgPrompt.validate).toBeDefined(); // Test the validation function expect(githubOrgPrompt.validate('valid-org')).toBe(true); expect(githubOrgPrompt.validate('')).toBe('Organization is required'); expect(githubOrgPrompt.validate(' ')).toBe('Organization is required'); }); test('generates proper repository configuration', async () => { const expectedConfig = { name: 'test-repo', description: 'Test project description', owner: 'test-org', features: ['ai-tasks', 'ai-pr-review'], template: 'default', created: expect.any(String), }; inquirer.prompt.mockResolvedValue({ name: 'test-project', githubOrg: 'test-org', repositoryName: 'test-repo', description: 'Test project description', features: ['ai-tasks', 'ai-pr-review'], template: 'default', }); await createProject('test-project', { force: true }); expect(fs.writeJson).toHaveBeenCalledWith( expect.stringContaining('repo-config.json'), expect.objectContaining(expectedConfig), { spaces: 2 } ); }); }); describe('Error Handling', () => { test('handles file system errors gracefully', async () => { fs.ensureDir.mockRejectedValue(new Error('Permission denied')); await expect( createProject('test-project', { force: true }) ).rejects.toThrow('Permission denied'); }); test('handles invalid project names', async () => { expect(() => createProject('Invalid Name', { force: true }) ).rejects.toThrow(); }); test('cancels operation when user declines overwrite', async () => { // Override pathExists for existing project check fs.pathExists.mockImplementationOnce(() => Promise.resolve(true)); inquirer.prompt.mockResolvedValueOnce({ overwrite: false }); await expect(createProject('existing-project', {})).rejects.toThrow( 'Operation cancelled' ); }); }); describe('Simplified Flow (No Secret Setup)', () => { test('creates project without secret setup', async () => { await createProject('test-project', { force: true }); // Verify project structure is created expect(fs.ensureDir).toHaveBeenCalled(); expect(fs.copy).toHaveBeenCalled(); expect(fs.writeJson).toHaveBeenCalled(); // Verify no setup-secrets related calls // setupSecrets function should not be called (it's removed) expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('README.md'), expect.any(String) ); }); test('does not prompt for API key configuration', async () => { await createProject('test-project', { force: true }); // Check that no API key prompts are made const promptCalls = inquirer.prompt.mock.calls; for (const call of promptCalls) { const questions = call[0]; const hasApiKeyQuestion = questions.some( (q) => q.name === 'setupApiKey' || q.name === 'apiKey' ); expect(hasApiKeyQuestion).toBe(false); } }); test('copies .env.example file', async () => { await createProject('test-project', { force: true }); // Verify that fs.copy was called and check if .env.example was copied expect(fs.copy).toHaveBeenCalled(); // Check if any of the fs.copy calls included .env.example const fsCopyCalls = fs.copy.mock.calls; const envExampleCopied = fsCopyCalls.some((call) => { const [src] = call; return src && src.includes('.env.example'); }); expect(envExampleCopied).toBe(true); }); }); describe('README Generation', () => { test('replaces template placeholders correctly', async () => { const templateReadme = `# agentic-workflow-template This is a template for {{PROJECT_DESCRIPTION}}. Repository: https://github.com/YOUR_ORG/YOUR_REPO`; const expectedReadme = `# test-repo This is a template for Test project description. Repository: https://github.com/test-org/test-repo`; fs.readFile.mockResolvedValue(templateReadme); inquirer.prompt.mockResolvedValue({ name: 'test-project', githubOrg: 'test-org', repositoryName: 'test-repo', description: 'Test project description', features: ['ai-tasks'], template: 'default', }); await createProject('test-project', { force: true }); expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('README.md'), expectedReadme ); }); it('should handle missing README.md gracefully', async () => { // Mock pathExists to return false for README.md in target directory fs.pathExists.mockImplementation((path) => { if (path.endsWith('README.md') && path.includes('test-project')) { return Promise.resolve(false); } if (path.includes('test-project')) { return Promise.resolve(false); } return Promise.resolve(true); }); const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); await createProject('test-project', { force: true }); // Should log warning about missing README.md and create default expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('README.md template not found') ); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Creating default README.md') ); // Should attempt to write default README.md expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('README.md'), expect.any(String) ); consoleSpy.mockRestore(); }); }); });