Files
2025-08-19 19:48:54 +10:00

639 lines
24 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 (delta-based ADDED) and include change name in skeleton', 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 delta-based change spec (ADDED requirement)
const specContent = `# Test Capability Spec - Changes
## ADDED Requirements
### Requirement: 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 created from skeleton and ADDED requirement applied
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(updatedContent).toContain('# test-capability Specification');
expect(updatedContent).toContain('## Purpose');
expect(updatedContent).toContain(`created by archiving change ${changeName}`);
expect(updatedContent).toContain('## Requirements');
expect(updatedContent).toContain('### Requirement: The system SHALL provide test capability');
expect(updatedContent).toContain('#### Scenario: Basic test');
});
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}`));
});
it('should support header trim-only normalization for matching', async () => {
const changeName = 'normalize-headers';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'alpha');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create existing main spec with a requirement (no extra trailing spaces)
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'alpha');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# alpha Specification
## Purpose
Alpha purpose.
## Requirements
### Requirement: Important Rule
Some details.`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Change attempts to modify the same requirement but with trailing spaces after the name
const deltaContent = `# Alpha - Changes
## MODIFIED Requirements
### Requirement: Important Rule
Updated details.`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updated).toContain('### Requirement: Important Rule');
expect(updated).toContain('Updated details.');
});
it('should apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED', async () => {
const changeName = 'apply-order';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'beta');
await fs.mkdir(changeSpecDir, { recursive: true });
// Main spec with two requirements A and B
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'beta');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# beta Specification
## Purpose
Beta purpose.
## Requirements
### Requirement: A
content A
### Requirement: B
content B`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Rename A->C, Remove B, Modify C, Add D
const deltaContent = `# Beta - Changes
## RENAMED Requirements
- FROM: \`### Requirement: A\`
- TO: \`### Requirement: C\`
## REMOVED Requirements
### Requirement: B
## MODIFIED Requirements
### Requirement: C
updated C
## ADDED Requirements
### Requirement: D
content D`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updated).toContain('### Requirement: C');
expect(updated).toContain('updated C');
expect(updated).toContain('### Requirement: D');
expect(updated).not.toContain('### Requirement: A');
expect(updated).not.toContain('### Requirement: B');
});
it('should abort with error when MODIFIED/REMOVED reference non-existent requirements', async () => {
const changeName = 'validate-missing';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'gamma');
await fs.mkdir(changeSpecDir, { recursive: true });
// Main spec with no requirements
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'gamma');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# gamma Specification
## Purpose
Gamma purpose.
## Requirements`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Delta tries to modify and remove non-existent requirement
const deltaContent = `# Gamma - Changes
## MODIFIED Requirements
### Requirement: Missing
new text
## REMOVED Requirements
### Requirement: Another Missing`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Should not change the main spec and should not archive the change dir
const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(still).toBe(mainContent);
// Change dir should still exist since operation aborted
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
it('should require MODIFIED to reference the NEW header when a rename exists (error format)', async () => {
const changeName = 'rename-modify-new-header';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'delta');
await fs.mkdir(changeSpecDir, { recursive: true });
// Main spec with Old
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# delta Specification
## Purpose
Delta purpose.
## Requirements
### Requirement: Old
old body`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Delta: rename Old->New, but MODIFIED references Old (should abort)
const badDelta = `# Delta - Changes
## RENAMED Requirements
- FROM: \`### Requirement: Old\`
- TO: \`### Requirement: New\`
## MODIFIED Requirements
### Requirement: Old
new body`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), badDelta);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const unchanged = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(unchanged).toBe(mainContent);
// Assert error message format and abort notice
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('delta validation failed')
);
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Aborted. No files were changed.')
);
// Fix MODIFIED to reference New (should succeed)
const goodDelta = `# Delta - Changes
## RENAMED Requirements
- FROM: \`### Requirement: Old\`
- TO: \`### Requirement: New\`
## MODIFIED Requirements
### Requirement: New
new body`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), goodDelta);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updated).toContain('### Requirement: New');
expect(updated).toContain('new body');
expect(updated).not.toContain('### Requirement: Old');
});
it('should process multiple specs atomically (any failure aborts all)', async () => {
const changeName = 'multi-spec-atomic';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const spec1Dir = path.join(changeDir, 'specs', 'epsilon');
const spec2Dir = path.join(changeDir, 'specs', 'zeta');
await fs.mkdir(spec1Dir, { recursive: true });
await fs.mkdir(spec2Dir, { recursive: true });
// Existing main specs
const epsilonMain = path.join(tempDir, 'openspec', 'specs', 'epsilon', 'spec.md');
await fs.mkdir(path.dirname(epsilonMain), { recursive: true });
await fs.writeFile(epsilonMain, `# epsilon Specification
## Purpose
Epsilon purpose.
## Requirements
### Requirement: E1
e1`);
const zetaMain = path.join(tempDir, 'openspec', 'specs', 'zeta', 'spec.md');
await fs.mkdir(path.dirname(zetaMain), { recursive: true });
await fs.writeFile(zetaMain, `# zeta Specification
## Purpose
Zeta purpose.
## Requirements
### Requirement: Z1
z1`);
// Delta: epsilon is valid modification; zeta tries to remove non-existent -> should abort both
await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Epsilon - Changes
## MODIFIED Requirements
### Requirement: E1
E1 updated`);
await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Zeta - Changes
## REMOVED Requirements
### Requirement: Missing`);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const e1 = await fs.readFile(epsilonMain, 'utf-8');
const z1 = await fs.readFile(zetaMain, 'utf-8');
expect(e1).toContain('### Requirement: E1');
expect(e1).not.toContain('E1 updated');
expect(z1).toContain('### Requirement: Z1');
// changeDir should still exist
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
it('should display aggregated totals across multiple specs', async () => {
const changeName = 'multi-spec-totals';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const spec1Dir = path.join(changeDir, 'specs', 'omega');
const spec2Dir = path.join(changeDir, 'specs', 'psi');
await fs.mkdir(spec1Dir, { recursive: true });
await fs.mkdir(spec2Dir, { recursive: true });
// Existing main specs
const omegaMain = path.join(tempDir, 'openspec', 'specs', 'omega', 'spec.md');
await fs.mkdir(path.dirname(omegaMain), { recursive: true });
await fs.writeFile(omegaMain, `# omega Specification\n\n## Purpose\nOmega purpose.\n\n## Requirements\n\n### Requirement: O1\no1`);
const psiMain = path.join(tempDir, 'openspec', 'specs', 'psi', 'spec.md');
await fs.mkdir(path.dirname(psiMain), { recursive: true });
await fs.writeFile(psiMain, `# psi Specification\n\n## Purpose\nPsi purpose.\n\n## Requirements\n\n### Requirement: P1\np1`);
// Deltas: omega add one, psi rename and modify -> totals: +1, ~1, -0, →1
await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Omega - Changes\n\n## ADDED Requirements\n\n### Requirement: O2\nnew`);
await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Psi - Changes\n\n## RENAMED Requirements\n- FROM: \`### Requirement: P1\`\n- TO: \`### Requirement: P2\`\n\n## MODIFIED Requirements\n### Requirement: P2\nupdated`);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify aggregated totals line was printed
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Totals: + 1, ~ 1, - 0, → 1')
);
});
});
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 (values matter, names may include progress)
expect(mockSelect).toHaveBeenCalledWith(expect.objectContaining({
message: 'Select a change to archive',
choices: expect.arrayContaining([
expect.objectContaining({ value: change1 }),
expect.objectContaining({ 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();
});
});
});