aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
330 lines (271 loc) • 11 kB
JavaScript
/**
* Integration Tests: Environment Configuration
* Story 1.6: Environment Configuration
*
* Tests for configure-environment.js with file system operations
*/
const fs = require('fs-extra');
const path = require('path');
const os = require('os');
const { configureEnvironment, updateGitignore } = require('../../src/config/configure-environment');
/**
* Cleanup helper with retry logic for flaky file system operations
* Handles ENOTEMPTY and EBUSY errors common in CI environments
* @param {string} dir - Directory to remove
* @param {number} maxRetries - Maximum retry attempts
* @param {number} retryDelay - Delay between retries in ms
*/
async function cleanupWithRetry(dir, maxRetries = 5, retryDelay = 100) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (await fs.pathExists(dir)) {
await fs.remove(dir);
}
return;
} catch (error) {
const isRetryable = error.code && ['ENOTEMPTY', 'EBUSY', 'EPERM', 'EACCES'].includes(error.code);
if (attempt === maxRetries || !isRetryable) {
// Last attempt failed or non-retryable error, log but don't throw
console.warn(`Warning: Failed to cleanup ${dir} after ${attempt} attempts:`, error.code);
return;
}
// Linear backoff (100ms, 200ms, 300ms...)
await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));
}
}
}
describe('Environment Configuration Integration', () => {
let testDir;
let testId;
beforeEach(async () => {
// Create unique temporary test directory with random suffix to avoid collisions
testId = `${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
testDir = path.join(os.tmpdir(), `aios-env-test-${testId}`);
await fs.ensureDir(testDir);
});
afterEach(async () => {
// Small delay to allow file handles to close
await new Promise(resolve => setTimeout(resolve, 50));
// Cleanup test directory with retry logic
await cleanupWithRetry(testDir);
});
describe('configureEnvironment', () => {
it('should create .env file with skip prompts', async () => {
const result = await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
projectType: 'GREENFIELD',
selectedIDEs: ['vscode'],
mcpServers: [],
});
expect(result.envCreated).toBe(true);
const envPath = path.join(testDir, '.env');
expect(await fs.pathExists(envPath)).toBe(true);
const content = await fs.readFile(envPath, 'utf8');
expect(content).toContain('NODE_ENV=development');
// Version-agnostic: check AIOS_VERSION exists with valid semver format
expect(content).toMatch(/AIOS_VERSION=\d+\.\d+\.\d+/);
});
it('should create .env.example file', async () => {
const result = await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
});
expect(result.envExampleCreated).toBe(true);
const envExamplePath = path.join(testDir, '.env.example');
expect(await fs.pathExists(envExamplePath)).toBe(true);
const content = await fs.readFile(envExamplePath, 'utf8');
expect(content).toContain('OPENAI_API_KEY=');
expect(content).not.toMatch(/sk-[a-zA-Z0-9]{20,}/);
});
it('should create core-config.yaml', async () => {
const result = await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
projectType: 'BROWNFIELD',
selectedIDEs: ['vscode', 'cursor'],
mcpServers: [{ name: 'github' }, { name: 'exa' }],
});
expect(result.coreConfigCreated).toBe(true);
const configPath = path.join(testDir, '.aios-core', 'core-config.yaml');
expect(await fs.pathExists(configPath)).toBe(true);
const content = await fs.readFile(configPath, 'utf8');
expect(content).toContain('type: BROWNFIELD');
expect(content).toContain('- vscode');
expect(content).toContain('- cursor');
expect(content).toContain('- github');
expect(content).toContain('- exa');
});
it('should update .gitignore', async () => {
const result = await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
});
expect(result.gitignoreUpdated).toBe(true);
const gitignorePath = path.join(testDir, '.gitignore');
expect(await fs.pathExists(gitignorePath)).toBe(true);
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toContain('.env');
});
it('should set .env file permissions on Unix', async () => {
if (process.platform === 'win32') {
// Skip on Windows
return;
}
await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
});
const envPath = path.join(testDir, '.env');
const stats = await fs.stat(envPath);
// Check permissions are 0600 (owner read/write only)
const mode = stats.mode & 0o777;
expect(mode).toBe(0o600);
});
it('should create backup of existing .env in non-prompt mode', async () => {
// Create existing .env
const envPath = path.join(testDir, '.env');
await fs.writeFile(envPath, 'EXISTING_KEY=existing_value', 'utf8');
// Run configuration (skipPrompts mode doesn't backup)
await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
});
// In skipPrompts mode, no backup is created, file is overwritten
const content = await fs.readFile(envPath, 'utf8');
expect(content).not.toContain('EXISTING_KEY=existing_value');
});
it('should handle errors gracefully', async () => {
// Try to write to a read-only directory (simulate permission error)
const readOnlyDir = path.join(testDir, 'readonly');
await fs.ensureDir(readOnlyDir);
// Note: This test is platform-dependent and may not work on all systems
// Just verify the function handles errors
try {
await configureEnvironment({
targetDir: '/invalid/path/that/does/not/exist',
skipPrompts: true,
});
fail('Should have thrown an error');
} catch (error) {
expect(error).toBeDefined();
}
});
});
describe('updateGitignore', () => {
it('should create .gitignore if not exists', async () => {
await updateGitignore(testDir);
const gitignorePath = path.join(testDir, '.gitignore');
expect(await fs.pathExists(gitignorePath)).toBe(true);
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toContain('.env');
});
it('should append to existing .gitignore', async () => {
const gitignorePath = path.join(testDir, '.gitignore');
await fs.writeFile(gitignorePath, 'node_modules\n*.log\n', 'utf8');
await updateGitignore(testDir);
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toContain('node_modules');
expect(content).toContain('*.log');
expect(content).toContain('.env');
});
it('should not duplicate .env entry', async () => {
const gitignorePath = path.join(testDir, '.gitignore');
await fs.writeFile(gitignorePath, '.env\n', 'utf8');
await updateGitignore(testDir);
const content = await fs.readFile(gitignorePath, 'utf8');
const lines = content.split('\n').filter(line => line.trim() === '.env' || line.trim() === '/.env');
expect(lines.length).toBe(1);
});
it('should recognize existing .env entry with slash', async () => {
const gitignorePath = path.join(testDir, '.gitignore');
await fs.writeFile(gitignorePath, '/.env\n', 'utf8');
await updateGitignore(testDir);
const content = await fs.readFile(gitignorePath, 'utf8');
const lines = content.split('\n').filter(line => line.trim() === '.env' || line.trim() === '/.env');
expect(lines.length).toBe(1);
});
});
describe('Wizard Integration', () => {
it('should integrate with wizard state from previous stories', async () => {
// Simulate wizard state from Stories 1.3, 1.4, 1.5
const wizardState = {
projectType: 'GREENFIELD',
selectedIDEs: ['vscode', 'cursor'],
mcpServers: [
{ name: 'github', id: 'github' },
{ name: 'exa', id: 'exa' },
],
};
const result = await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
...wizardState,
});
// Verify all files created
expect(result.envCreated).toBe(true);
expect(result.coreConfigCreated).toBe(true);
expect(result.gitignoreUpdated).toBe(true);
// Verify core-config includes wizard state
const configPath = path.join(testDir, '.aios-core', 'core-config.yaml');
const configContent = await fs.readFile(configPath, 'utf8');
expect(configContent).toContain('GREENFIELD');
expect(configContent).toContain('vscode');
expect(configContent).toContain('cursor');
expect(configContent).toContain('github');
expect(configContent).toContain('exa');
});
});
describe('Cross-Platform Compatibility', () => {
it('should handle Windows paths', async () => {
const result = await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
});
expect(result.envCreated).toBe(true);
expect(result.coreConfigCreated).toBe(true);
});
it('should create valid YAML on all platforms', async () => {
await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
projectType: 'GREENFIELD',
});
const yaml = require('js-yaml');
const configPath = path.join(testDir, '.aios-core', 'core-config.yaml');
const content = await fs.readFile(configPath, 'utf8');
// Should parse without errors
const parsed = yaml.load(content);
expect(parsed).toBeDefined();
expect(parsed.project.type).toBe('GREENFIELD');
});
});
describe('File Structure', () => {
it('should create correct directory structure', async () => {
await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
});
// Check all expected files exist
const expectedFiles = [
'.env',
'.env.example',
'.gitignore',
'.aios-core/core-config.yaml',
];
for (const file of expectedFiles) {
const filePath = path.join(testDir, file);
expect(await fs.pathExists(filePath)).toBe(true);
}
});
it('should create .aios-core directory if missing', async () => {
const aioscoreDir = path.join(testDir, '.aios-core');
expect(await fs.pathExists(aioscoreDir)).toBe(false);
await configureEnvironment({
targetDir: testDir,
skipPrompts: true,
});
expect(await fs.pathExists(aioscoreDir)).toBe(true);
});
});
});