mirror of
https://github.com/jparkerweb/plan2code.git
synced 2026-07-21 18:33:22 -07:00
196 lines
7.5 KiB
TypeScript
196 lines
7.5 KiB
TypeScript
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
|
|
import fs from 'fs-extra';
|
||
|
|
import os from 'os';
|
||
|
|
import path from 'path';
|
||
|
|
import { validateStepArtifacts, installSkillsForBot } from './cli.js';
|
||
|
|
|
||
|
|
let tmpDir: string;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p2c-cli-test-'));
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
fs.removeSync(tmpDir);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── validateStepArtifacts ───────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('validateStepArtifacts', () => {
|
||
|
|
describe('init', () => {
|
||
|
|
it('valid when AGENTS.md exists', () => {
|
||
|
|
fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents');
|
||
|
|
const result = validateStepArtifacts('init', tmpDir);
|
||
|
|
expect(result.valid).toBe(true);
|
||
|
|
expect(result.missing).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('missing when AGENTS.md does not exist', () => {
|
||
|
|
const result = validateStepArtifacts('init', tmpDir);
|
||
|
|
expect(result.valid).toBe(false);
|
||
|
|
expect(result.missing).toContain('AGENTS.md');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('plan', () => {
|
||
|
|
it('valid when specs/feature/PLAN-DRAFT-*.md exists', () => {
|
||
|
|
const specDir = path.join(tmpDir, 'specs', 'my-feature');
|
||
|
|
fs.ensureDirSync(specDir);
|
||
|
|
fs.writeFileSync(path.join(specDir, 'PLAN-DRAFT-v1.md'), '# Plan');
|
||
|
|
const result = validateStepArtifacts('plan', tmpDir);
|
||
|
|
expect(result.valid).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('missing when specs/ is absent', () => {
|
||
|
|
const result = validateStepArtifacts('plan', tmpDir);
|
||
|
|
expect(result.valid).toBe(false);
|
||
|
|
expect(result.missing).toContain('specs/ directory');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('missing when specs/ has dirs but no plan draft files', () => {
|
||
|
|
const specDir = path.join(tmpDir, 'specs', 'my-feature');
|
||
|
|
fs.ensureDirSync(specDir);
|
||
|
|
fs.writeFileSync(path.join(specDir, 'notes.md'), '# Notes');
|
||
|
|
const result = validateStepArtifacts('plan', tmpDir);
|
||
|
|
expect(result.valid).toBe(false);
|
||
|
|
expect(result.missing).toContain('specs/*/PLAN-DRAFT-*.md');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('document', () => {
|
||
|
|
it('valid when overview.md and phase-*.md exist', () => {
|
||
|
|
const specDir = path.join(tmpDir, 'specs', 'my-feature');
|
||
|
|
fs.ensureDirSync(specDir);
|
||
|
|
fs.writeFileSync(path.join(specDir, 'overview.md'), '# Overview');
|
||
|
|
fs.writeFileSync(path.join(specDir, 'phase-1.md'), '# Phase 1');
|
||
|
|
const result = validateStepArtifacts('document', tmpDir);
|
||
|
|
expect(result.valid).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('missing overview.md when absent', () => {
|
||
|
|
const specDir = path.join(tmpDir, 'specs', 'my-feature');
|
||
|
|
fs.ensureDirSync(specDir);
|
||
|
|
fs.writeFileSync(path.join(specDir, 'phase-1.md'), '# Phase 1');
|
||
|
|
const result = validateStepArtifacts('document', tmpDir);
|
||
|
|
expect(result.valid).toBe(false);
|
||
|
|
expect(result.missing).toContain('specs/*/overview.md');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('missing phase files when absent', () => {
|
||
|
|
const specDir = path.join(tmpDir, 'specs', 'my-feature');
|
||
|
|
fs.ensureDirSync(specDir);
|
||
|
|
fs.writeFileSync(path.join(specDir, 'overview.md'), '# Overview');
|
||
|
|
const result = validateStepArtifacts('document', tmpDir);
|
||
|
|
expect(result.valid).toBe(false);
|
||
|
|
expect(result.missing).toContain('specs/*/phase-*.md files');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('accepts phase files inside phases/ subdirectory', () => {
|
||
|
|
const specDir = path.join(tmpDir, 'specs', 'my-feature');
|
||
|
|
const phasesDir = path.join(specDir, 'phases');
|
||
|
|
fs.ensureDirSync(phasesDir);
|
||
|
|
fs.writeFileSync(path.join(specDir, 'overview.md'), '# Overview');
|
||
|
|
fs.writeFileSync(path.join(phasesDir, 'phase-1.md'), '# Phase 1');
|
||
|
|
const result = validateStepArtifacts('document', tmpDir);
|
||
|
|
expect(result.valid).toBe(true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('finalize', () => {
|
||
|
|
it('valid when specs--completed/ has a subdirectory', () => {
|
||
|
|
fs.ensureDirSync(path.join(tmpDir, 'specs--completed', 'my-feature'));
|
||
|
|
const result = validateStepArtifacts('finalize', tmpDir);
|
||
|
|
expect(result.valid).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('missing when specs--completed/ is absent', () => {
|
||
|
|
const result = validateStepArtifacts('finalize', tmpDir);
|
||
|
|
expect(result.valid).toBe(false);
|
||
|
|
expect(result.missing).toContain('specs--completed/ directory');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('missing when specs--completed/ is empty', () => {
|
||
|
|
fs.ensureDirSync(path.join(tmpDir, 'specs--completed'));
|
||
|
|
const result = validateStepArtifacts('finalize', tmpDir);
|
||
|
|
expect(result.valid).toBe(false);
|
||
|
|
expect(result.missing).toContain('archived spec in specs--completed/');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── installSkillsForBot ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('installSkillsForBot', () => {
|
||
|
|
let fakeHome: string;
|
||
|
|
let projectDir: string;
|
||
|
|
let origHome: string | undefined;
|
||
|
|
let origUserProfile: string | undefined;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'p2c-home-'));
|
||
|
|
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p2c-proj-'));
|
||
|
|
origHome = process.env.HOME;
|
||
|
|
origUserProfile = process.env.USERPROFILE;
|
||
|
|
process.env.HOME = fakeHome;
|
||
|
|
process.env.USERPROFILE = fakeHome;
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
process.env.HOME = origHome;
|
||
|
|
process.env.USERPROFILE = origUserProfile;
|
||
|
|
fs.removeSync(fakeHome);
|
||
|
|
fs.removeSync(projectDir);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('copies skills from user dir to project .claude/skills/', () => {
|
||
|
|
const srcDir = path.join(fakeHome, '.claude', 'skills', 'plan2code-init');
|
||
|
|
fs.ensureDirSync(srcDir);
|
||
|
|
fs.writeFileSync(path.join(srcDir, 'SKILL.md'), '---\nname: init\n---\nSome content');
|
||
|
|
|
||
|
|
installSkillsForBot(projectDir);
|
||
|
|
|
||
|
|
const dest = path.join(projectDir, '.claude', 'skills', 'plan2code-init', 'SKILL.md');
|
||
|
|
expect(fs.existsSync(dest)).toBe(true);
|
||
|
|
expect(fs.readFileSync(dest, 'utf-8')).toContain('Some content');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('strips disable-model-invocation: true from copied SKILL.md', () => {
|
||
|
|
const srcDir = path.join(fakeHome, '.claude', 'skills', 'plan2code-1-plan');
|
||
|
|
fs.ensureDirSync(srcDir);
|
||
|
|
fs.writeFileSync(
|
||
|
|
path.join(srcDir, 'SKILL.md'),
|
||
|
|
'disable-model-invocation: true\n---\nname: plan\n---\nPlan content',
|
||
|
|
);
|
||
|
|
|
||
|
|
installSkillsForBot(projectDir);
|
||
|
|
|
||
|
|
const dest = path.join(projectDir, '.claude', 'skills', 'plan2code-1-plan', 'SKILL.md');
|
||
|
|
const content = fs.readFileSync(dest, 'utf-8');
|
||
|
|
expect(content).not.toContain('disable-model-invocation');
|
||
|
|
expect(content).toContain('Plan content');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves rest of skill content', () => {
|
||
|
|
const srcDir = path.join(fakeHome, '.claude', 'skills', 'plan2code-2-document');
|
||
|
|
fs.ensureDirSync(srcDir);
|
||
|
|
const original = 'disable-model-invocation: true\n---\nname: doc\n---\nLine 1\nLine 2\nLine 3';
|
||
|
|
fs.writeFileSync(path.join(srcDir, 'SKILL.md'), original);
|
||
|
|
|
||
|
|
installSkillsForBot(projectDir);
|
||
|
|
|
||
|
|
const dest = path.join(projectDir, '.claude', 'skills', 'plan2code-2-document', 'SKILL.md');
|
||
|
|
const content = fs.readFileSync(dest, 'utf-8');
|
||
|
|
expect(content).toContain('Line 1');
|
||
|
|
expect(content).toContain('Line 2');
|
||
|
|
expect(content).toContain('Line 3');
|
||
|
|
expect(content).toContain('name: doc');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('skips gracefully when source skill does not exist', () => {
|
||
|
|
// No skills in fakeHome — should not throw
|
||
|
|
expect(() => installSkillsForBot(projectDir)).not.toThrow();
|
||
|
|
// No .claude/skills/ created in project
|
||
|
|
expect(fs.existsSync(path.join(projectDir, '.claude', 'skills'))).toBe(false);
|
||
|
|
});
|
||
|
|
});
|