merge: sync Kilo runtime branch with latest main
This commit is contained in:
171
tests/augment-conversion.test.cjs
Normal file
171
tests/augment-conversion.test.cjs
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Augment conversion regression tests.
|
||||
*
|
||||
* Ensures Augment frontmatter names are emitted as plain identifiers
|
||||
* (without surrounding quotes), so Augment does not treat quotes as
|
||||
* literal parts of skill/subagent names.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToAugmentSkill,
|
||||
convertClaudeAgentToAugmentAgent,
|
||||
convertClaudeToAugmentMarkdown,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
describe('convertClaudeCommandToAugmentSkill', () => {
|
||||
test('writes unquoted Augment skill name in frontmatter', () => {
|
||||
const input = `---
|
||||
name: quick
|
||||
description: Execute a quick task
|
||||
---
|
||||
|
||||
<objective>
|
||||
Test body
|
||||
</objective>
|
||||
`;
|
||||
|
||||
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-quick');
|
||||
const nameMatch = result.match(/^name:\s*(.+)$/m);
|
||||
|
||||
assert.ok(nameMatch, 'frontmatter contains name field');
|
||||
assert.strictEqual(nameMatch[1], 'gsd-quick', 'skill name is plain scalar');
|
||||
assert.ok(!result.includes('name: "gsd-quick"'), 'quoted skill name is not emitted');
|
||||
});
|
||||
|
||||
test('preserves slash for slash commands in markdown body', () => {
|
||||
const input = `---
|
||||
name: gsd:plan-phase
|
||||
description: Plan a phase
|
||||
---
|
||||
|
||||
Next:
|
||||
/gsd:execute-phase 17
|
||||
/gsd-help
|
||||
gsd:progress
|
||||
`;
|
||||
|
||||
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-plan-phase');
|
||||
// Slash commands: /gsd:execute-phase -> /gsd-execute-phase
|
||||
assert.ok(result.includes('/gsd-execute-phase 17'), 'slash command gsd: -> gsd-');
|
||||
assert.ok(result.includes('/gsd-help'), '/gsd-help preserved');
|
||||
assert.ok(result.includes('gsd-progress'), 'bare gsd: -> gsd-');
|
||||
});
|
||||
|
||||
test('includes augment_skill_adapter block', () => {
|
||||
const input = `---
|
||||
name: test
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Body content.
|
||||
`;
|
||||
|
||||
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-test');
|
||||
assert.ok(result.includes('<augment_skill_adapter>'), 'adapter header present');
|
||||
assert.ok(result.includes('</augment_skill_adapter>'), 'adapter footer present');
|
||||
assert.ok(result.includes('launch-process'), 'launch-process tool mentioned');
|
||||
assert.ok(result.includes('str-replace-editor'), 'str-replace-editor tool mentioned');
|
||||
});
|
||||
|
||||
test('converts tool names to Augment format', () => {
|
||||
const input = `---
|
||||
name: test
|
||||
description: Test
|
||||
---
|
||||
|
||||
Use Bash() to run commands.
|
||||
Use Edit() to modify files.
|
||||
Use Read() to view files.
|
||||
`;
|
||||
|
||||
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-test');
|
||||
assert.ok(result.includes('launch-process('), 'Bash converted to launch-process');
|
||||
assert.ok(result.includes('str-replace-editor('), 'Edit converted to str-replace-editor');
|
||||
assert.ok(result.includes('view('), 'Read converted to view');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertClaudeAgentToAugmentAgent', () => {
|
||||
test('converts agent frontmatter with unquoted name', () => {
|
||||
const input = `---
|
||||
name: gsd-bugfix
|
||||
description: "Fix bugs automatically"
|
||||
tools: Read, Write, Edit, Bash
|
||||
color: green
|
||||
---
|
||||
|
||||
<role>
|
||||
You are a bug fixer.
|
||||
</role>
|
||||
`;
|
||||
|
||||
const result = convertClaudeAgentToAugmentAgent(input);
|
||||
const nameMatch = result.match(/^name:\s*(.+)$/m);
|
||||
|
||||
assert.ok(nameMatch, 'frontmatter contains name field');
|
||||
assert.strictEqual(nameMatch[1], 'gsd-bugfix', 'agent name is plain scalar');
|
||||
assert.ok(!result.includes('name: "gsd-bugfix"'), 'quoted agent name is not emitted');
|
||||
});
|
||||
|
||||
test('removes color and skills from frontmatter', () => {
|
||||
const input = `---
|
||||
name: gsd-test
|
||||
description: Test agent
|
||||
color: blue
|
||||
skills:
|
||||
- some-skill
|
||||
tools: Read, Write
|
||||
---
|
||||
|
||||
<role>
|
||||
Test role
|
||||
</role>
|
||||
`;
|
||||
|
||||
const result = convertClaudeAgentToAugmentAgent(input);
|
||||
|
||||
assert.ok(result.includes('name: gsd-test'), 'name preserved');
|
||||
assert.ok(result.includes('description:'), 'description preserved');
|
||||
assert.ok(!result.includes('color:'), 'color removed');
|
||||
assert.ok(!result.includes('skills:'), 'skills removed');
|
||||
});
|
||||
|
||||
test('replaces CLAUDE.md with .augment/rules/', () => {
|
||||
const input = `---
|
||||
name: gsd-test
|
||||
description: Test
|
||||
---
|
||||
|
||||
See CLAUDE.md for details.
|
||||
`;
|
||||
|
||||
const result = convertClaudeAgentToAugmentAgent(input);
|
||||
assert.ok(result.includes('.augment/rules/'), 'CLAUDE.md replaced with .augment/rules/');
|
||||
assert.ok(!result.includes('CLAUDE.md'), 'CLAUDE.md reference removed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertClaudeToAugmentMarkdown', () => {
|
||||
test('replaces Claude Code with Augment', () => {
|
||||
const input = 'Use Claude Code tools for this task.';
|
||||
const result = convertClaudeToAugmentMarkdown(input);
|
||||
assert.strictEqual(result, 'Use Augment tools for this task.');
|
||||
});
|
||||
|
||||
test('replaces .claude/skills/ with .augment/skills/', () => {
|
||||
const input = 'Check .claude/skills/ for more info.';
|
||||
const result = convertClaudeToAugmentMarkdown(input);
|
||||
assert.strictEqual(result, 'Check .augment/skills/ for more info.');
|
||||
});
|
||||
|
||||
test('normalizes gsd: to gsd-', () => {
|
||||
const input = 'Run /gsd:new-project for new projects.';
|
||||
const result = convertClaudeToAugmentMarkdown(input);
|
||||
assert.strictEqual(result, 'Run /gsd-new-project for new projects.');
|
||||
});
|
||||
});
|
||||
84
tests/autonomous-interactive.test.cjs
Normal file
84
tests/autonomous-interactive.test.cjs
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* GSD Tools Tests - autonomous --interactive flag
|
||||
*
|
||||
* Validates that the autonomous workflow and command definition
|
||||
* correctly document and support the --interactive flag.
|
||||
*
|
||||
* Closes: #1413
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('autonomous --interactive flag (#1413)', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'autonomous.md');
|
||||
const commandPath = path.join(__dirname, '..', 'commands', 'gsd', 'autonomous.md');
|
||||
|
||||
test('command definition includes --interactive in argument-hint', () => {
|
||||
const content = fs.readFileSync(commandPath, 'utf8');
|
||||
assert.ok(content.includes('--interactive'), 'command should document --interactive flag');
|
||||
assert.ok(content.includes('argument-hint:') && content.includes('--interactive'),
|
||||
'argument-hint should include --interactive');
|
||||
});
|
||||
|
||||
test('command definition describes interactive mode behavior', () => {
|
||||
const content = fs.readFileSync(commandPath, 'utf8');
|
||||
assert.ok(content.includes('discuss') && content.includes('inline'),
|
||||
'command should describe discuss running inline');
|
||||
assert.ok(content.includes('background'),
|
||||
'command should mention background agents for plan+execute');
|
||||
});
|
||||
|
||||
test('workflow parses --interactive flag', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
assert.ok(content.includes("--interactive") && content.includes('INTERACTIVE'),
|
||||
'workflow should parse --interactive into INTERACTIVE variable');
|
||||
});
|
||||
|
||||
test('workflow uses discuss-phase skill in interactive mode', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('gsd:discuss-phase') && content.includes('INTERACTIVE'),
|
||||
'workflow should invoke gsd:discuss-phase when INTERACTIVE is set'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow dispatches plan as background agent in interactive mode', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
// Should have Agent() with run_in_background for plan
|
||||
assert.ok(
|
||||
content.includes('run_in_background') && content.includes('plan-phase'),
|
||||
'workflow should dispatch plan-phase as background agent in interactive mode'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow dispatches execute as background agent in interactive mode', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('run_in_background') && content.includes('execute-phase'),
|
||||
'workflow should dispatch execute-phase as background agent in interactive mode'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow describes pipeline parallelism in interactive mode', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('pipeline parallelism') || content.includes('Phase N+1'),
|
||||
'workflow should describe overlapping discuss/execute between phases'
|
||||
);
|
||||
});
|
||||
|
||||
test('success criteria include --interactive requirements', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
const criteriaMatch = content.match(/<success_criteria>([\s\S]*?)<\/success_criteria>/);
|
||||
const criteria = criteriaMatch ? criteriaMatch[1] : '';
|
||||
assert.ok(criteria.includes('--interactive'),
|
||||
'success criteria should include --interactive requirements');
|
||||
assert.ok(criteria.includes('discuss inline'),
|
||||
'success criteria should mention discuss inline');
|
||||
assert.ok(criteria.includes('background agents'),
|
||||
'success criteria should mention background agents');
|
||||
});
|
||||
});
|
||||
@@ -1694,3 +1694,60 @@ describe('stats command', () => {
|
||||
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// check-commit command (#1395)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('check-commit command', () => {
|
||||
const { createTempGitProject } = require('./helpers.cjs');
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempGitProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('allows commit when commit_docs is true', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ commit_docs: true })
|
||||
);
|
||||
const result = runGsdTools('check-commit', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.allowed, true);
|
||||
});
|
||||
|
||||
test('allows commit when no .planning/ files staged and commit_docs is false', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ commit_docs: false })
|
||||
);
|
||||
// Stage a non-planning file
|
||||
fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")');
|
||||
execSync('git add src.js', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
const result = runGsdTools('check-commit', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.allowed, true);
|
||||
});
|
||||
|
||||
test('blocks commit when .planning/ files staged and commit_docs is false', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ commit_docs: false })
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
|
||||
execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
const result = runGsdTools('check-commit', tmpDir);
|
||||
assert.ok(!result.success, 'should block commit');
|
||||
assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files');
|
||||
assert.ok(result.error.includes('unstage'), 'error should suggest unstage command');
|
||||
});
|
||||
});
|
||||
|
||||
819
tests/concurrency-safety.test.cjs
Normal file
819
tests/concurrency-safety.test.cjs
Normal file
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* GSD Tools Tests - Concurrency Safety
|
||||
*
|
||||
* Tests for fix/concurrency-safety-1473a:
|
||||
* - Planning lock integration (withPlanningLock in phase/roadmap operations)
|
||||
* - readModifyWriteStateMd (atomic state updates)
|
||||
* - normalizeMd behavioral equivalence (O(n) insideFence rewrite)
|
||||
* - Warnings (frontmatter parse warning, stateReplaceFieldWithFallback)
|
||||
* - Performance benchmarks (normalizeMd O(n) verification)
|
||||
* - Snapshot tests for normalizeMd (regression detection)
|
||||
* - Multi-process concurrent write tests
|
||||
* - Stress tests at scale (50+ phases)
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execSync, exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const { performance } = require('perf_hooks');
|
||||
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
normalizeMd,
|
||||
} = require('../get-shit-done/bin/lib/core.cjs');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
|
||||
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n${lines}\n`
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalStateMd(tmpDir, content) {
|
||||
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
defaultContent
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalProjectMd(tmpDir) {
|
||||
const sections = ['## What This Is', '## Core Value', '## Requirements'];
|
||||
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
`# Project\n\n${content}`
|
||||
);
|
||||
}
|
||||
|
||||
function writeValidConfigJson(tmpDir, overrides = {}) {
|
||||
const base = { model_profile: 'balanced', commit_docs: true };
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ ...base, ...overrides }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 50-phase project structure for stress testing.
|
||||
*/
|
||||
function create50PhaseProject(tmpDir, completedCount = 25) {
|
||||
let roadmapContent = '# Roadmap v1.0\n\n';
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
roadmapContent += `- [${i <= completedCount ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
|
||||
}
|
||||
roadmapContent += '\n';
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const pad = String(i).padStart(2, '0');
|
||||
roadmapContent += `### Phase ${i}: Feature ${i}\n\n`;
|
||||
roadmapContent += `**Goal:** Build feature ${i}\n`;
|
||||
roadmapContent += `**Requirements:** REQ-${pad}\n`;
|
||||
roadmapContent += `**Plans:** 1 plans\n\n`;
|
||||
roadmapContent += `Plans:\n- [${i <= completedCount ? 'x' : ' '}] ${pad}-01-PLAN.md\n\n`;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
roadmapContent
|
||||
);
|
||||
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const pad = String(i).padStart(2, '0');
|
||||
const dirName = `${pad}-feature-${i}`;
|
||||
const phaseDir = path.join(phasesDir, dirName);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, `${pad}-01-PLAN.md`),
|
||||
`# Phase ${i} Plan 1\n\nBuild feature ${i}.\n`
|
||||
);
|
||||
if (i <= completedCount) {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, `${pad}-01-SUMMARY.md`),
|
||||
`# Phase ${i} Plan 1 Summary\n\nFeature ${i} completed.\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Planning lock integration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('planning lock integration', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('phase add creates and releases .planning/.lock during ROADMAP write', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase add Testing', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', '.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase add');
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_number, 2, 'should be phase 2');
|
||||
});
|
||||
|
||||
test('phase complete creates and releases .planning/.lock', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n- [ ] Phase 1: Foundation\n\n### Phase 1: Foundation\n**Goal:** Setup\n**Plans:** 1 plans\n\n### Phase 2: API\n**Goal:** Build\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
||||
);
|
||||
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', '.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase complete');
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.completed_phase, '1', 'phase should be completed');
|
||||
});
|
||||
|
||||
test('roadmap update-plan-progress creates and releases .planning/.lock', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n| Phase | Plans | Status | Updated |\n|-------|-------|--------|---------|\n| 1 | 0/0 | Not started | - |\n\n### Phase 1: Foundation\n**Goal:** Setup\n`
|
||||
);
|
||||
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', '.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after roadmap update');
|
||||
});
|
||||
|
||||
test('lock file does NOT persist after successful phase operations', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0\n`
|
||||
);
|
||||
|
||||
runGsdTools('phase add First Phase', tmpDir);
|
||||
runGsdTools('phase add Second Phase', tmpDir);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', '.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), '.lock file should not persist after multiple operations');
|
||||
});
|
||||
|
||||
test('phase add still works correctly with lock (behavioral regression)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n\n---\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase add User Dashboard', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_number, 3, 'should be phase 3');
|
||||
assert.strictEqual(output.slug, 'user-dashboard');
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')),
|
||||
'directory should be created'
|
||||
);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. readModifyWriteStateMd (tested via CLI commands that use it)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('readModifyWriteStateMd (via state patch)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('transforms content atomically (read + modify + write under lock)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 03\n**Status:** Planning\n**Current Plan:** 03-01\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('state patch --Status "In progress" --"Current Plan" 03-02', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(content.includes('**Status:** In progress'), 'Status should be updated');
|
||||
assert.ok(content.includes('03-02'), 'Current Plan should be updated');
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should be released after patch');
|
||||
});
|
||||
|
||||
test('lock file cleaned up after state patch operation', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 01\n**Status:** Ready\n`
|
||||
);
|
||||
|
||||
runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after operation');
|
||||
});
|
||||
|
||||
test('state patch still works correctly via readModifyWriteStateMd path (behavioral regression)', () => {
|
||||
const stateMd = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 03',
|
||||
'**Status:** Planning',
|
||||
'**Current Plan:** 03-01',
|
||||
'**Last Activity:** 2024-01-15',
|
||||
].join('\n') + '\n';
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||||
|
||||
const result = runGsdTools('state patch --Status Complete --"Current Phase" 04', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(updated.includes('**Status:** Complete'), 'Status should be updated to Complete');
|
||||
assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged');
|
||||
});
|
||||
|
||||
test('two sequential state patches both persist (patch A then patch B)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n**Last Activity:** 2024-01-01\n`
|
||||
);
|
||||
|
||||
const resultA = runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
assert.ok(resultA.success, `Patch A failed: ${resultA.error}`);
|
||||
|
||||
const resultB = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
|
||||
assert.ok(resultB.success, `Patch B failed: ${resultB.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(content.includes('**Status:** In progress'), 'Patch A (Status) should persist');
|
||||
assert.ok(content.includes('01-02'), 'Patch B (Current Plan) should persist');
|
||||
assert.ok(content.includes('**Last Activity:** 2024-01-01'), 'Untouched field should be preserved');
|
||||
});
|
||||
|
||||
test('lock file does not persist after rapid sequential patches', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n`
|
||||
);
|
||||
|
||||
runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
|
||||
runGsdTools('state patch --Status Complete', tmpDir);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after rapid sequential patches');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Multi-process concurrent write tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('multi-process concurrent write tests', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('two concurrent state patches to DIFFERENT fields both persist', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 01',
|
||||
'**Status:** In progress',
|
||||
'**Current Plan:** 01-01',
|
||||
'**Last Activity:** 2025-01-01',
|
||||
'**Last Activity Description:** Working',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const toolsPath = TOOLS_PATH;
|
||||
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
|
||||
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
execAsync(cmdA, { encoding: 'utf-8' }).catch(e => e),
|
||||
execAsync(cmdB, { encoding: 'utf-8' }).catch(e => e),
|
||||
]);
|
||||
|
||||
const aOk = !(resultA instanceof Error);
|
||||
const bOk = !(resultB instanceof Error);
|
||||
assert.ok(aOk || bOk, 'At least one concurrent patch should succeed');
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
content.includes('Complete') || content.includes('01-02'),
|
||||
`At least one concurrent patch should persist in STATE.md. Content:\n${content}`
|
||||
);
|
||||
|
||||
if (content.includes('Complete') && content.includes('01-02')) {
|
||||
assert.ok(true, 'Both concurrent patches persisted (lock serialization)');
|
||||
}
|
||||
|
||||
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field Current Phase should survive');
|
||||
assert.ok(content.includes('2025-01-01'), 'Untouched field Last Activity should survive');
|
||||
});
|
||||
|
||||
test('lock file does not persist after concurrent operations', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 01',
|
||||
'**Status:** Planning',
|
||||
'**Current Plan:** 01-01',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const toolsPath = TOOLS_PATH;
|
||||
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
|
||||
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
|
||||
|
||||
await Promise.all([
|
||||
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
|
||||
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
|
||||
]);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
|
||||
assert.ok(
|
||||
!fs.existsSync(lockPath),
|
||||
'STATE.md.lock should not persist after concurrent operations complete'
|
||||
);
|
||||
});
|
||||
|
||||
test('three rapid sequential patches all persist', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 01',
|
||||
'**Status:** Planning',
|
||||
'**Current Plan:** 01-01',
|
||||
'**Last Activity:** 2025-01-01',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const r1 = runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
assert.ok(r1.success, `Patch 1 failed: ${r1.error}`);
|
||||
|
||||
const r2 = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
|
||||
assert.ok(r2.success, `Patch 2 failed: ${r2.error}`);
|
||||
|
||||
const r3 = runGsdTools('state patch --"Last Activity" 2025-06-15', tmpDir);
|
||||
assert.ok(r3.success, `Patch 3 failed: ${r3.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(content.includes('In progress'), 'Patch 1 (Status) should persist');
|
||||
assert.ok(content.includes('01-02'), 'Patch 2 (Current Plan) should persist');
|
||||
assert.ok(content.includes('2025-06-15'), 'Patch 3 (Last Activity) should persist');
|
||||
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field should be preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. normalizeMd behavioral equivalence (O(n) insideFence rewrite)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeMd behavioral equivalence', () => {
|
||||
test('simple markdown with headings and paragraphs', () => {
|
||||
const input = '# Title\nSome text.\n## Section\nMore text.\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('# Title\n\nSome text.'), 'title heading should have blank line after');
|
||||
assert.ok(result.includes('\n\n## Section\n\nMore text.'), 'section heading should have blank lines around it');
|
||||
assert.ok(result.endsWith('\n'), 'should end with newline');
|
||||
assert.ok(!result.endsWith('\n\n'), 'should not end with double newline');
|
||||
});
|
||||
|
||||
test('single fenced code block gets blank lines before/after', () => {
|
||||
const input = 'Some text\n```js\nconst x = 1;\n```\nMore text\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('Some text\n\n```js'), 'code block should have blank line before');
|
||||
assert.ok(result.includes('```\n\nMore text'), 'code block should have blank line after');
|
||||
assert.ok(result.includes('const x = 1;'), 'code content should be preserved');
|
||||
});
|
||||
|
||||
test('multiple fenced code blocks', () => {
|
||||
const input = 'Intro\n```js\nfoo();\n```\nMiddle\n```py\nbar()\n```\nEnd\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('Intro\n\n```js'), 'first code block should have blank line before');
|
||||
assert.ok(result.includes('```\n\nMiddle'), 'first code block should have blank line after');
|
||||
assert.ok(result.includes('Middle\n\n```py'), 'second code block should have blank line before');
|
||||
assert.ok(result.includes('```\n\nEnd'), 'second code block should have blank line after');
|
||||
});
|
||||
|
||||
test('unclosed fence at end of file (edge case)', () => {
|
||||
const input = 'Some text\n```js\nconst x = 1;\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(typeof result === 'string', 'should return a string');
|
||||
assert.ok(result.includes('```js'), 'fence opener should be preserved');
|
||||
assert.ok(result.includes('const x = 1;'), 'content after unclosed fence should be preserved');
|
||||
assert.ok(result.endsWith('\n'), 'should end with newline');
|
||||
});
|
||||
|
||||
test('empty string input', () => {
|
||||
assert.strictEqual(normalizeMd(''), '', 'empty string should return empty string');
|
||||
});
|
||||
|
||||
test('mixed headings + lists + fences (complex case)', () => {
|
||||
const input = [
|
||||
'# Title',
|
||||
'## Section One',
|
||||
'Paragraph text.',
|
||||
'- item 1',
|
||||
'- item 2',
|
||||
'## Section Two',
|
||||
'```bash',
|
||||
'echo hello',
|
||||
'```',
|
||||
'After code.',
|
||||
'## Section Three',
|
||||
'1. First',
|
||||
'2. Second',
|
||||
'Done.',
|
||||
].join('\n') + '\n';
|
||||
|
||||
const result = normalizeMd(input);
|
||||
|
||||
assert.ok(result.includes('\n\n## Section One\n\n'), 'Section One heading needs blank lines');
|
||||
assert.ok(result.includes('\n\n## Section Two\n\n'), 'Section Two heading needs blank lines');
|
||||
assert.ok(result.includes('\n\n## Section Three\n\n'), 'Section Three heading needs blank lines');
|
||||
assert.ok(result.includes('Paragraph text.\n\n- item 1'), 'list should have blank line before');
|
||||
assert.ok(result.includes('\n\n```bash'), 'code block should have blank line before');
|
||||
assert.ok(result.includes('```\n\nAfter code.'), 'code block should have blank line after');
|
||||
assert.ok(result.includes('echo hello'), 'code content should be preserved');
|
||||
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. normalizeMd performance benchmark
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeMd performance benchmark', () => {
|
||||
test('processes a 100-line markdown file in under 50ms', () => {
|
||||
const lines = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (i % 20 === 0) {
|
||||
lines.push(`## Section ${i / 20 + 1}`);
|
||||
} else if (i % 30 === 0) {
|
||||
lines.push('```js');
|
||||
lines.push(`const x${i} = ${i};`);
|
||||
lines.push('```');
|
||||
} else if (i % 5 === 0) {
|
||||
lines.push(`- List item ${i}`);
|
||||
} else {
|
||||
lines.push(`Paragraph text line ${i} with some content to process.`);
|
||||
}
|
||||
}
|
||||
const input = lines.join('\n') + '\n';
|
||||
|
||||
const start = performance.now();
|
||||
const result = normalizeMd(input);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert.ok(typeof result === 'string', 'should return a string');
|
||||
assert.ok(result.length > 0, 'result should not be empty');
|
||||
assert.ok(result.endsWith('\n'), 'result should end with newline');
|
||||
assert.ok(elapsed < 50, `100-line file should process in under 50ms, took ${elapsed.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
test('processes a 1000-line markdown file with 20 code blocks in under 200ms', () => {
|
||||
const lines = [];
|
||||
let codeBlockCount = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
if (i % 50 === 0 && codeBlockCount < 20) {
|
||||
lines.push(`## Section ${codeBlockCount + 1}`);
|
||||
lines.push('');
|
||||
lines.push('Some introductory text for this section.');
|
||||
lines.push('');
|
||||
lines.push('```python');
|
||||
for (let j = 0; j < 5; j++) {
|
||||
lines.push(` result_${codeBlockCount}_${j} = compute(${j})`);
|
||||
}
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
lines.push('Explanation of the code above.');
|
||||
codeBlockCount++;
|
||||
} else if (i % 10 === 0) {
|
||||
lines.push(`### Subsection at line ${i}`);
|
||||
} else if (i % 7 === 0) {
|
||||
lines.push(`- Item ${i}: description of this list item`);
|
||||
} else if (i % 13 === 0) {
|
||||
lines.push(`1. Ordered item ${i}`);
|
||||
} else {
|
||||
lines.push(`Line ${i}: Regular paragraph content with various markdown elements.`);
|
||||
}
|
||||
}
|
||||
const input = lines.join('\n') + '\n';
|
||||
|
||||
normalizeMd(input); // warm up JIT
|
||||
|
||||
const start = performance.now();
|
||||
const result = normalizeMd(input);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert.ok(typeof result === 'string', 'should return a string');
|
||||
assert.ok(result.length > 0, 'result should not be empty');
|
||||
assert.ok(result.endsWith('\n'), 'result should end with newline');
|
||||
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
|
||||
assert.ok(elapsed < 200, `1000-line file with 20 code blocks should process in under 200ms, took ${elapsed.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 6. normalizeMd snapshot tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeMd snapshot tests', () => {
|
||||
test('snapshot - heading spacing', () => {
|
||||
const input = '# Title\nParagraph\n## Section\nMore text';
|
||||
const expected = '# Title\n\nParagraph\n\n## Section\n\nMore text\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.strictEqual(result, expected,
|
||||
`Heading spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot - code block spacing', () => {
|
||||
const input = 'Text before\n```js\nconst x = 1;\n```\nText after\n';
|
||||
const expected = 'Text before\n\n```js\nconst x = 1;\n```\n\nText after\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.strictEqual(result, expected,
|
||||
`Code block spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot - list spacing', () => {
|
||||
const input = 'Paragraph\n- item 1\n- item 2\nAnother paragraph';
|
||||
const expected = 'Paragraph\n\n- item 1\n- item 2\n\nAnother paragraph\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.strictEqual(result, expected,
|
||||
`List spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot - complex mixed document', () => {
|
||||
const input = [
|
||||
'# Main Title',
|
||||
'Intro paragraph.',
|
||||
'## Section One',
|
||||
'Some text here.',
|
||||
'```js',
|
||||
'const a = 1;',
|
||||
'```',
|
||||
'- first item',
|
||||
'- second item',
|
||||
'## Section Two',
|
||||
'Final text.',
|
||||
].join('\n');
|
||||
|
||||
const expected = [
|
||||
'# Main Title',
|
||||
'',
|
||||
'Intro paragraph.',
|
||||
'',
|
||||
'## Section One',
|
||||
'',
|
||||
'Some text here.',
|
||||
'',
|
||||
'```js',
|
||||
'const a = 1;',
|
||||
'```',
|
||||
'',
|
||||
'- first item',
|
||||
'- second item',
|
||||
'',
|
||||
'## Section Two',
|
||||
'',
|
||||
'Final text.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = normalizeMd(input);
|
||||
assert.strictEqual(result, expected,
|
||||
`Complex mixed document snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 7. Warnings (frontmatter parse, state field miss)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('warnings', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('must_haves parse warning fires for block with content but 0 items', () => {
|
||||
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(planDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planDir, '01-01-PLAN.md'),
|
||||
`---
|
||||
phase: "01"
|
||||
plan: "01"
|
||||
must_haves:
|
||||
acceptance:
|
||||
bare content without dash prefix
|
||||
another line without dash prefix
|
||||
---
|
||||
|
||||
# Plan 01-01
|
||||
`
|
||||
);
|
||||
|
||||
const result = runGsdTools(
|
||||
['frontmatter', 'get', path.join(planDir, '01-01-PLAN.md'), 'must_haves'],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
const stderr = result.error || '';
|
||||
assert.ok(
|
||||
stderr.includes('WARNING') && stderr.includes('must_haves') ||
|
||||
result.output.includes('acceptance'),
|
||||
`Expected WARNING about must_haves parse or valid parse result. stderr: ${stderr}, stdout: ${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
test('stateReplaceFieldWithFallback logs warning on miss', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('state advance-plan', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 8. Malformed input resilience
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('malformed input resilience', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('STATE.md with invalid bold format -- state patch returns gracefully', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'# Project State\n\n**Current Phase: 01\n**Status:** Planning\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
const didNotCrash = result.success || (result.output !== undefined);
|
||||
assert.ok(didNotCrash, `state patch should not crash on malformed bold format: ${result.error}`);
|
||||
|
||||
if (result.success) {
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('In progress'),
|
||||
'Status field (with valid bold format) should be updated'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('STATE.md with only frontmatter, no body -- state patch handles gracefully', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'---\nphase: "01"\n---\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
const didNotCrash = result.success || (result.output !== undefined);
|
||||
assert.ok(didNotCrash, `state patch should not crash on frontmatter-only STATE.md: ${result.error}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 9. Stress tests with 50+ phases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('stress tests with 50+ phases', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('roadmap analyze on 50-phase ROADMAP completes in under 2000ms', () => {
|
||||
create50PhaseProject(tmpDir, 25);
|
||||
|
||||
const start = performance.now();
|
||||
const result = runGsdTools('roadmap analyze', tmpDir);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`);
|
||||
assert.ok(elapsed < 2000, `Should complete in under 2000ms, took ${elapsed.toFixed(0)}ms`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(output.phases), 'Output should contain a phases array');
|
||||
assert.strictEqual(output.phases.length, 50, `Should have 50 phases, got ${output.phases.length}`);
|
||||
|
||||
const completedPhases = output.phases.filter(p => p.disk_status === 'complete');
|
||||
assert.strictEqual(completedPhases.length, 25, `Should have 25 complete phases, got ${completedPhases.length}`);
|
||||
});
|
||||
|
||||
test('phase complete on phase 26 of 50-phase project works correctly', () => {
|
||||
create50PhaseProject(tmpDir, 25);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** In progress\n');
|
||||
|
||||
const phase26Dir = path.join(tmpDir, '.planning', 'phases', '26-feature-26');
|
||||
fs.writeFileSync(
|
||||
path.join(phase26Dir, '26-01-SUMMARY.md'),
|
||||
'# Phase 26 Plan 1 Summary\n\nFeature 26 completed.\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase complete 26', tmpDir);
|
||||
assert.ok(result.success, `phase complete 26 should succeed: ${result.error}`);
|
||||
|
||||
const roadmapContent = fs.readFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'utf-8'
|
||||
);
|
||||
const phase26Checkbox = roadmapContent.match(/-\s*\[(x| )\]\s*.*Phase\s+26/i);
|
||||
assert.ok(phase26Checkbox, 'Should find Phase 26 checkbox in ROADMAP');
|
||||
assert.strictEqual(phase26Checkbox[1], 'x', 'Phase 26 should now be marked as complete [x]');
|
||||
});
|
||||
});
|
||||
@@ -242,6 +242,16 @@ describe('config-set command', () => {
|
||||
assert.strictEqual(config.workflow.use_worktrees, false);
|
||||
});
|
||||
|
||||
test('sets git.base_branch for non-main default branches', () => {
|
||||
writeConfig(tmpDir, {});
|
||||
|
||||
const result = runGsdTools('config-set git.base_branch master', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const config = readConfig(tmpDir);
|
||||
assert.strictEqual(config.git.base_branch, 'master');
|
||||
});
|
||||
|
||||
test('errors when no key path provided', () => {
|
||||
const result = runGsdTools('config-set', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
@@ -329,6 +339,27 @@ describe('config-get command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('gets git.base_branch after it is set', () => {
|
||||
runGsdTools('config-set git.base_branch master', tmpDir);
|
||||
const result = runGsdTools('config-get git.base_branch', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output, 'master');
|
||||
});
|
||||
|
||||
test('errors for git.base_branch when not explicitly set', () => {
|
||||
// Default config from config-ensure-section does not include git.base_branch,
|
||||
// so config-get should return "Key not found" — this triggers auto-detect
|
||||
// fallback in the workflow (origin/HEAD detection).
|
||||
const result = runGsdTools('config-get git.base_branch', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(
|
||||
result.error.includes('Key not found'),
|
||||
`Expected "Key not found" in error: ${result.error}`
|
||||
);
|
||||
});
|
||||
|
||||
test('errors when no key path provided', () => {
|
||||
const result = runGsdTools('config-get', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
|
||||
145
tests/context-enrichment.test.cjs
Normal file
145
tests/context-enrichment.test.cjs
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* GSD Tools Tests - Adaptive Context Enrichment for 1M Models
|
||||
*
|
||||
* Tests for feat/1m-context-enrichment-1473b:
|
||||
* - Workflow template syntax validation (CONTEXT_WINDOW conditionals)
|
||||
* - execute-phase.md enrichment blocks (executor + verifier)
|
||||
* - plan-phase.md cross-phase context gating
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Workflow template syntax validation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('execute-phase.md context enrichment', () => {
|
||||
const EXECUTE_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'execute-phase.md');
|
||||
|
||||
test('contains CONTEXT_WINDOW config-get command', () => {
|
||||
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('CONTEXT_WINDOW'),
|
||||
'execute-phase.md should reference CONTEXT_WINDOW variable'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('config-get context_window'),
|
||||
'execute-phase.md should read context_window via config-get'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('|| echo "200000"'),
|
||||
'execute-phase.md should default CONTEXT_WINDOW to 200000'
|
||||
);
|
||||
});
|
||||
|
||||
test('contains conditional prior_wave_summaries in executor prompt', () => {
|
||||
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('CONTEXT_WINDOW >= 500000'),
|
||||
'execute-phase.md should gate enrichment on CONTEXT_WINDOW >= 500000'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('prior_wave_summaries'),
|
||||
'execute-phase.md should include prior_wave_summaries in enrichment block'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('CONTEXT.md'),
|
||||
'execute-phase.md should reference CONTEXT.md in conditional enrichment'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('RESEARCH.md'),
|
||||
'execute-phase.md should reference RESEARCH.md in conditional enrichment'
|
||||
);
|
||||
});
|
||||
|
||||
test('verifier prompt includes files_to_read block', () => {
|
||||
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('<files_to_read>'),
|
||||
'execute-phase.md should contain <files_to_read> opening tag'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('</files_to_read>'),
|
||||
'execute-phase.md should contain </files_to_read> closing tag'
|
||||
);
|
||||
const verifierSection = content.substring(content.lastIndexOf('<files_to_read>'));
|
||||
assert.ok(
|
||||
verifierSection.includes('PLAN.md'),
|
||||
'verifier files_to_read should reference PLAN.md'
|
||||
);
|
||||
assert.ok(
|
||||
verifierSection.includes('SUMMARY.md'),
|
||||
'verifier files_to_read should reference SUMMARY.md'
|
||||
);
|
||||
assert.ok(
|
||||
verifierSection.includes('REQUIREMENTS.md'),
|
||||
'verifier files_to_read should reference REQUIREMENTS.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('executor enrichment block includes CONTEXT.md and RESEARCH.md for 1M models', () => {
|
||||
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
|
||||
// Find the executor section's enrichment block
|
||||
const executorIdx = content.indexOf('CONTEXT_WINDOW >= 500000');
|
||||
assert.ok(executorIdx > -1, 'Should find CONTEXT_WINDOW >= 500000 conditional');
|
||||
|
||||
// Extract ~500 chars after the conditional to check what's included
|
||||
const enrichmentBlock = content.substring(executorIdx, executorIdx + 500);
|
||||
assert.ok(
|
||||
enrichmentBlock.includes('CONTEXT.md'),
|
||||
'executor enrichment should include CONTEXT.md'
|
||||
);
|
||||
assert.ok(
|
||||
enrichmentBlock.includes('RESEARCH.md'),
|
||||
'executor enrichment should include RESEARCH.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan-phase.md context enrichment', () => {
|
||||
const PLAN_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'plan-phase.md');
|
||||
|
||||
test('contains CONTEXT_WINDOW conditional for prior CONTEXT.md', () => {
|
||||
const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('CONTEXT_WINDOW'),
|
||||
'plan-phase.md should reference CONTEXT_WINDOW variable'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('config-get context_window'),
|
||||
'plan-phase.md should read context_window via config-get'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('CONTEXT_WINDOW >= 500000'),
|
||||
'plan-phase.md should gate cross-phase context on CONTEXT_WINDOW >= 500000'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('CONTEXT.md'),
|
||||
'plan-phase.md should reference CONTEXT.md in cross-phase enrichment'
|
||||
);
|
||||
});
|
||||
|
||||
test('enrichment block mentions cross-phase decision consistency', () => {
|
||||
const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8');
|
||||
// The enrichment should explain why prior context matters
|
||||
assert.ok(
|
||||
content.includes('cross-phase') || content.includes('Cross-phase'),
|
||||
'plan-phase.md should mention cross-phase context'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('SUMMARY.md'),
|
||||
'plan-phase.md should reference prior SUMMARY.md files'
|
||||
);
|
||||
});
|
||||
|
||||
test('default CONTEXT_WINDOW fallback is 200000', () => {
|
||||
const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('|| echo "200000"'),
|
||||
'plan-phase.md should default CONTEXT_WINDOW to 200000'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -659,7 +659,7 @@ describe('copyCommandsAsCopilotSkills', () => {
|
||||
assert.ok(skillContent.includes('description: Run all remaining phases autonomously'),
|
||||
'description preserved');
|
||||
// argument-hint present and double-quoted
|
||||
assert.ok(skillContent.includes('argument-hint: "[--from N]"'), 'argument-hint present and quoted');
|
||||
assert.ok(skillContent.includes('argument-hint: "[--from N] [--only N] [--interactive]"'), 'argument-hint present and quoted');
|
||||
// allowed-tools comma-separated
|
||||
assert.ok(skillContent.includes('allowed-tools: Read, Write, Bash, Glob, Grep, AskUserQuestion, Task'),
|
||||
'allowed-tools is comma-separated');
|
||||
@@ -1349,3 +1349,89 @@ describe('E2E: Copilot uninstall verification', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Claude uninstall: user file preservation (#1423) ─────────────────────────
|
||||
|
||||
function runClaudeInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
function runClaudeUninstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--uninstall'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Claude uninstall preserves user-generated files (#1423)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-preserve-'));
|
||||
runClaudeInstall(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('preserves USER-PROFILE.md across uninstall', () => {
|
||||
const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md');
|
||||
const content = '# Developer Profile\n\nAutonomy: High\nGenerated: 2026-03-29\n';
|
||||
fs.writeFileSync(profilePath, content);
|
||||
|
||||
runClaudeUninstall(tmpDir);
|
||||
|
||||
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive uninstall');
|
||||
assert.strictEqual(fs.readFileSync(profilePath, 'utf-8'), content, 'content should be identical');
|
||||
});
|
||||
|
||||
test('preserves dev-preferences.md across uninstall', () => {
|
||||
const prefsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
fs.mkdirSync(prefsDir, { recursive: true });
|
||||
const prefsPath = path.join(prefsDir, 'dev-preferences.md');
|
||||
const content = '---\nname: dev-preferences\n---\n# Preferences\nUse TypeScript strict.\n';
|
||||
fs.writeFileSync(prefsPath, content);
|
||||
|
||||
runClaudeUninstall(tmpDir);
|
||||
|
||||
assert.ok(fs.existsSync(prefsPath), 'dev-preferences.md should survive uninstall');
|
||||
assert.strictEqual(fs.readFileSync(prefsPath, 'utf-8'), content, 'content should be identical');
|
||||
});
|
||||
|
||||
test('still removes GSD engine files during uninstall', () => {
|
||||
const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# Profile\n');
|
||||
|
||||
// Verify engine files exist before uninstall
|
||||
const binDir = path.join(tmpDir, '.claude', 'get-shit-done', 'bin');
|
||||
assert.ok(fs.existsSync(binDir), 'bin/ should exist before uninstall');
|
||||
|
||||
runClaudeUninstall(tmpDir);
|
||||
|
||||
// Engine files gone, user file preserved
|
||||
assert.ok(!fs.existsSync(binDir), 'bin/ should be removed after uninstall');
|
||||
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive');
|
||||
});
|
||||
|
||||
test('clean uninstall when no user files exist', () => {
|
||||
runClaudeUninstall(tmpDir);
|
||||
|
||||
const gsdDir = path.join(tmpDir, '.claude', 'get-shit-done');
|
||||
const cmdDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
// Directories should be fully removed when no user files to preserve
|
||||
assert.ok(!fs.existsSync(gsdDir), 'get-shit-done/ should not exist after clean uninstall');
|
||||
assert.ok(!fs.existsSync(cmdDir), 'commands/gsd/ should not exist after clean uninstall');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
findPhaseInternal,
|
||||
findProjectRoot,
|
||||
detectSubRepos,
|
||||
planningDir,
|
||||
} = require('../get-shit-done/bin/lib/core.cjs');
|
||||
|
||||
// ─── loadConfig ────────────────────────────────────────────────────────────────
|
||||
@@ -1611,3 +1612,78 @@ describe('reapStaleTempFiles', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── planningDir ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('planningDir', () => {
|
||||
const cwd = '/fake/repo';
|
||||
let savedProject, savedWorkstream;
|
||||
|
||||
beforeEach(() => {
|
||||
savedProject = process.env.GSD_PROJECT;
|
||||
savedWorkstream = process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject;
|
||||
else delete process.env.GSD_PROJECT;
|
||||
if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream;
|
||||
else delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
|
||||
test('returns .planning/ when neither project nor workstream is set', () => {
|
||||
const result = planningDir(cwd, null, null);
|
||||
assert.strictEqual(result, path.join(cwd, '.planning'));
|
||||
});
|
||||
|
||||
test('returns .planning/{project}/ when project is set', () => {
|
||||
const result = planningDir(cwd, null, 'my-app');
|
||||
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app'));
|
||||
});
|
||||
|
||||
test('returns .planning/workstreams/{ws}/ when workstream is set', () => {
|
||||
const result = planningDir(cwd, 'feature-x', null);
|
||||
assert.strictEqual(result, path.join(cwd, '.planning', 'workstreams', 'feature-x'));
|
||||
});
|
||||
|
||||
test('returns .planning/{project}/workstreams/{ws}/ when both are set', () => {
|
||||
const result = planningDir(cwd, 'feature-x', 'my-app');
|
||||
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x'));
|
||||
});
|
||||
|
||||
test('reads GSD_PROJECT from env when project param is undefined', () => {
|
||||
process.env.GSD_PROJECT = 'env-project';
|
||||
const result = planningDir(cwd);
|
||||
assert.strictEqual(result, path.join(cwd, '.planning', 'env-project'));
|
||||
});
|
||||
|
||||
test('rejects path traversal in project name', () => {
|
||||
assert.throws(
|
||||
() => planningDir(cwd, null, '../../etc'),
|
||||
/invalid path characters/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects forward slash in project name', () => {
|
||||
assert.throws(
|
||||
() => planningDir(cwd, null, 'foo/bar'),
|
||||
/invalid path characters/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects backslash in project name', () => {
|
||||
assert.throws(
|
||||
() => planningDir(cwd, null, 'foo\\bar'),
|
||||
/invalid path characters/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects path traversal in workstream name', () => {
|
||||
assert.throws(
|
||||
() => planningDir(cwd, '../../../tmp', null),
|
||||
/invalid path characters/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
388
tests/health-validation.test.cjs
Normal file
388
tests/health-validation.test.cjs
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* GSD Tools Tests - Health Validation
|
||||
*
|
||||
* Tests for fix/health-validation-1473c:
|
||||
* - W011: STATE/ROADMAP cross-validation (phase divergence detection)
|
||||
* - W012: branching_strategy validation
|
||||
* - W013: context_window validation
|
||||
* - W014: phase_branch_template placeholder validation
|
||||
* - W015: milestone_branch_template placeholder validation
|
||||
* - stateReplaceFieldWithFallback field-miss warning
|
||||
* - Boundary conditions and edge cases
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
|
||||
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n${lines}\n`
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalStateMd(tmpDir, content) {
|
||||
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
defaultContent
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalProjectMd(tmpDir) {
|
||||
const sections = ['## What This Is', '## Core Value', '## Requirements'];
|
||||
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
`# Project\n\n${content}`
|
||||
);
|
||||
}
|
||||
|
||||
function writeValidConfigJson(tmpDir, overrides = {}) {
|
||||
const base = { model_profile: 'balanced', commit_docs: true };
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ ...base, ...overrides }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. W011: STATE/ROADMAP cross-validation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('W011: STATE/ROADMAP cross-validation', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('STATE says current phase but ROADMAP shows it as complete -> warning', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Session State\n\n**Current Phase:** 03\n**Current Phase Name:** Database Layer\n**Status:** In progress\n`
|
||||
);
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.code === 'W011'),
|
||||
`Expected W011 in warnings: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('STATE and ROADMAP agree (phase not checked off) -> no W011 warning', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n- [ ] Phase 2: API Layer\n\n### Phase 2: API Layer\n**Goal:** Build API\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Session State\n\n**Current Phase:** 2\n**Status:** In progress\n`
|
||||
);
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api-layer'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!output.warnings.some(w => w.code === 'W011'),
|
||||
`Should not have W011: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. W012-W015: Config field validation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('config field validation', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('W012: invalid branching_strategy triggers warning', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||||
writeValidConfigJson(tmpDir, { branching_strategy: 'banana' });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.code === 'W012'),
|
||||
`Expected W012 in warnings: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('W013: negative context_window triggers warning', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||||
writeValidConfigJson(tmpDir, { context_window: -500 });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.code === 'W013'),
|
||||
`Expected W013 in warnings: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('W014: phase_branch_template missing {phase} triggers warning', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||||
writeValidConfigJson(tmpDir, { phase_branch_template: 'gsd/no-placeholder-{slug}' });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.code === 'W014'),
|
||||
`Expected W014 in warnings: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('W015: milestone_branch_template missing {milestone} triggers warning', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||||
writeValidConfigJson(tmpDir, { milestone_branch_template: 'release/no-placeholder' });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.code === 'W015'),
|
||||
`Expected W015 in warnings: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Boundary conditions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('boundary conditions', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('context_window config accepts 500000 (boundary value)', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||||
writeValidConfigJson(tmpDir, { context_window: 500000 });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!output.warnings.some(w => w.code === 'W013'),
|
||||
`Should not have W013 for context_window=500000: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('context_window config accepts 200000 (default value)', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||||
writeValidConfigJson(tmpDir, { context_window: 200000 });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!output.warnings.some(w => w.code === 'W013'),
|
||||
`Should not have W013 for context_window=200000: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('W013 does NOT fire when context_window is absent from config', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!output.warnings.some(w => w.code === 'W013'),
|
||||
`Should not have W013 when context_window is absent: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('health check handles STATE.md with no Current Phase field (no W011 crash)', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nSome content but no phase reference.\n');
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command should not crash: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(typeof output.status === 'string', 'should return a status string');
|
||||
assert.ok(Array.isArray(output.errors), 'should return errors array');
|
||||
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
|
||||
});
|
||||
|
||||
test('health check handles empty ROADMAP.md (no crash)', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '');
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command should not crash on empty ROADMAP.md: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(typeof output.status === 'string', 'should return a status string');
|
||||
assert.ok(Array.isArray(output.errors), 'should return errors array');
|
||||
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
|
||||
});
|
||||
|
||||
test('config.json with trailing comma -- validate health reports parse error', () => {
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
'{"model_profile": "balanced",}'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\n### Phase 1: Test Phase\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `validate health should not crash on invalid JSON: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const hasE005 = output.errors.some(e => e.code === 'E005');
|
||||
assert.ok(hasE005, `Should report E005 for invalid config.json: ${JSON.stringify(output.errors)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. stateReplaceFieldWithFallback warning
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('stateReplaceFieldWithFallback field-miss warning', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('advance-plan completes even when fields are missing (non-fatal)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('state advance-plan', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
|
||||
});
|
||||
|
||||
test('validate health on 50-phase project completes in under 3000ms', () => {
|
||||
// Stress test for the new health checks at scale
|
||||
let roadmapContent = '# Roadmap v1.0\n\n';
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
roadmapContent += `- [${i <= 25 ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
|
||||
}
|
||||
roadmapContent += '\n';
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const pad = String(i).padStart(2, '0');
|
||||
roadmapContent += `### Phase ${i}: Feature ${i}\n\n**Goal:** Build feature ${i}\n**Plans:** 1 plans\n\n`;
|
||||
}
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent);
|
||||
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** Planning\n');
|
||||
writeValidConfigJson(tmpDir);
|
||||
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const pad = String(i).padStart(2, '0');
|
||||
const phaseDir = path.join(phasesDir, `${pad}-feature-${i}`);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, `${pad}-01-PLAN.md`), `# Plan ${i}\n`);
|
||||
if (i <= 25) {
|
||||
fs.writeFileSync(path.join(phaseDir, `${pad}-01-SUMMARY.md`), `# Summary ${i}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const { performance } = require('perf_hooks');
|
||||
const start = performance.now();
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert.ok(result.success, `validate health should succeed: ${result.error}`);
|
||||
assert.ok(elapsed < 3000, `Should complete in under 3000ms, took ${elapsed.toFixed(0)}ms`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(typeof output.status === 'string', 'Should return a status string');
|
||||
});
|
||||
});
|
||||
507
tests/hooks-opt-in.test.cjs
Normal file
507
tests/hooks-opt-in.test.cjs
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* GSD Tools Tests - Community Hooks (opt-in)
|
||||
*
|
||||
* Tests for feat/hooks-opt-in-1473d:
|
||||
* - Hook file existence and permissions
|
||||
* - Installer hook registration in install.js
|
||||
* - Hook execution with opt-in enabled and disabled
|
||||
* - Negative security tests for hooks
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempProject(prefix = 'gsd-hook-test-') {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
function cleanup(tmpDir) {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
function writeConfigWithHooks(tmpDir, enabled) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
hooks: { community: enabled }
|
||||
}, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalStateMd(tmpDir, content) {
|
||||
const defaultContent = content || '# Session State\n\n**Current Phase:** 01\n**Status:** Active\n';
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
defaultContent
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Hook file existence and permissions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hook file validation', () => {
|
||||
test('gsd-session-state.sh exists', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
|
||||
assert.ok(fs.existsSync(hookPath), 'gsd-session-state.sh should exist');
|
||||
});
|
||||
|
||||
test('gsd-validate-commit.sh exists', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
assert.ok(fs.existsSync(hookPath), 'gsd-validate-commit.sh should exist');
|
||||
});
|
||||
|
||||
test('gsd-phase-boundary.sh exists', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
|
||||
assert.ok(fs.existsSync(hookPath), 'gsd-phase-boundary.sh should exist');
|
||||
});
|
||||
|
||||
test('gsd-session-state.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
|
||||
const stat = fs.statSync(hookPath);
|
||||
assert.ok((stat.mode & 0o111) !== 0, 'gsd-session-state.sh should be executable');
|
||||
});
|
||||
|
||||
test('gsd-validate-commit.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const stat = fs.statSync(hookPath);
|
||||
assert.ok((stat.mode & 0o111) !== 0, 'gsd-validate-commit.sh should be executable');
|
||||
});
|
||||
|
||||
test('gsd-phase-boundary.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
|
||||
const stat = fs.statSync(hookPath);
|
||||
assert.ok((stat.mode & 0o111) !== 0, 'gsd-phase-boundary.sh should be executable');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Installer hook registration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('installer hook registration', () => {
|
||||
const installJsPath = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
let installSource;
|
||||
|
||||
beforeEach(() => {
|
||||
installSource = fs.readFileSync(installJsPath, 'utf-8');
|
||||
});
|
||||
|
||||
test('install.js contains gsd-validate-commit registration block', () => {
|
||||
assert.ok(
|
||||
installSource.includes('gsd-validate-commit'),
|
||||
'install.js should contain gsd-validate-commit hook registration'
|
||||
);
|
||||
assert.ok(
|
||||
installSource.includes('validateCommitCommand'),
|
||||
'install.js should define validateCommitCommand variable'
|
||||
);
|
||||
assert.ok(
|
||||
installSource.includes('hasValidateCommitHook'),
|
||||
'install.js should check for existing validate-commit hook'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js contains gsd-session-state registration block', () => {
|
||||
assert.ok(
|
||||
installSource.includes('gsd-session-state'),
|
||||
'install.js should contain gsd-session-state hook registration'
|
||||
);
|
||||
assert.ok(
|
||||
installSource.includes('sessionStateCommand'),
|
||||
'install.js should define sessionStateCommand variable'
|
||||
);
|
||||
assert.ok(
|
||||
installSource.includes('hasSessionStateHook'),
|
||||
'install.js should check for existing session-state hook'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js contains gsd-phase-boundary registration block', () => {
|
||||
assert.ok(
|
||||
installSource.includes('gsd-phase-boundary'),
|
||||
'install.js should contain gsd-phase-boundary hook registration'
|
||||
);
|
||||
assert.ok(
|
||||
installSource.includes('phaseBoundaryCommand'),
|
||||
'install.js should define phaseBoundaryCommand variable'
|
||||
);
|
||||
assert.ok(
|
||||
installSource.includes('hasPhaseBoundaryHook'),
|
||||
'install.js should check for existing phase-boundary hook'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js registers validate-commit with PreToolUse event and Bash matcher', () => {
|
||||
assert.ok(
|
||||
installSource.includes("settings.hooks[preToolEvent].push"),
|
||||
'validate-commit should be pushed to preToolEvent hooks array'
|
||||
);
|
||||
const validateCommitBlock = installSource.substring(
|
||||
installSource.indexOf('// Configure commit validation hook'),
|
||||
installSource.indexOf('// Configure session state orientation hook')
|
||||
);
|
||||
assert.ok(
|
||||
validateCommitBlock.includes("matcher: 'Bash'"),
|
||||
'validate-commit hook should use Bash matcher'
|
||||
);
|
||||
assert.ok(
|
||||
validateCommitBlock.includes('preToolEvent'),
|
||||
'validate-commit hook should register on preToolEvent (PreToolUse)'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js adds all 3 new hooks to the uninstall cleanup list', () => {
|
||||
const gsdHooksMatch = installSource.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
|
||||
assert.ok(gsdHooksMatch, 'install.js should define gsdHooks array for uninstall cleanup');
|
||||
|
||||
const gsdHooksContent = gsdHooksMatch[1];
|
||||
assert.ok(
|
||||
gsdHooksContent.includes('gsd-session-state.sh'),
|
||||
'gsdHooks should include gsd-session-state.sh'
|
||||
);
|
||||
assert.ok(
|
||||
gsdHooksContent.includes('gsd-validate-commit.sh'),
|
||||
'gsdHooks should include gsd-validate-commit.sh'
|
||||
);
|
||||
assert.ok(
|
||||
gsdHooksContent.includes('gsd-phase-boundary.sh'),
|
||||
'gsdHooks should include gsd-phase-boundary.sh'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js log messages indicate opt-in behavior', () => {
|
||||
assert.ok(
|
||||
installSource.includes('opt-in via config'),
|
||||
'install.js should mention opt-in in log messages'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Opt-in gating behavior
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('opt-in gating behavior', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('validate-commit is a no-op when hooks.community is false', () => {
|
||||
writeConfigWithHooks(tmpDir, false);
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "WIP save"' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
// Should exit 0 (no-op) even with a bad commit message
|
||||
assert.strictEqual(result.status, 0, `Should be no-op when disabled, got ${result.status}`);
|
||||
});
|
||||
|
||||
test('validate-commit is a no-op when config.json is absent', () => {
|
||||
// No config.json at all
|
||||
const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-bare-'));
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "WIP save"' }
|
||||
});
|
||||
|
||||
try {
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: bareDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Should be no-op without config.json, got ${result.status}`);
|
||||
} finally {
|
||||
fs.rmSync(bareDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('session-state is a no-op when hooks.community is false', () => {
|
||||
writeConfigWithHooks(tmpDir, false);
|
||||
writeMinimalStateMd(tmpDir);
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input: '',
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
|
||||
// Should NOT output state info when disabled
|
||||
assert.ok(
|
||||
!result.stdout.includes('Project State Reminder'),
|
||||
`Should not output state reminder when disabled: ${result.stdout}`
|
||||
);
|
||||
});
|
||||
|
||||
test('phase-boundary is a no-op when hooks.community is false', () => {
|
||||
writeConfigWithHooks(tmpDir, false);
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { file_path: '.planning/STATE.md' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
|
||||
assert.ok(
|
||||
!result.stdout.includes('.planning/ file modified'),
|
||||
`Should not output warning when disabled: ${result.stdout}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Hook execution when enabled
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
writeConfigWithHooks(tmpDir, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('validate-commit allows valid conventional commit', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "fix(core): add locking mechanism"' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Valid commit should exit 0, got ${result.status}. stderr: ${result.stderr}`);
|
||||
});
|
||||
|
||||
test('validate-commit blocks non-conventional commit', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "WIP save"' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 2, `Non-conventional commit should exit 2, got ${result.status}`);
|
||||
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
|
||||
assert.ok(result.stdout.includes('Conventional Commits'), `stdout should mention "Conventional Commits": ${result.stdout}`);
|
||||
});
|
||||
|
||||
test('validate-commit allows non-commit commands', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'git push origin main' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Non-commit command should exit 0, got ${result.status}`);
|
||||
});
|
||||
|
||||
test('session-state outputs state info when enabled', () => {
|
||||
writeMinimalStateMd(tmpDir);
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input: '',
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
|
||||
assert.ok(
|
||||
result.stdout.includes('STATE.md exists'),
|
||||
`stdout should contain "STATE.md exists": ${result.stdout}`
|
||||
);
|
||||
});
|
||||
|
||||
test('session-state exits 0 without .planning/ (in enabled project)', () => {
|
||||
// Create a dir with config but no STATE.md
|
||||
const noStateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-nostate-'));
|
||||
fs.mkdirSync(path.join(noStateDir, '.planning'), { recursive: true });
|
||||
writeConfigWithHooks(noStateDir, true);
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
|
||||
|
||||
try {
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input: '',
|
||||
encoding: 'utf-8',
|
||||
cwd: noStateDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
|
||||
assert.ok(
|
||||
result.stdout.includes('No .planning/ found') || result.stdout.includes('Project State'),
|
||||
`Should handle missing STATE.md gracefully: ${result.stdout}`
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(noStateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('phase-boundary detects .planning/ writes when enabled', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { file_path: '.planning/STATE.md' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
|
||||
assert.ok(
|
||||
result.stdout.includes('.planning/ file modified'),
|
||||
`stdout should contain ".planning/ file modified": ${result.stdout}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. Negative security tests for hooks
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hook security tests', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
writeConfigWithHooks(tmpDir, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('validate-commit blocks message with shell metacharacters', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "$(rm -rf /)"' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 2, `Shell metacharacter message should be blocked: ${result.status}`);
|
||||
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
|
||||
});
|
||||
|
||||
test('validate-commit blocks message with backtick injection', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "`whoami`"' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 2, `Backtick injection should be blocked: ${result.status}`);
|
||||
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
|
||||
});
|
||||
|
||||
test('validate-commit allows commit with scope containing special chars', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "fix(api/v2): handle edge case"' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Valid commit with / in scope should be allowed: ${result.status}`);
|
||||
});
|
||||
|
||||
test('phase-boundary handles malformed JSON input gracefully', () => {
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
|
||||
const input = 'not json at all';
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `Should not crash on malformed JSON: ${result.stderr}`);
|
||||
});
|
||||
|
||||
test('hooks handle config.json with broken JSON gracefully', () => {
|
||||
// Write malformed JSON config
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
'{ broken json'
|
||||
);
|
||||
|
||||
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "WIP save"' }
|
||||
});
|
||||
|
||||
const result = spawnSync('bash', [hookPath], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
// Should exit 0 (treat malformed config as disabled)
|
||||
assert.strictEqual(result.status, 0, `Malformed config should be treated as disabled: ${result.status}`);
|
||||
});
|
||||
});
|
||||
@@ -26,9 +26,10 @@ const runtimeMap = {
|
||||
'6': 'copilot',
|
||||
'7': 'antigravity',
|
||||
'8': 'cursor',
|
||||
'9': 'windsurf'
|
||||
'9': 'windsurf',
|
||||
'10': 'augment'
|
||||
};
|
||||
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf'];
|
||||
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
|
||||
|
||||
/**
|
||||
* Simulate the parsing logic from promptRuntime without requiring readline.
|
||||
@@ -37,7 +38,7 @@ const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot',
|
||||
function parseRuntimeInput(input) {
|
||||
input = input.trim() || '1';
|
||||
|
||||
if (input === '10') {
|
||||
if (input === '11') {
|
||||
return allRuntimes;
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ describe('multi-runtime selection parsing', () => {
|
||||
test('single choice returns single runtime', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('1'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('2'), ['kilo']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('3'), ['opencode']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('4'), ['gemini']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('5'), ['codex']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('8'), ['cursor']);
|
||||
@@ -82,8 +84,12 @@ describe('multi-runtime selection parsing', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('9'), ['windsurf']);
|
||||
});
|
||||
|
||||
test('choice 10 returns all runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('10'), allRuntimes);
|
||||
test('single choice for augment', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('10'), ['augment']);
|
||||
});
|
||||
|
||||
test('choice 11 returns all runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('11'), allRuntimes);
|
||||
});
|
||||
|
||||
test('empty input defaults to claude', () => {
|
||||
@@ -92,7 +98,7 @@ describe('multi-runtime selection parsing', () => {
|
||||
});
|
||||
|
||||
test('invalid choices are ignored, falls back to claude if all invalid', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('11'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('12'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']);
|
||||
});
|
||||
@@ -114,7 +120,7 @@ describe('multi-runtime selection parsing', () => {
|
||||
});
|
||||
|
||||
describe('install.js source contains multi-select support', () => {
|
||||
test('runtimeMap is defined with all 9 runtimes', () => {
|
||||
test('runtimeMap is defined with all 10 runtimes', () => {
|
||||
for (const [key, name] of Object.entries(runtimeMap)) {
|
||||
assert.ok(
|
||||
installSrc.includes(`'${key}': '${name}'`),
|
||||
@@ -131,6 +137,24 @@ describe('install.js source contains multi-select support', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('all shortcut uses option 11', () => {
|
||||
assert.ok(
|
||||
installSrc.includes("if (input === '11')"),
|
||||
'all shortcut uses option 11'
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt lists Augment as option 10 and All as option 11', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('10${reset}) Augment'),
|
||||
'prompt lists Augment as option 10'
|
||||
);
|
||||
assert.ok(
|
||||
installSrc.includes('11${reset}) All'),
|
||||
'prompt lists All as option 11'
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt text shows multi-select hint', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('Select multiple'),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* GSD Tools Tests - OpenCode permission config
|
||||
* Regression tests for OpenCode permission config handling.
|
||||
*
|
||||
* Regression tests for OpenCode permission handling, including the valid
|
||||
* top-level string form: "permission": "allow".
|
||||
* Ensures the installer does not crash when opencode.json uses the valid
|
||||
* top-level string form: "permission": "allow", and that path-specific
|
||||
* permissions are written against the actual resolved install directory.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
@@ -17,6 +18,19 @@ const { configureOpencodePermissions } = require('../bin/install.js');
|
||||
|
||||
const installSrc = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
|
||||
|
||||
const envKeys = ['OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'XDG_CONFIG_HOME'];
|
||||
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
function restoreEnv(snapshot) {
|
||||
for (const key of envKeys) {
|
||||
if (snapshot[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = snapshot[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('configureOpencodePermissions', () => {
|
||||
let configDir;
|
||||
|
||||
@@ -26,9 +40,10 @@ describe('configureOpencodePermissions', () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(configDir);
|
||||
restoreEnv(originalEnv);
|
||||
});
|
||||
|
||||
test('does not rewrite top-level string permissions', () => {
|
||||
test('does not crash or rewrite top-level string permissions', () => {
|
||||
const configPath = path.join(configDir, 'opencode.json');
|
||||
const original = JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
@@ -37,6 +52,7 @@ describe('configureOpencodePermissions', () => {
|
||||
}, null, 2) + '\n';
|
||||
|
||||
fs.writeFileSync(configPath, original);
|
||||
process.env.OPENCODE_CONFIG_DIR = configDir;
|
||||
|
||||
assert.doesNotThrow(() => configureOpencodePermissions(true, configDir));
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
||||
@@ -45,6 +61,7 @@ describe('configureOpencodePermissions', () => {
|
||||
test('adds path-specific read and external_directory permissions for object configs', () => {
|
||||
const configPath = path.join(configDir, 'opencode.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ permission: {} }, null, 2) + '\n');
|
||||
process.env.OPENCODE_CONFIG_DIR = configDir;
|
||||
|
||||
configureOpencodePermissions(true, configDir);
|
||||
|
||||
@@ -56,6 +73,9 @@ describe('configureOpencodePermissions', () => {
|
||||
});
|
||||
|
||||
test('finishInstall passes the actual config dir to OpenCode permissions', () => {
|
||||
assert.ok(installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'), 'OpenCode permission config uses actual install dir');
|
||||
assert.ok(
|
||||
installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'),
|
||||
'OpenCode permission config uses actual install dir'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
254
tests/subagent-timeout.test.cjs
Normal file
254
tests/subagent-timeout.test.cjs
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* GSD Tools Tests - subagent timeout configuration
|
||||
*
|
||||
* Validates that workflow.subagent_timeout is properly registered,
|
||||
* loaded from config, and emitted in init context.
|
||||
*
|
||||
* Closes: #1472
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── config key registration ─────────────────────────────────────────────────
|
||||
|
||||
describe('workflow.subagent_timeout config key (#1472)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('subagent_timeout has correct default value (300000ms)', () => {
|
||||
// Write a minimal config.json
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ model_profile: 'balanced' }, null, 2));
|
||||
|
||||
// Load config via init and check the value propagates
|
||||
// Use config-get to verify the field is recognized
|
||||
const result = runGsdTools(['config-set', 'workflow.subagent_timeout', '600000'], tmpDir);
|
||||
assert.ok(result.success, `config-set should accept workflow.subagent_timeout: ${result.error}`);
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(config.workflow.subagent_timeout, 600000);
|
||||
});
|
||||
|
||||
test('config-set rejects invalid config keys but accepts subagent_timeout', () => {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
|
||||
|
||||
// Valid key should succeed
|
||||
const valid = runGsdTools(['config-set', 'workflow.subagent_timeout', '900000'], tmpDir);
|
||||
assert.ok(valid.success, `workflow.subagent_timeout should be a valid key: ${valid.error}`);
|
||||
|
||||
// Invalid key should fail
|
||||
const invalid = runGsdTools(['config-set', 'workflow.nonexistent_key', 'true'], tmpDir);
|
||||
assert.ok(!invalid.success, 'nonexistent key should be rejected');
|
||||
});
|
||||
|
||||
test('subagent_timeout appears in map-codebase init context', () => {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
workflow: { subagent_timeout: 600000 }
|
||||
}, null, 2));
|
||||
|
||||
const result = runGsdTools('init map-codebase', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init map-codebase should succeed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.subagent_timeout, 600000, 'init context should include configured timeout');
|
||||
});
|
||||
|
||||
test('subagent_timeout defaults to 300000 when not configured', () => {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
|
||||
|
||||
const result = runGsdTools('init map-codebase', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init map-codebase should succeed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.subagent_timeout, 300000, 'default should be 300000ms (5 minutes)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('map-codebase workflow references configurable timeout (#1472)', () => {
|
||||
test('workflow file references subagent_timeout from init context', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md');
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
assert.ok(
|
||||
content.includes('subagent_timeout'),
|
||||
'map-codebase.md should reference subagent_timeout from init context'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('workflow.subagent_timeout'),
|
||||
'map-codebase.md should document the config key'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow file no longer has hardcoded 300000 timeout', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md');
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
// The timeout line should reference the config variable, not a hardcoded value
|
||||
const timeoutLines = content.split('\n').filter(l => l.includes('timeout:'));
|
||||
for (const line of timeoutLines) {
|
||||
assert.ok(
|
||||
!line.match(/timeout:\s*300000\s*$/),
|
||||
`found hardcoded timeout: "${line.trim()}". Should reference subagent_timeout from init context.`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('planning-config.md documents subagent_timeout (#1472)', () => {
|
||||
test('reference doc includes subagent_timeout entry', () => {
|
||||
const refPath = path.join(__dirname, '..', 'get-shit-done', 'references', 'planning-config.md');
|
||||
const content = fs.readFileSync(refPath, 'utf8');
|
||||
|
||||
assert.ok(
|
||||
content.includes('workflow.subagent_timeout'),
|
||||
'planning-config.md should document workflow.subagent_timeout'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('300000'),
|
||||
'planning-config.md should document the default value (300000)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── init execute-phase includes context_window ─────────────────────────────
|
||||
|
||||
describe('init execute-phase context_window (#1472)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('init execute-phase output includes context_window from config', () => {
|
||||
// Write config with a custom context_window value (1M for Opus/Sonnet 4.6)
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
context_window: 1000000,
|
||||
}, null, 2));
|
||||
|
||||
// Create a phase directory with a plan so init execute-phase succeeds
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
|
||||
|
||||
const result = runGsdTools('init execute-phase 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.context_window, 1000000, 'context_window should reflect configured value');
|
||||
});
|
||||
|
||||
test('init execute-phase uses default context_window when not configured', () => {
|
||||
// Write minimal config without context_window
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
|
||||
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
|
||||
|
||||
const result = runGsdTools('init execute-phase 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.context_window, 200000, 'default context_window should be 200000');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── config-get context_window ──────────────────────────────────────────────
|
||||
|
||||
describe('config-get context_window (#1472)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('config-get context_window returns the configured value', () => {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
context_window: 1000000,
|
||||
}, null, 2));
|
||||
|
||||
const result = runGsdTools('config-get context_window', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output, 1000000);
|
||||
});
|
||||
|
||||
test('config-get context_window errors when key is absent', () => {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
|
||||
|
||||
const result = runGsdTools('config-get context_window', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(
|
||||
result.error.includes('Key not found'),
|
||||
`Expected "Key not found" in error: ${result.error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── config-set workflow.subagent_timeout numeric coercion ──────────────────
|
||||
|
||||
describe('config-set workflow.subagent_timeout numeric values (#1472)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('config-set workflow.subagent_timeout coerces string to number', () => {
|
||||
const result = runGsdTools(['config-set', 'workflow.subagent_timeout', '900000'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.updated, true);
|
||||
assert.strictEqual(output.key, 'workflow.subagent_timeout');
|
||||
assert.strictEqual(output.value, 900000);
|
||||
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(config.workflow.subagent_timeout, 900000);
|
||||
assert.strictEqual(typeof config.workflow.subagent_timeout, 'number');
|
||||
});
|
||||
|
||||
test('config-set workflow.subagent_timeout round-trips through config-get', () => {
|
||||
runGsdTools(['config-set', 'workflow.subagent_timeout', '1200000'], tmpDir);
|
||||
|
||||
const result = runGsdTools('config-get workflow.subagent_timeout', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output, 1200000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user