@stillrivercode/agentic-workflow-template
Version:
NPM package to create AI-powered GitHub workflow automation projects
436 lines (363 loc) • 13.6 kB
JavaScript
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();
});
});
});