mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-09-14 20:16:53 +08:00
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { ArchiveCommand } from '../../src/core/archive.js';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
|
|
// Mock @inquirer/prompts
|
|
vi.mock('@inquirer/prompts', () => ({
|
|
select: vi.fn(),
|
|
confirm: vi.fn()
|
|
}));
|
|
|
|
describe('ArchiveCommand', () => {
|
|
let tempDir: string;
|
|
let archiveCommand: ArchiveCommand;
|
|
const originalConsoleLog = console.log;
|
|
|
|
beforeEach(async () => {
|
|
// Create temp directory
|
|
tempDir = path.join(os.tmpdir(), `openspec-archive-test-${Date.now()}`);
|
|
await fs.mkdir(tempDir, { recursive: true });
|
|
|
|
// Change to temp directory
|
|
process.chdir(tempDir);
|
|
|
|
// Create OpenSpec structure
|
|
const openspecDir = path.join(tempDir, 'openspec');
|
|
await fs.mkdir(path.join(openspecDir, 'changes'), { recursive: true });
|
|
await fs.mkdir(path.join(openspecDir, 'specs'), { recursive: true });
|
|
await fs.mkdir(path.join(openspecDir, 'changes', 'archive'), { recursive: true });
|
|
|
|
// Suppress console.log during tests
|
|
console.log = vi.fn();
|
|
|
|
archiveCommand = new ArchiveCommand();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Restore console.log
|
|
console.log = originalConsoleLog;
|
|
|
|
// Clear mocks
|
|
vi.clearAllMocks();
|
|
|
|
// Clean up temp directory
|
|
try {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
describe('execute', () => {
|
|
it('should archive a change successfully', async () => {
|
|
// Create a test change
|
|
const changeName = 'test-feature';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
await fs.mkdir(changeDir, { recursive: true });
|
|
|
|
// Create tasks.md with completed tasks
|
|
const tasksContent = '- [x] Task 1\n- [x] Task 2';
|
|
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
|
|
|
|
// Execute archive with --yes flag
|
|
await archiveCommand.execute(changeName, { yes: true });
|
|
|
|
// Check that change was moved to archive
|
|
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
|
const archives = await fs.readdir(archiveDir);
|
|
|
|
expect(archives.length).toBe(1);
|
|
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
|
|
|
|
// Verify original change directory no longer exists
|
|
await expect(fs.access(changeDir)).rejects.toThrow();
|
|
});
|
|
|
|
it('should warn about incomplete tasks', async () => {
|
|
const changeName = 'incomplete-feature';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
await fs.mkdir(changeDir, { recursive: true });
|
|
|
|
// Create tasks.md with incomplete tasks
|
|
const tasksContent = '- [x] Task 1\n- [ ] Task 2\n- [ ] Task 3';
|
|
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
|
|
|
|
// Execute archive with --yes flag
|
|
await archiveCommand.execute(changeName, { yes: true });
|
|
|
|
// Verify warning was logged
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
expect.stringContaining('Warning: 2 incomplete task(s) found')
|
|
);
|
|
});
|
|
|
|
it('should update specs when archiving', async () => {
|
|
const changeName = 'spec-feature';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
|
|
await fs.mkdir(changeSpecDir, { recursive: true });
|
|
|
|
// Create valid spec in change
|
|
const specContent = `# Test Capability Spec
|
|
|
|
## Purpose
|
|
This is a test capability specification for testing purposes.
|
|
|
|
## Requirements
|
|
|
|
### The system SHALL provide test capability
|
|
|
|
#### Scenario: Basic test
|
|
Given a test condition
|
|
When an action occurs
|
|
Then expected result happens`;
|
|
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
|
|
|
// Execute archive with --yes flag and skip validation for speed
|
|
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
|
|
|
// Verify spec was copied to main specs
|
|
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
|
|
const copiedContent = await fs.readFile(mainSpecPath, 'utf-8');
|
|
expect(copiedContent).toBe(specContent);
|
|
});
|
|
|
|
it('should throw error if change does not exist', async () => {
|
|
await expect(
|
|
archiveCommand.execute('non-existent-change', { yes: true })
|
|
).rejects.toThrow("Change 'non-existent-change' not found.");
|
|
});
|
|
|
|
it('should throw error if archive already exists', async () => {
|
|
const changeName = 'duplicate-feature';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
await fs.mkdir(changeDir, { recursive: true });
|
|
|
|
// Create existing archive with same date
|
|
const date = new Date().toISOString().split('T')[0];
|
|
const archivePath = path.join(tempDir, 'openspec', 'changes', 'archive', `${date}-${changeName}`);
|
|
await fs.mkdir(archivePath, { recursive: true });
|
|
|
|
// Try to archive
|
|
await expect(
|
|
archiveCommand.execute(changeName, { yes: true })
|
|
).rejects.toThrow(`Archive '${date}-${changeName}' already exists.`);
|
|
});
|
|
|
|
it('should handle changes without tasks.md', async () => {
|
|
const changeName = 'no-tasks-feature';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
await fs.mkdir(changeDir, { recursive: true });
|
|
|
|
// Execute archive without tasks.md
|
|
await archiveCommand.execute(changeName, { yes: true });
|
|
|
|
// Should complete without warnings
|
|
expect(console.log).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('incomplete task(s)')
|
|
);
|
|
|
|
// Verify change was archived
|
|
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
|
const archives = await fs.readdir(archiveDir);
|
|
expect(archives.length).toBe(1);
|
|
});
|
|
|
|
it('should handle changes without specs', async () => {
|
|
const changeName = 'no-specs-feature';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
await fs.mkdir(changeDir, { recursive: true });
|
|
|
|
// Execute archive without specs
|
|
await archiveCommand.execute(changeName, { yes: true });
|
|
|
|
// Should complete without spec updates
|
|
expect(console.log).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('Specs to update')
|
|
);
|
|
|
|
// Verify change was archived
|
|
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
|
const archives = await fs.readdir(archiveDir);
|
|
expect(archives.length).toBe(1);
|
|
});
|
|
|
|
it('should skip spec updates when --skip-specs flag is used', async () => {
|
|
const changeName = 'skip-specs-feature';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
|
|
await fs.mkdir(changeSpecDir, { recursive: true });
|
|
|
|
// Create spec in change
|
|
const specContent = '# Test Capability Spec\n\nTest content';
|
|
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
|
|
|
// Execute archive with --skip-specs flag and noValidate to skip validation
|
|
await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, noValidate: true });
|
|
|
|
// Verify skip message was logged
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
'Skipping spec updates (--skip-specs flag provided).'
|
|
);
|
|
|
|
// Verify spec was NOT copied to main specs
|
|
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
|
|
await expect(fs.access(mainSpecPath)).rejects.toThrow();
|
|
|
|
// Verify change was still archived
|
|
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
|
const archives = await fs.readdir(archiveDir);
|
|
expect(archives.length).toBe(1);
|
|
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
|
|
});
|
|
|
|
it('should proceed with archive when user declines spec updates', async () => {
|
|
const { confirm } = await import('@inquirer/prompts');
|
|
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const changeName = 'decline-specs-feature';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
|
|
await fs.mkdir(changeSpecDir, { recursive: true });
|
|
|
|
// Create valid spec in change
|
|
const specContent = `# Test Capability Spec
|
|
|
|
## Purpose
|
|
This is a test capability specification.
|
|
|
|
## Requirements
|
|
|
|
### The system SHALL provide test capability
|
|
|
|
#### Scenario: Basic test
|
|
Given a test condition
|
|
When an action occurs
|
|
Then expected result happens`;
|
|
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
|
|
|
// Mock confirm to return false (decline spec updates)
|
|
mockConfirm.mockResolvedValueOnce(false);
|
|
|
|
// Execute archive without --yes flag
|
|
await archiveCommand.execute(changeName);
|
|
|
|
// Verify user was prompted about specs
|
|
expect(mockConfirm).toHaveBeenCalledWith({
|
|
message: 'Proceed with spec updates?',
|
|
default: true
|
|
});
|
|
|
|
// Verify skip message was logged
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
'Skipping spec updates. Proceeding with archive.'
|
|
);
|
|
|
|
// Verify spec was NOT copied to main specs
|
|
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
|
|
await expect(fs.access(mainSpecPath)).rejects.toThrow();
|
|
|
|
// Verify change was still archived
|
|
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
|
const archives = await fs.readdir(archiveDir);
|
|
expect(archives.length).toBe(1);
|
|
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should throw error when openspec directory does not exist', async () => {
|
|
// Remove openspec directory
|
|
await fs.rm(path.join(tempDir, 'openspec'), { recursive: true });
|
|
|
|
await expect(
|
|
archiveCommand.execute('any-change', { yes: true })
|
|
).rejects.toThrow("No OpenSpec changes directory found. Run 'openspec init' first.");
|
|
});
|
|
});
|
|
|
|
describe('interactive mode', () => {
|
|
it('should use select prompt for change selection', async () => {
|
|
const { select } = await import('@inquirer/prompts');
|
|
const mockSelect = select as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
// Create test changes
|
|
const change1 = 'feature-a';
|
|
const change2 = 'feature-b';
|
|
await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change1), { recursive: true });
|
|
await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change2), { recursive: true });
|
|
|
|
// Mock select to return first change
|
|
mockSelect.mockResolvedValueOnce(change1);
|
|
|
|
// Execute without change name
|
|
await archiveCommand.execute(undefined, { yes: true });
|
|
|
|
// Verify select was called with correct options
|
|
expect(mockSelect).toHaveBeenCalledWith({
|
|
message: 'Select a change to archive',
|
|
choices: [
|
|
{ name: change1, value: change1 },
|
|
{ name: change2, value: change2 }
|
|
]
|
|
});
|
|
|
|
// Verify the selected change was archived
|
|
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
|
const archives = await fs.readdir(archiveDir);
|
|
expect(archives[0]).toContain(change1);
|
|
});
|
|
|
|
it('should use confirm prompt for task warnings', async () => {
|
|
const { confirm } = await import('@inquirer/prompts');
|
|
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const changeName = 'incomplete-interactive';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
await fs.mkdir(changeDir, { recursive: true });
|
|
|
|
// Create tasks.md with incomplete tasks
|
|
const tasksContent = '- [ ] Task 1';
|
|
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
|
|
|
|
// Mock confirm to return true (proceed)
|
|
mockConfirm.mockResolvedValueOnce(true);
|
|
|
|
// Execute without --yes flag
|
|
await archiveCommand.execute(changeName);
|
|
|
|
// Verify confirm was called
|
|
expect(mockConfirm).toHaveBeenCalledWith({
|
|
message: 'Warning: 1 incomplete task(s) found. Continue?',
|
|
default: false
|
|
});
|
|
});
|
|
|
|
it('should cancel when user declines task warning', async () => {
|
|
const { confirm } = await import('@inquirer/prompts');
|
|
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const changeName = 'cancel-test';
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
|
await fs.mkdir(changeDir, { recursive: true });
|
|
|
|
// Create tasks.md with incomplete tasks
|
|
const tasksContent = '- [ ] Task 1';
|
|
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
|
|
|
|
// Mock confirm to return false (cancel) for validation skip
|
|
mockConfirm.mockResolvedValueOnce(false);
|
|
// Mock another false for task warning
|
|
mockConfirm.mockResolvedValueOnce(false);
|
|
|
|
// Execute without --yes flag but skip validation to test task warning
|
|
await archiveCommand.execute(changeName, { noValidate: true });
|
|
|
|
// Verify archive was cancelled
|
|
expect(console.log).toHaveBeenCalledWith('Archive cancelled.');
|
|
|
|
// Verify change was not archived
|
|
await expect(fs.access(changeDir)).resolves.not.toThrow();
|
|
});
|
|
});
|
|
}); |