Files
get-shit-done/tests/core.test.cjs
Tom Boucher 5afcd5577e fix: zero-padded phase numbers bypass archived-phase guard; stale current_milestone (#2458)
* fix(sdk): extractCurrentMilestone Backlog leak + state.begin-phase flag parsing

Closes #2422
Closes #2420

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): skip stateVersion early-return for shipped milestones

When STATE.md has a stale `milestone: v1.0` entry but v1.0 is already
shipped (heading contains  in ROADMAP.md), the stateVersion early-return
path in getMilestoneInfo was returning v1.0 instead of detecting the new
active milestone.

Two-part fix:
1. In the stateVersion block: skip the early-return when the matched
   heading line includes  (shipped marker). Fall through to normal
   detection instead.
2. In the heading-format fallback regex: add a negative lookahead
   `(?!.*)` so the regex never matches a  heading regardless of
   whether stateVersion was present. This handles the no-STATE.md case
   and ensures fallthrough from part 1 actually finds the next milestone.

Adds two regression tests covering both -suffix (`## v1.0  Name`)
and -prefix (`##  v1.0 Name`) heading formats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(core): allow padded-and-unpadded phase headings in getRoadmapPhaseInternal

The zero-strip normalization (01→1) fixed the archived-phase guard but
broke lookup against ROADMAP headings that still use zero-padded numbers
like "Phase 01:". Change the regex to use 0*<normalized> so both formats
match, making the fix robust regardless of ROADMAP heading style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:08:40 -04:00

1993 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GSD Tools Tests - core.cjs
*
* Tests for the foundational module's exports including regressions
* for known bugs (REG-01: loadConfig model_overrides, REG-02: getRoadmapPhaseInternal export).
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
const {
loadConfig,
resolveModelInternal,
escapeRegex,
generateSlugInternal,
normalizePhaseName,
reapStaleTempFiles,
normalizeMd,
comparePhaseNum,
safeReadFile,
pathExistsInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
getRoadmapPhaseInternal,
searchPhaseInDir,
findPhaseInternal,
findProjectRoot,
detectSubRepos,
planningDir,
timeAgo,
} = require('../get-shit-done/bin/lib/core.cjs');
// ─── loadConfig ────────────────────────────────────────────────────────────────
describe('loadConfig', () => {
let tmpDir;
let originalCwd;
beforeEach(() => {
tmpDir = createTempProject();
originalCwd = process.cwd();
});
afterEach(() => {
process.chdir(originalCwd);
cleanup(tmpDir);
});
function writeConfig(obj) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(obj, null, 2)
);
}
test('returns defaults when config.json is missing', () => {
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'balanced');
assert.strictEqual(config.commit_docs, true);
assert.strictEqual(config.research, true);
assert.strictEqual(config.plan_checker, true);
assert.strictEqual(config.brave_search, false);
assert.strictEqual(config.parallelization, true);
assert.strictEqual(config.nyquist_validation, true);
assert.strictEqual(config.text_mode, false);
});
test('reads model_profile from config.json', () => {
writeConfig({ model_profile: 'quality' });
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'quality');
});
test('reads nested config keys', () => {
writeConfig({ planning: { commit_docs: false } });
const config = loadConfig(tmpDir);
assert.strictEqual(config.commit_docs, false);
});
test('reads branching_strategy from git section', () => {
writeConfig({ git: { branching_strategy: 'per-phase' } });
const config = loadConfig(tmpDir);
assert.strictEqual(config.branching_strategy, 'per-phase');
});
// Bug: loadConfig previously omitted model_overrides from return value
test('returns model_overrides when present (REG-01)', () => {
writeConfig({ model_overrides: { 'gsd-executor': 'opus' } });
const config = loadConfig(tmpDir);
assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'opus' });
});
test('returns model_overrides as null when not in config', () => {
writeConfig({ model_profile: 'balanced' });
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_overrides, null);
});
test('reads response_language when set', () => {
writeConfig({ response_language: 'Portuguese' });
const config = loadConfig(tmpDir);
assert.strictEqual(config.response_language, 'Portuguese');
});
test('returns response_language as null when not set', () => {
writeConfig({ model_profile: 'balanced' });
const config = loadConfig(tmpDir);
assert.strictEqual(config.response_language, null);
});
test('returns defaults when config.json contains invalid JSON', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'not valid json {{{{'
);
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'balanced');
assert.strictEqual(config.commit_docs, true);
});
test('handles parallelization as boolean', () => {
writeConfig({ parallelization: false });
const config = loadConfig(tmpDir);
assert.strictEqual(config.parallelization, false);
});
test('handles parallelization as object with enabled field', () => {
writeConfig({ parallelization: { enabled: false } });
const config = loadConfig(tmpDir);
assert.strictEqual(config.parallelization, false);
});
test('prefers top-level keys over nested keys', () => {
writeConfig({ commit_docs: false, planning: { commit_docs: true } });
const config = loadConfig(tmpDir);
assert.strictEqual(config.commit_docs, false);
});
test('warns on unknown config keys to stderr (#1535)', (t) => {
writeConfig({ model_profile: 'quality', active_project: 'my-project', custom_flag: true });
const origWrite = process.stderr.write;
let stderrOutput = '';
process.stderr.write = (chunk) => { stderrOutput += chunk; };
t.after(() => { process.stderr.write = origWrite; });
const config = loadConfig(tmpDir);
// Known key still loads correctly
assert.strictEqual(config.model_profile, 'quality');
// Warning emitted for unknown keys
assert.ok(stderrOutput.includes('active_project'), 'should warn about active_project');
assert.ok(stderrOutput.includes('custom_flag'), 'should warn about custom_flag');
assert.ok(stderrOutput.includes('ignored'), 'should mention keys will be ignored');
});
test('known config keys are derived from VALID_CONFIG_KEYS (not hardcoded)', () => {
// Verify that loadConfig's unknown-key check uses config-set's VALID_CONFIG_KEYS
// as its source of truth. If a new key is added to config-set, it should
// automatically be recognized by loadConfig without a separate update.
const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config.cjs');
// Every top-level key from VALID_CONFIG_KEYS should be recognized
const topLevelKeys = [...VALID_CONFIG_KEYS].map(k => k.split('.')[0]);
for (const key of topLevelKeys) {
writeConfig({ [key]: 'test-value' });
const origWrite = process.stderr.write;
let stderrOutput = '';
process.stderr.write = (chunk) => { stderrOutput += chunk; };
try {
loadConfig(tmpDir);
assert.ok(
!stderrOutput.includes(key),
`VALID_CONFIG_KEYS key "${key}" should not trigger unknown-key warning`
);
} finally {
process.stderr.write = origWrite;
}
}
});
test('does not warn when all config keys are known', (t) => {
writeConfig({ model_profile: 'balanced', workflow: { research: false }, git: { branching_strategy: 'per-phase' } });
const origWrite = process.stderr.write;
let stderrOutput = '';
process.stderr.write = (chunk) => { stderrOutput += chunk; };
t.after(() => { process.stderr.write = origWrite; });
loadConfig(tmpDir);
assert.strictEqual(stderrOutput, '', 'should not emit any warnings for valid config');
});
});
// ─── loadConfig commit_docs gitignore auto-detection (#1250) ──────────────────
describe('loadConfig commit_docs gitignore auto-detection (#1250)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
});
afterEach(() => {
cleanup(tmpDir);
});
function writeConfig(obj) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(obj, null, 2)
);
}
test('commit_docs defaults to false when .planning/ is gitignored and no explicit config', () => {
fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n');
// No commit_docs in config — should auto-detect
writeConfig({ model_profile: 'balanced' });
const config = loadConfig(tmpDir);
assert.strictEqual(config.commit_docs, false,
'commit_docs should be false when .planning/ is gitignored and not explicitly set');
});
test('commit_docs defaults to true when .planning/ is NOT gitignored and no explicit config', () => {
// No .gitignore, no commit_docs in config
writeConfig({ model_profile: 'balanced' });
const config = loadConfig(tmpDir);
assert.strictEqual(config.commit_docs, true,
'commit_docs should default to true when .planning/ is not gitignored');
});
test('explicit commit_docs: false is respected even when .planning/ is not gitignored', () => {
writeConfig({ commit_docs: false });
const config = loadConfig(tmpDir);
assert.strictEqual(config.commit_docs, false);
});
test('explicit commit_docs: true is respected even when .planning/ is gitignored', () => {
fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n');
writeConfig({ commit_docs: true });
const config = loadConfig(tmpDir);
assert.strictEqual(config.commit_docs, true,
'explicit commit_docs: true should override gitignore auto-detection');
});
test('commit_docs auto-detect works with no config.json', () => {
// Remove config.json so loadConfig uses defaults
try { fs.unlinkSync(path.join(tmpDir, '.planning', 'config.json')); } catch {}
fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n');
const config = loadConfig(tmpDir);
// When config.json is missing, loadConfig catches and returns defaults.
// The gitignore check happens inside the try block, so with no config.json
// the catch returns defaults (commit_docs: true). This is acceptable since
// a project without config.json hasn't been initialized by GSD yet.
assert.strictEqual(typeof config.commit_docs, 'boolean');
});
});
// ─── resolveModelInternal ──────────────────────────────────────────────────────
describe('resolveModelInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
function writeConfig(obj) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(obj, null, 2)
);
}
describe('model profile structural validation', () => {
test('all known agents resolve to a valid string for each profile', () => {
const knownAgents = ['gsd-planner', 'gsd-executor', 'gsd-phase-researcher', 'gsd-codebase-mapper'];
const profiles = ['quality', 'balanced', 'budget', 'inherit'];
const validValues = ['inherit', 'sonnet', 'haiku', 'opus'];
for (const profile of profiles) {
writeConfig({ model_profile: profile });
for (const agent of knownAgents) {
const result = resolveModelInternal(tmpDir, agent);
assert.ok(
validValues.includes(result),
`profile=${profile} agent=${agent} returned unexpected value: ${result}`
);
}
}
});
test('inherit profile forces all known agents to inherit model', () => {
const knownAgents = ['gsd-planner', 'gsd-executor', 'gsd-phase-researcher', 'gsd-codebase-mapper'];
writeConfig({ model_profile: 'inherit' });
for (const agent of knownAgents) {
assert.strictEqual(resolveModelInternal(tmpDir, agent), 'inherit');
}
});
});
describe('override precedence', () => {
test('per-agent override takes precedence over profile', () => {
writeConfig({
model_profile: 'balanced',
model_overrides: { 'gsd-executor': 'haiku' },
});
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'haiku');
});
test('opus override resolves to opus', () => {
writeConfig({
model_overrides: { 'gsd-executor': 'opus' },
});
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'opus');
});
test('agents not in override fall back to profile', () => {
writeConfig({
model_profile: 'quality',
model_overrides: { 'gsd-executor': 'haiku' },
});
// gsd-planner not overridden, should use quality profile -> opus
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'opus');
});
});
describe('edge cases', () => {
test('returns sonnet for unknown agent type', () => {
writeConfig({ model_profile: 'balanced' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'sonnet');
});
test('returns sonnet for unknown agent type even with inherit profile', () => {
writeConfig({ model_profile: 'inherit' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'sonnet');
});
test('defaults to balanced profile when model_profile missing', () => {
writeConfig({});
// balanced profile, gsd-planner -> opus
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'opus');
});
});
describe('resolve_model_ids: "omit"', () => {
test('returns empty string for known agents', () => {
writeConfig({ resolve_model_ids: 'omit' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), '');
});
test('returns empty string for unknown agents', () => {
writeConfig({ resolve_model_ids: 'omit' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), '');
});
test('still respects model_overrides even when omit', () => {
writeConfig({
resolve_model_ids: 'omit',
model_overrides: { 'gsd-planner': 'openai/gpt-5.4' },
});
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'openai/gpt-5.4');
});
test('returns empty string with inherit profile', () => {
writeConfig({ resolve_model_ids: 'omit', model_profile: 'inherit' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), '');
});
});
});
// ─── escapeRegex ───────────────────────────────────────────────────────────────
describe('escapeRegex', () => {
test('escapes dots', () => {
assert.strictEqual(escapeRegex('file.txt'), 'file\\.txt');
});
test('escapes all special regex characters', () => {
const input = '1.0 (alpha) [test] {ok} $100 ^start end$ a+b a*b a?b pipe|or back\\slash';
const result = escapeRegex(input);
// Verify each special char is escaped
assert.ok(result.includes('\\.'));
assert.ok(result.includes('\\('));
assert.ok(result.includes('\\)'));
assert.ok(result.includes('\\['));
assert.ok(result.includes('\\]'));
assert.ok(result.includes('\\{'));
assert.ok(result.includes('\\}'));
assert.ok(result.includes('\\$'));
assert.ok(result.includes('\\^'));
assert.ok(result.includes('\\+'));
assert.ok(result.includes('\\*'));
assert.ok(result.includes('\\?'));
assert.ok(result.includes('\\|'));
assert.ok(result.includes('\\\\'));
});
test('handles empty string', () => {
assert.strictEqual(escapeRegex(''), '');
});
test('returns plain string unchanged', () => {
assert.strictEqual(escapeRegex('hello'), 'hello');
});
});
// ─── generateSlugInternal ──────────────────────────────────────────────────────
describe('generateSlugInternal', () => {
test('converts text to lowercase kebab-case', () => {
assert.strictEqual(generateSlugInternal('Hello World'), 'hello-world');
});
test('removes special characters', () => {
assert.strictEqual(generateSlugInternal('core.cjs Tests!'), 'core-cjs-tests');
});
test('trims leading and trailing hyphens', () => {
assert.strictEqual(generateSlugInternal('---hello---'), 'hello');
});
test('returns null for null input', () => {
assert.strictEqual(generateSlugInternal(null), null);
});
test('returns null for empty string', () => {
assert.strictEqual(generateSlugInternal(''), null);
});
test('strips newlines and control characters', () => {
assert.strictEqual(generateSlugInternal('hello\nworld'), 'hello-world');
assert.strictEqual(generateSlugInternal('tab\there'), 'tab-here');
});
test('truncates to 60 characters', () => {
const long = 'a'.repeat(100);
const result = generateSlugInternal(long);
assert.ok(result.length <= 60, `slug should be <=60 chars, got ${result.length}`);
});
});
// ─── normalizePhaseName / comparePhaseNum ──────────────────────────────────────
// NOTE: Comprehensive tests for normalizePhaseName and comparePhaseNum are in
// phase.test.cjs (which covers all edge cases: hybrid, letter-suffix,
// multi-level decimal, case-insensitive, directory-slug, and full sort order).
// Removed duplicates here to keep a single authoritative test location.
// ─── safeReadFile ──────────────────────────────────────────────────────────────
describe('safeReadFile', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('reads existing file', () => {
const filePath = path.join(tmpDir, 'test.txt');
fs.writeFileSync(filePath, 'hello world');
assert.strictEqual(safeReadFile(filePath), 'hello world');
});
test('returns null for missing file', () => {
assert.strictEqual(safeReadFile('/nonexistent/path/file.txt'), null);
});
});
// ─── pathExistsInternal ────────────────────────────────────────────────────────
describe('pathExistsInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns true for existing path', () => {
assert.strictEqual(pathExistsInternal(tmpDir, '.planning'), true);
});
test('returns false for non-existing path', () => {
assert.strictEqual(pathExistsInternal(tmpDir, 'nonexistent'), false);
});
test('handles absolute paths', () => {
assert.strictEqual(pathExistsInternal(tmpDir, tmpDir), true);
});
});
// ─── getMilestoneInfo ──────────────────────────────────────────────────────────
describe('getMilestoneInfo', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('extracts version and name from roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Roadmap v1.2: My Cool Project\n\nSome content'
);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v1.2');
assert.strictEqual(info.name, 'My Cool Project');
});
test('returns defaults when roadmap missing', () => {
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v1.0');
assert.strictEqual(info.name, 'milestone');
});
test('returns active milestone when shipped milestone is collapsed in details block', () => {
const roadmap = [
'# Milestones',
'',
'| Version | Status |',
'|---------|--------|',
'| v0.1 | Shipped |',
'| v0.2 | Active |',
'',
'<details>',
'<summary>v0.1 — Legacy Feature Parity (Shipped)</summary>',
'',
'## Roadmap v0.1: Legacy Feature Parity',
'',
'### Phase 1: Core Setup',
'Some content about phase 1',
'',
'</details>',
'',
'## Roadmap v0.2: Dashboard Overhaul',
'',
'### Phase 8: New Dashboard Layout',
'Some content about phase 8',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v0.2');
assert.strictEqual(info.name, 'Dashboard Overhaul');
});
test('returns active milestone when multiple shipped milestones exist in details blocks', () => {
const roadmap = [
'# Milestones',
'',
'| Version | Status |',
'|---------|--------|',
'| v0.1 | Shipped |',
'| v0.2 | Shipped |',
'| v0.3 | Active |',
'',
'<details>',
'<summary>v0.1 — Initial Release (Shipped)</summary>',
'',
'## Roadmap v0.1: Initial Release',
'',
'</details>',
'',
'<details>',
'<summary>v0.2 — Feature Expansion (Shipped)</summary>',
'',
'## Roadmap v0.2: Feature Expansion',
'',
'</details>',
'',
'## Roadmap v0.3: Performance Tuning',
'',
'### Phase 12: Optimize Queries',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v0.3');
assert.strictEqual(info.name, 'Performance Tuning');
});
test('returns defaults when roadmap has no heading matches', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\nSome content without version headings'
);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v1.0');
assert.strictEqual(info.name, 'milestone');
});
// Bug #2409: getMilestoneInfo must prefer STATE.md milestone: field over regex matching
test('uses STATE.md milestone frontmatter when 🚧 is inside <summary> tag without bold (bug #2409)', () => {
// STATE.md says v2.9, ROADMAP has 🚧 v2.9 inside <summary> (not bolded) — no bold regex match
const roadmap = [
'# Milestones',
'',
'- ✅ v2.2 Old Features — shipped 2026-04-03',
'- 🚧 v2.9 Full-Pass Verification',
'',
'<details>',
'<summary>🚧 v2.9 Full-Pass Verification & Bug Fixing — IN PROGRESS</summary>',
'',
'## Roadmap v2.9: Full-Pass Verification & Bug Fixing',
'',
'### Phase 1: Verification',
'',
'</details>',
'',
'## Phase Details (v2.2 — Old Features)',
'',
'### Phase 1: Old Stuff',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v2.9\n---\n\n# State\n'
);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v2.9',
'should return v2.9 from STATE.md, not v2.2 from a stale heading match');
assert.ok(info.name.includes('Full-Pass') || info.name.includes('Verification'),
`name should reference the v2.9 milestone, got: "${info.name}"`);
});
test('STATE.md milestone takes precedence over first ## heading match (bug #2409)', () => {
// ROADMAP with multiple ## headings — without STATE.md anchoring, first match wins
const roadmap = [
'## Phase Details (v1.5v2.1)',
'',
'## Roadmap v2.2: Old Milestone',
'',
'## Roadmap v2.9: Current Milestone',
'',
'### Phase 1: Alpha',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v2.9\n---\n\n# State\n'
);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v2.9',
'should read v2.9 from STATE.md, not v2.2 from first ## heading');
assert.strictEqual(info.name, 'Current Milestone');
});
// Bug found in code review of PR #2458: stateVersion early-return doesn't check if shipped
test('falls through to new active milestone when STATE.md version is already shipped (✅ heading)', () => {
// STATE.md still says v1.0 (stale), but v1.0 is marked ✅ in ROADMAP.md.
// getMilestoneInfo must NOT return v1.0; it must fall through and detect v2.0.
const roadmap = [
'## v1.0 ✅ Initial Release: Done',
'',
'### Phase 1: Setup',
'',
'## v2.0: Active Milestone',
'',
'### Phase 2: Build',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v1.0\n---\n\n# State\n'
);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v2.0',
'should return v2.0 (active milestone), not v1.0 (stale shipped milestone from STATE.md)');
assert.strictEqual(info.name, 'Active Milestone');
});
test('falls through when STATE.md version matches ✅ heading in alternate position formats', () => {
// ✅ can appear before the version: ## ✅ v1.0 Old Name
const roadmap = [
'## ✅ v1.0 Old Name',
'',
'## v2.0: New Stuff',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v1.0\n---\n\n# State\n'
);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v2.0',
'should return v2.0, not stale v1.0 with ✅ prefix in heading');
assert.strictEqual(info.name, 'New Stuff');
});
});
// ─── searchPhaseInDir ──────────────────────────────────────────────────────────
describe('searchPhaseInDir', () => {
let tmpDir;
let phasesDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
phasesDir = path.join(tmpDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('finds phase directory by normalized prefix', () => {
fs.mkdirSync(path.join(phasesDir, '01-foundation'));
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '01');
assert.strictEqual(result.phase_name, 'foundation');
});
test('returns plans and summaries', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.ok(result.plans.includes('01-01-PLAN.md'));
assert.ok(result.summaries.includes('01-01-SUMMARY.md'));
assert.strictEqual(result.incomplete_plans.length, 0);
});
test('identifies incomplete plans', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.incomplete_plans.length, 1);
assert.ok(result.incomplete_plans.includes('01-02-PLAN.md'));
});
test('detects research and context files', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-RESEARCH.md'), '# Research');
fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# Context');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.has_research, true);
assert.strictEqual(result.has_context, true);
});
test('returns null when phase not found', () => {
fs.mkdirSync(path.join(phasesDir, '01-foundation'));
const result = searchPhaseInDir(phasesDir, '.planning/phases', '99');
assert.strictEqual(result, null);
});
test('generates phase_slug from directory name', () => {
fs.mkdirSync(path.join(phasesDir, '01-core-cjs-tests'));
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.phase_slug, 'core-cjs-tests');
});
});
// ─── findPhaseInternal ─────────────────────────────────────────────────────────
describe('findPhaseInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('finds phase in current phases directory', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'));
const result = findPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '01');
});
test('returns null for non-existent phase', () => {
const result = findPhaseInternal(tmpDir, '99');
assert.strictEqual(result, null);
});
test('returns null for null phase', () => {
const result = findPhaseInternal(tmpDir, null);
assert.strictEqual(result, null);
});
test('searches archived milestones when not in current', () => {
// Create archived milestone structure (no current phase match)
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation');
fs.mkdirSync(archiveDir, { recursive: true });
const result = findPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.archived, 'v1.0');
});
});
// ─── getRoadmapPhaseInternal ───────────────────────────────────────────────────
describe('getRoadmapPhaseInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// Bug: getRoadmapPhaseInternal was missing from module.exports
test('is exported from core.cjs (REG-02)', () => {
assert.strictEqual(typeof getRoadmapPhaseInternal, 'function');
// Also verify it works with a real roadmap (note: goal regex expects **Goal:** with colon inside bold)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal:** Build the base\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Foundation');
assert.strictEqual(result.goal, 'Build the base');
});
test('extracts phase name and goal from roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 2: API Layer\n**Goal:** Create REST endpoints\n**Depends on**: Phase 1\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '2');
assert.strictEqual(result.phase_name, 'API Layer');
assert.strictEqual(result.goal, 'Create REST endpoints');
});
test('returns goal when Goal uses colon-outside-bold format', () => {
// **Goal**: (colon outside bold) is now supported alongside **Goal:**
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Foundation');
assert.strictEqual(result.goal, 'Build the base');
});
test('returns null when roadmap missing', () => {
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result, null);
});
test('returns null when phase not in roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '99');
assert.strictEqual(result, null);
});
test('returns null for null phase number', () => {
const result = getRoadmapPhaseInternal(tmpDir, null);
assert.strictEqual(result, null);
});
test('extracts full section text', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n**Requirements**: TEST-01\nSome details here\n\n### Phase 2: API\n**Goal**: REST\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.ok(result.section.includes('Phase 1: Foundation'));
assert.ok(result.section.includes('Some details here'));
// Should not include Phase 2 content
assert.ok(!result.section.includes('Phase 2: API'));
});
// Bug #2391: zero-padded phase numbers ("03") must match unpadded ROADMAP headings ("Phase 3:")
test('matches zero-padded phase number against unpadded ROADMAP heading (bug #2391)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 3: Rotation Engine\n**Goal**: Build rotation\n**Requirements**: ROTA-01\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '03');
assert.ok(result !== null, 'should find the phase with zero-padded input "03"');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Rotation Engine');
assert.strictEqual(result.goal, 'Build rotation');
});
test('matches double-zero-padded phase number against unpadded ROADMAP heading (bug #2391)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 7: Final\n**Goal**: Ship it\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '007');
assert.ok(result !== null, 'should find the phase with "007"');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Final');
});
test('unpadded lookup still works after fix (regression check)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 3: Rotation Engine\n**Goal**: Build rotation\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '3');
assert.ok(result !== null);
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Rotation Engine');
});
});
// ─── getMilestonePhaseFilter ────────────────────────────────────────────────────
describe('getMilestonePhaseFilter', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('filters directories to only current milestone phases', () => {
// ROADMAP lists only phases 5-7
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'## Roadmap v2.0: Next Release',
'',
'### Phase 5: Auth',
'**Goal:** Add authentication',
'',
'### Phase 6: Dashboard',
'**Goal:** Build dashboard',
'',
'### Phase 7: Polish',
'**Goal:** Final polish',
].join('\n')
);
// Create phase dirs 1-7 on disk (leftover from previous milestones)
for (let i = 1; i <= 7; i++) {
const padded = String(i).padStart(2, '0');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`));
}
const filter = getMilestonePhaseFilter(tmpDir);
// Only phases 5, 6, 7 should match
assert.strictEqual(filter('05-auth'), true);
assert.strictEqual(filter('06-dashboard'), true);
assert.strictEqual(filter('07-polish'), true);
// Phases 1-4 should NOT match
assert.strictEqual(filter('01-phase-1'), false);
assert.strictEqual(filter('02-phase-2'), false);
assert.strictEqual(filter('03-phase-3'), false);
assert.strictEqual(filter('04-phase-4'), false);
});
test('returns pass-all filter when ROADMAP.md is missing', () => {
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter('01-foundation'), true);
assert.strictEqual(filter('99-anything'), true);
});
test('returns pass-all filter when ROADMAP has no phase headings', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\nSome content without phases.\n'
);
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter('01-foundation'), true);
assert.strictEqual(filter('05-api'), true);
});
test('handles letter-suffix phases (e.g. 3A)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 3A: Sub-feature\n**Goal:** Sub work\n'
);
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter('03A-sub-feature'), true);
assert.strictEqual(filter('03-main'), false);
assert.strictEqual(filter('04-other'), false);
});
test('handles decimal phases (e.g. 5.1)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 5: Main\n**Goal:** Main work\n\n### Phase 5.1: Patch\n**Goal:** Patch work\n'
);
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter('05-main'), true);
assert.strictEqual(filter('05.1-patch'), true);
assert.strictEqual(filter('04-other'), false);
});
test('returns false for non-phase directory names', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Init\n**Goal:** Start\n'
);
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter('not-a-phase'), false);
assert.strictEqual(filter('.gitkeep'), false);
});
test('phaseCount reflects ROADMAP phase count', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 5: Auth\n### Phase 6: Dashboard\n### Phase 7: Polish\n'
);
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter.phaseCount, 3);
});
test('phaseCount is 0 when ROADMAP is missing', () => {
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter.phaseCount, 0);
});
test('phaseCount is 0 when ROADMAP has no phase headings', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\nSome content.\n'
);
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter.phaseCount, 0);
});
});
// ─── normalizeMd ─────────────────────────────────────────────────────────────
describe('normalizeMd', () => {
test('returns null/undefined/empty unchanged', () => {
assert.strictEqual(normalizeMd(null), null);
assert.strictEqual(normalizeMd(undefined), undefined);
assert.strictEqual(normalizeMd(''), '');
});
test('MD022: adds blank lines around headings', () => {
const input = 'Some text\n## Heading\nMore text\n';
const result = normalizeMd(input);
assert.ok(result.includes('\n\n## Heading\n\n'), 'heading should have blank lines around it');
});
test('MD032: adds blank line before list after non-list content', () => {
const input = 'Some text\n- item 1\n- item 2\n';
const result = normalizeMd(input);
assert.ok(result.includes('Some text\n\n- item 1'), 'list should have blank line before it');
});
test('MD032: adds blank line after list before non-list content', () => {
const input = '- item 1\n- item 2\nSome text\n';
const result = normalizeMd(input);
assert.ok(result.includes('- item 2\n\nSome text'), 'list should have blank line after it');
});
test('MD032: does not add extra blank lines between list items', () => {
const input = '- item 1\n- item 2\n- item 3\n';
const result = normalizeMd(input);
assert.ok(result.includes('- item 1\n- item 2\n- item 3'), 'consecutive list items should not get blank lines');
});
test('MD031: adds blank lines around fenced code blocks', () => {
const input = 'Some text\n```js\ncode\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');
});
test('MD012: collapses 3+ consecutive blank lines to 2', () => {
const input = 'Line 1\n\n\n\n\nLine 2\n';
const result = normalizeMd(input);
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
assert.ok(result.includes('Line 1\n\nLine 2'), 'should collapse to double newline');
});
test('MD047: ensures file ends with single newline', () => {
const input = 'Content';
const result = normalizeMd(input);
assert.ok(result.endsWith('\n'), 'should end with newline');
assert.ok(!result.endsWith('\n\n'), 'should not end with double newline');
});
test('MD047: trims trailing multiple newlines', () => {
const input = 'Content\n\n\n';
const result = normalizeMd(input);
assert.ok(result.endsWith('Content\n'), 'should end with single newline after content');
});
test('preserves frontmatter delimiters', () => {
const input = '---\nkey: value\n---\n\n# Heading\n\nContent\n';
const result = normalizeMd(input);
assert.ok(result.startsWith('---\n'), 'should preserve opening frontmatter');
assert.ok(result.includes('---\n\n# Heading'), 'should preserve frontmatter closing');
});
test('handles CRLF line endings', () => {
const input = 'Some text\r\n## Heading\r\nMore text\r\n';
const result = normalizeMd(input);
assert.ok(!result.includes('\r'), 'should normalize to LF');
assert.ok(result.includes('\n\n## Heading\n\n'), 'should add blank lines around heading');
});
test('handles ordered lists', () => {
const input = 'Some text\n1. First\n2. Second\nMore text\n';
const result = normalizeMd(input);
assert.ok(result.includes('Some text\n\n1. First'), 'ordered list should have blank line before');
});
test('does not add blank line between table and list', () => {
const input = '| Col |\n|-----|\n| val |\n- item\n';
const result = normalizeMd(input);
// Table rows start with |, should not add extra blank before list after table
assert.ok(result.includes('| val |\n\n- item'), 'list after table should have blank line');
});
test('complex real-world STATE.md-like content', () => {
const input = [
'# Project State',
'## Current Position',
'Phase: 5 of 10',
'Status: Executing',
'## Decisions',
'- Decision 1',
'- Decision 2',
'## Blockers',
'None',
].join('\n');
const result = normalizeMd(input);
// Every heading should have blank lines around it
assert.ok(result.includes('\n\n## Current Position\n\n'), 'section heading needs blank lines');
assert.ok(result.includes('\n\n## Decisions\n\n'), 'decisions heading needs blank lines');
assert.ok(result.includes('\n\n## Blockers\n\n'), 'blockers heading needs blank lines');
// List should have blank line before it
assert.ok(result.includes('\n\n- Decision 1'), 'list needs blank line before');
});
});
// ─── Stale hook filter regression (#1200) ─────────────────────────────────────
describe('stale hook filter', () => {
test('filter should only match gsd-prefixed .js files', () => {
const files = [
'gsd-check-update.js',
'gsd-context-monitor.js',
'gsd-prompt-guard.js',
'gsd-statusline.js',
'gsd-workflow-guard.js',
'guard-edits-outside-project.js', // user hook
'my-custom-hook.js', // user hook
'gsd-check-update.js.bak', // backup file
'README.md', // non-js file
];
const gsdFilter = f => f.startsWith('gsd-') && f.endsWith('.js');
const filtered = files.filter(gsdFilter);
assert.deepStrictEqual(filtered, [
'gsd-check-update.js',
'gsd-context-monitor.js',
'gsd-prompt-guard.js',
'gsd-statusline.js',
'gsd-workflow-guard.js',
], 'should only include gsd-prefixed .js files');
assert.ok(!filtered.includes('guard-edits-outside-project.js'), 'must not include user hooks');
assert.ok(!filtered.includes('my-custom-hook.js'), 'must not include non-gsd hooks');
});
});
// ─── stale hook path regression (#1249) ──────────────────────────────────────
describe('stale hook path', () => {
test('gsd-check-update.js checks configDir/hooks/ where hooks are actually installed (#1421)', () => {
// The stale-hook scan logic lives in the worker (moved from inline -e template literal).
// The worker receives configDir via env and constructs the hooksDir path.
const content = fs.readFileSync(
path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js'), 'utf-8'
);
// Hooks are installed at configDir/hooks/ (e.g. ~/.claude/hooks/),
// not configDir/get-shit-done/hooks/ which doesn't exist (#1421)
assert.ok(
content.includes("path.join(configDir, 'hooks')"),
'stale hook check must look in configDir/hooks/ where hooks are actually installed'
);
});
});
// ─── shared cache directory regression (#1421) ─────────────────────────────────
describe('shared cache directory (#1421)', () => {
test('gsd-check-update.js writes cache to shared ~/.cache/gsd/ directory', () => {
const content = fs.readFileSync(
path.join(__dirname, '..', 'hooks', 'gsd-check-update.js'), 'utf-8'
);
// Cache must use a tool-agnostic path so statusline can find it
// regardless of which runtime (Claude, Gemini, OpenCode) ran the check
assert.ok(
content.includes("path.join(homeDir, '.cache', 'gsd')"),
'check-update must write cache to ~/.cache/gsd/ (shared, tool-agnostic)'
);
});
test('gsd-statusline.js checks shared cache first, falls back to legacy (#1421)', () => {
const content = fs.readFileSync(
path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'), 'utf-8'
);
// Statusline must check the shared cache path first
assert.ok(
content.includes("path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json')"),
'statusline must check shared cache at ~/.cache/gsd/gsd-update-check.json'
);
// Must fall back to legacy runtime-specific cache for backward compat
assert.ok(
content.includes("path.join(claudeDir, 'cache', 'gsd-update-check.json')"),
'statusline must fall back to legacy cache at claudeDir/cache/gsd-update-check.json'
);
// Shared cache must be checked before legacy (existsSync order matters)
const sharedIdx = content.indexOf('sharedCacheFile');
const legacyIdx = content.indexOf('legacyCacheFile');
assert.ok(
sharedIdx < legacyIdx,
'shared cache must be defined and checked before legacy cache'
);
});
});
// ─── resolveWorktreeRoot ─────────────────────────────────────────────────────
describe('resolveWorktreeRoot', () => {
const { resolveWorktreeRoot } = require('../get-shit-done/bin/lib/core.cjs');
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns cwd when not in a git repo', () => {
assert.strictEqual(resolveWorktreeRoot(tmpDir), tmpDir);
});
test('returns cwd in a normal git repo (not a worktree)', () => {
const { execSync: execSyncLocal } = require('child_process');
execSyncLocal('git init', { cwd: tmpDir, stdio: 'pipe' });
assert.strictEqual(resolveWorktreeRoot(tmpDir), tmpDir);
});
});
// ─── resolveWorktreeRoot — linked worktree with .planning/ (#1315) ───────────
describe('resolveWorktreeRoot with linked worktree .planning/', () => {
const { resolveWorktreeRoot } = require('../get-shit-done/bin/lib/core.cjs');
const { execSync: execSyncLocal } = require('child_process');
// On Windows CI, os.tmpdir() may return 8.3 short paths (RUNNER~1) while
// git returns long paths (runneradmin). realpathSync.native resolves both.
const normalizePath = (p) => {
try { return fs.realpathSync.native(p); } catch { return fs.realpathSync(p); }
};
let mainDir;
let worktreeDir;
function initBareGitRepo() {
const dir = normalizePath(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-main-')));
execSyncLocal('git init', { cwd: dir, stdio: 'pipe' });
execSyncLocal('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
execSyncLocal('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
execSyncLocal('git config commit.gpgsign false', { cwd: dir, stdio: 'pipe' });
fs.writeFileSync(path.join(dir, 'README.md'), '# Main');
execSyncLocal('git add -A', { cwd: dir, stdio: 'pipe' });
execSyncLocal('git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
return dir;
}
beforeEach(() => {
mainDir = initBareGitRepo();
worktreeDir = null;
});
afterEach(() => {
if (worktreeDir) {
try { execSyncLocal(`git worktree remove "${worktreeDir}" --force`, { cwd: mainDir, stdio: 'pipe' }); } catch { /* ok */ }
try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch { /* ok */ }
}
cleanup(mainDir);
});
test('returns linked worktree cwd when it has its own .planning/', () => {
// Add .planning/ to main repo
fs.mkdirSync(path.join(mainDir, '.planning'), { recursive: true });
// Create a linked worktree
worktreeDir = normalizePath(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-linked-')));
fs.rmSync(worktreeDir, { recursive: true, force: true });
execSyncLocal(`git worktree add "${worktreeDir}" -b test-linked`, { cwd: mainDir, stdio: 'pipe' });
// Give the linked worktree its own .planning/
fs.mkdirSync(path.join(worktreeDir, '.planning'), { recursive: true });
// resolveWorktreeRoot should return the linked worktree dir, not the main repo
const result = normalizePath(resolveWorktreeRoot(worktreeDir));
assert.strictEqual(result, worktreeDir,
'linked worktree with .planning/ should resolve to itself, not the main repo');
});
test('returns main repo root when linked worktree has no .planning/', () => {
// Create a linked worktree (no .planning/ in main or worktree)
worktreeDir = normalizePath(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-linked-')));
fs.rmSync(worktreeDir, { recursive: true, force: true });
execSyncLocal(`git worktree add "${worktreeDir}" -b test-linked-no-plan`, { cwd: mainDir, stdio: 'pipe' });
// resolveWorktreeRoot should return the main repo root
const result = normalizePath(resolveWorktreeRoot(worktreeDir));
const expected = normalizePath(mainDir);
assert.strictEqual(result, expected,
'linked worktree without .planning/ should resolve to main repo root');
});
});
// ─── monorepo worktree CWD preservation (#1283) ─────────────────────────────
describe('monorepo worktree CWD preservation', () => {
const { resolveWorktreeRoot } = require('../get-shit-done/bin/lib/core.cjs');
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-monorepo-wt-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('CWD with .planning/ skips worktree resolution (monorepo subdirectory)', () => {
const subDir = path.join(tmpDir, 'service-alpha');
fs.mkdirSync(path.join(subDir, '.planning'), { recursive: true });
let cwd = subDir;
if (!fs.existsSync(path.join(cwd, '.planning'))) {
const worktreeRoot = resolveWorktreeRoot(cwd);
if (worktreeRoot !== cwd) cwd = worktreeRoot;
}
assert.strictEqual(cwd, subDir, 'CWD with .planning/ must not be overridden by worktree resolution');
});
test('CWD without .planning/ still goes through worktree resolution', () => {
let cwd = tmpDir;
let worktreeResolutionCalled = false;
if (!fs.existsSync(path.join(cwd, '.planning'))) {
worktreeResolutionCalled = true;
const worktreeRoot = resolveWorktreeRoot(cwd);
if (worktreeRoot !== cwd) cwd = worktreeRoot;
}
assert.ok(worktreeResolutionCalled, 'worktree resolution must be called when .planning/ is absent');
});
});
// ─── withPlanningLock ────────────────────────────────────────────────────────
describe('withPlanningLock', () => {
const { withPlanningLock, planningDir } = require('../get-shit-done/bin/lib/core.cjs');
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('executes function and returns result', () => {
const result = withPlanningLock(tmpDir, () => 42);
assert.strictEqual(result, 42);
// Lock file should be cleaned up
assert.ok(!fs.existsSync(path.join(planningDir(tmpDir), '.lock')));
});
test('cleans up lock file even on error', () => {
assert.throws(() => {
withPlanningLock(tmpDir, () => { throw new Error('test'); });
}, /test/);
assert.ok(!fs.existsSync(path.join(planningDir(tmpDir), '.lock')));
});
test('recovers from stale lock (>30s old)', () => {
const lockPath = path.join(tmpDir, '.planning', '.lock');
// Create a stale lock
fs.writeFileSync(lockPath, '{"pid":99999}');
// Backdate the lock file by 31 seconds
const staleTime = new Date(Date.now() - 31000);
fs.utimesSync(lockPath, staleTime, staleTime);
const result = withPlanningLock(tmpDir, () => 'recovered');
assert.strictEqual(result, 'recovered');
});
});
// ─── detectSubRepos ──────────────────────────────────────────────────────────
describe('detectSubRepos', () => {
let projectRoot;
beforeEach(() => {
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-detect-test-'));
});
afterEach(() => {
fs.rmSync(projectRoot, { recursive: true, force: true });
});
test('returns empty array when no child directories have .git', () => {
fs.mkdirSync(path.join(projectRoot, 'src'));
fs.mkdirSync(path.join(projectRoot, 'lib'));
assert.deepStrictEqual(detectSubRepos(projectRoot), []);
});
test('detects directories with .git', () => {
fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'frontend', '.git'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'scripts')); // no .git
assert.deepStrictEqual(detectSubRepos(projectRoot), ['backend', 'frontend']);
});
test('returns sorted results', () => {
fs.mkdirSync(path.join(projectRoot, 'zeta', '.git'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'alpha', '.git'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'mid', '.git'), { recursive: true });
assert.deepStrictEqual(detectSubRepos(projectRoot), ['alpha', 'mid', 'zeta']);
});
test('skips hidden directories', () => {
fs.mkdirSync(path.join(projectRoot, '.hidden', '.git'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'visible', '.git'), { recursive: true });
assert.deepStrictEqual(detectSubRepos(projectRoot), ['visible']);
});
test('skips node_modules', () => {
fs.mkdirSync(path.join(projectRoot, 'node_modules', '.git'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'app', '.git'), { recursive: true });
assert.deepStrictEqual(detectSubRepos(projectRoot), ['app']);
});
});
// ─── loadConfig sub_repos auto-sync ──────────────────────────────────────────
describe('loadConfig sub_repos auto-sync', () => {
let projectRoot;
beforeEach(() => {
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-sync-test-'));
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
});
afterEach(() => {
fs.rmSync(projectRoot, { recursive: true, force: true });
});
test('migrates multiRepo: true to sub_repos array', () => {
// Create config with legacy multiRepo flag
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ multiRepo: true, model_profile: 'quality' })
);
// Create sub-repos
fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'frontend', '.git'), { recursive: true });
const config = loadConfig(projectRoot);
assert.deepStrictEqual(config.sub_repos, ['backend', 'frontend']);
assert.strictEqual(config.commit_docs, false);
// Verify config was persisted
const saved = JSON.parse(fs.readFileSync(path.join(projectRoot, '.planning', 'config.json'), 'utf-8'));
assert.deepStrictEqual(saved.sub_repos, ['backend', 'frontend']);
assert.strictEqual(saved.multiRepo, undefined, 'multiRepo should be removed');
});
test('adds newly detected repos to sub_repos', () => {
fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['backend'] })
);
// Add a new repo
fs.mkdirSync(path.join(projectRoot, 'frontend', '.git'), { recursive: true });
const config = loadConfig(projectRoot);
assert.deepStrictEqual(config.sub_repos, ['backend', 'frontend']);
});
test('removes repos that no longer have .git', () => {
fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['backend', 'old-repo'] })
);
const config = loadConfig(projectRoot);
assert.deepStrictEqual(config.sub_repos, ['backend']);
});
test('does not sync when sub_repos is empty and no repos detected', () => {
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: [] })
);
const config = loadConfig(projectRoot);
assert.deepStrictEqual(config.sub_repos, []);
});
});
// ─── findProjectRoot ─────────────────────────────────────────────────────────
describe('findProjectRoot', () => {
let projectRoot;
beforeEach(() => {
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-root-test-'));
});
afterEach(() => {
fs.rmSync(projectRoot, { recursive: true, force: true });
});
test('returns startDir when no .planning/ exists anywhere', () => {
const subDir = path.join(projectRoot, 'backend');
fs.mkdirSync(subDir);
assert.strictEqual(findProjectRoot(subDir), subDir);
});
test('returns startDir when .planning/ is in startDir itself', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
assert.strictEqual(findProjectRoot(projectRoot), projectRoot);
});
test('walks up to parent with .planning/ and sub_repos config listing this dir', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['backend', 'frontend'] })
);
const backendDir = path.join(projectRoot, 'backend');
fs.mkdirSync(backendDir);
assert.strictEqual(findProjectRoot(backendDir), projectRoot);
});
test('walks up from nested sub-repo subdirectory', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['backend', 'frontend'] })
);
const deepDir = path.join(projectRoot, 'backend', 'src', 'services');
fs.mkdirSync(deepDir, { recursive: true });
assert.strictEqual(findProjectRoot(deepDir), projectRoot);
});
test('walks up via legacy multiRepo flag', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ multiRepo: true })
);
const backendDir = path.join(projectRoot, 'backend');
fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
assert.strictEqual(findProjectRoot(backendDir), projectRoot);
});
test('walks up via .git heuristic when no config exists', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
// No config.json at all
const backendDir = path.join(projectRoot, 'backend');
fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
assert.strictEqual(findProjectRoot(backendDir), projectRoot);
});
test('walks up from nested path inside sub-repo via .git heuristic', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
// Sub-repo with .git at its root
const backendDir = path.join(projectRoot, 'backend');
fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
// Nested path deep inside the sub-repo
const nestedDir = path.join(backendDir, 'src', 'modules', 'auth');
fs.mkdirSync(nestedDir, { recursive: true });
// isInsideGitRepo walks up and finds backend/.git
assert.strictEqual(findProjectRoot(nestedDir), projectRoot);
});
test('walks up from nested path inside sub-repo via sub_repos config', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['backend'] })
);
// Nested path deep inside the sub-repo
const nestedDir = path.join(projectRoot, 'backend', 'src', 'modules');
fs.mkdirSync(nestedDir, { recursive: true });
// With sub_repos config, it checks topSegment of relative path
assert.strictEqual(findProjectRoot(nestedDir), projectRoot);
});
test('walks up from nested path via legacy multiRepo flag', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ multiRepo: true })
);
const backendDir = path.join(projectRoot, 'backend');
fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
// Nested inside sub-repo — isInsideGitRepo walks up and finds backend/.git
const nestedDir = path.join(backendDir, 'src');
fs.mkdirSync(nestedDir, { recursive: true });
assert.strictEqual(findProjectRoot(nestedDir), projectRoot);
});
test('does not walk up for dirs without .git when no sub_repos config', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
const scriptsDir = path.join(projectRoot, 'scripts');
fs.mkdirSync(scriptsDir);
assert.strictEqual(findProjectRoot(scriptsDir), scriptsDir);
});
test('handles planning.sub_repos nested config format', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ planning: { sub_repos: ['backend'] } })
);
const backendDir = path.join(projectRoot, 'backend');
fs.mkdirSync(backendDir);
assert.strictEqual(findProjectRoot(backendDir), projectRoot);
});
test('returns startDir when sub_repos is empty and no .git', () => {
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: [] })
);
const backendDir = path.join(projectRoot, 'backend');
fs.mkdirSync(backendDir);
assert.strictEqual(findProjectRoot(backendDir), backendDir);
});
test('walks up from subdirectory when .git is at same level as .planning/ (single-repo)', () => {
// Common single-repo layout: .git and .planning are siblings at project root
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
// User cwd is a subdirectory (e.g., src/)
const srcDir = path.join(projectRoot, 'src');
fs.mkdirSync(srcDir, { recursive: true });
// Should detect that parent has .planning/ and .git is at that same level
assert.strictEqual(findProjectRoot(srcDir), projectRoot);
});
test('walks up from deep subdirectory when .git is at same level as .planning/', () => {
// Single-repo: .git and .planning at root, cwd deep inside
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
const deepDir = path.join(projectRoot, 'src', 'lib', 'utils');
fs.mkdirSync(deepDir, { recursive: true });
assert.strictEqual(findProjectRoot(deepDir), projectRoot);
});
test('returns startDir when .planning exists at same level (cwd is project root)', () => {
// User is already at project root — no parent to walk up to
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
assert.strictEqual(findProjectRoot(projectRoot), projectRoot);
});
test('does not walk past child with own .planning/ to workspace parent (#1362)', () => {
// Workspace layout: parent has .planning/, child git repo also has .planning/
// findProjectRoot should return the child (startDir), not the parent
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
const childRepo = path.join(projectRoot, 'authenticator');
fs.mkdirSync(path.join(childRepo, '.planning'), { recursive: true });
fs.mkdirSync(path.join(childRepo, '.git'), { recursive: true });
assert.strictEqual(findProjectRoot(childRepo), childRepo);
});
test('does not walk past nested dir whose git root has .planning/ (#1362)', () => {
// Workspace layout: parent has .planning/, child git repo also has .planning/
// cwd is deep inside child — should resolve to child root, not workspace root
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
const childRepo = path.join(projectRoot, 'authenticator');
fs.mkdirSync(path.join(childRepo, '.planning'), { recursive: true });
fs.mkdirSync(path.join(childRepo, '.git'), { recursive: true });
const deepDir = path.join(childRepo, 'src', 'lib');
fs.mkdirSync(deepDir, { recursive: true });
assert.strictEqual(findProjectRoot(deepDir), childRepo);
});
});
// ─── reapStaleTempFiles ─────────────────────────────────────────────────────
describe('reapStaleTempFiles', () => {
const gsdTmpDir = path.join(os.tmpdir(), 'gsd');
test('removes stale gsd-*.json files older than maxAgeMs', () => {
fs.mkdirSync(gsdTmpDir, { recursive: true });
const stalePath = path.join(gsdTmpDir, `gsd-reap-test-${Date.now()}.json`);
fs.writeFileSync(stalePath, '{}');
// Set mtime to 10 minutes ago
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(stalePath, oldTime, oldTime);
reapStaleTempFiles('gsd-reap-test-', { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(stalePath), 'stale file should be removed');
});
test('preserves fresh gsd-*.json files', () => {
fs.mkdirSync(gsdTmpDir, { recursive: true });
const freshPath = path.join(gsdTmpDir, `gsd-reap-fresh-${Date.now()}.json`);
fs.writeFileSync(freshPath, '{}');
reapStaleTempFiles('gsd-reap-fresh-', { maxAgeMs: 5 * 60 * 1000 });
assert.ok(fs.existsSync(freshPath), 'fresh file should be preserved');
// Clean up
fs.unlinkSync(freshPath);
});
test('removes stale temp directories when present', () => {
fs.mkdirSync(gsdTmpDir, { recursive: true });
const staleDir = fs.mkdtempSync(path.join(gsdTmpDir, 'gsd-reap-dir-'));
fs.writeFileSync(path.join(staleDir, 'data.jsonl'), 'test');
// Set mtime to 10 minutes ago
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(staleDir, oldTime, oldTime);
reapStaleTempFiles('gsd-reap-dir-', { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(staleDir), 'stale directory should be removed');
});
test('does not throw on empty or missing prefix matches', () => {
assert.doesNotThrow(() => {
reapStaleTempFiles('gsd-nonexistent-prefix-xyz-', { maxAgeMs: 0 });
});
});
});
// ─── 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/
);
});
});
// ─── timeAgo ──────────────────────────────────────────────────────────────────
describe('timeAgo', () => {
const now = () => Date.now();
const dateAt = (msAgo) => new Date(now() - msAgo);
// ─── seconds boundary ───
test('returns "just now" for dates under 5 seconds old', () => {
assert.strictEqual(timeAgo(dateAt(0)), 'just now');
assert.strictEqual(timeAgo(dateAt(4_000)), 'just now');
});
test('returns "N seconds ago" between 5 and 59 seconds', () => {
assert.strictEqual(timeAgo(dateAt(5_000)), '5 seconds ago');
assert.strictEqual(timeAgo(dateAt(30_000)), '30 seconds ago');
assert.strictEqual(timeAgo(dateAt(59_000)), '59 seconds ago');
});
// ─── minutes boundary ───
test('transitions to minutes at 60 seconds', () => {
assert.strictEqual(timeAgo(dateAt(60_000)), '1 minute ago');
});
test('uses singular "1 minute ago" for exactly one minute', () => {
assert.strictEqual(timeAgo(dateAt(60_000)), '1 minute ago');
assert.strictEqual(timeAgo(dateAt(119_000)), '1 minute ago');
});
test('uses plural "N minutes ago" for 2-59 minutes', () => {
assert.strictEqual(timeAgo(dateAt(120_000)), '2 minutes ago');
assert.strictEqual(timeAgo(dateAt(5 * 60_000)), '5 minutes ago');
assert.strictEqual(timeAgo(dateAt(59 * 60_000)), '59 minutes ago');
});
// ─── hours boundary ───
test('transitions to hours at 60 minutes', () => {
assert.strictEqual(timeAgo(dateAt(60 * 60_000)), '1 hour ago');
});
test('uses singular "1 hour ago" for exactly one hour', () => {
assert.strictEqual(timeAgo(dateAt(60 * 60_000)), '1 hour ago');
assert.strictEqual(timeAgo(dateAt(119 * 60_000)), '1 hour ago');
});
test('uses plural "N hours ago" for 2-23 hours', () => {
assert.strictEqual(timeAgo(dateAt(2 * 3600_000)), '2 hours ago');
assert.strictEqual(timeAgo(dateAt(23 * 3600_000)), '23 hours ago');
});
// ─── days boundary ───
test('transitions to days at 24 hours', () => {
assert.strictEqual(timeAgo(dateAt(24 * 3600_000)), '1 day ago');
});
test('uses singular "1 day ago" for exactly one day', () => {
assert.strictEqual(timeAgo(dateAt(24 * 3600_000)), '1 day ago');
});
test('uses plural "N days ago" for 2-29 days', () => {
assert.strictEqual(timeAgo(dateAt(2 * 86400_000)), '2 days ago');
assert.strictEqual(timeAgo(dateAt(29 * 86400_000)), '29 days ago');
});
// ─── months boundary ───
test('transitions to months at 30 days', () => {
assert.strictEqual(timeAgo(dateAt(30 * 86400_000)), '1 month ago');
});
test('uses singular "1 month ago" for exactly one month (30 days)', () => {
assert.strictEqual(timeAgo(dateAt(30 * 86400_000)), '1 month ago');
assert.strictEqual(timeAgo(dateAt(59 * 86400_000)), '1 month ago');
});
test('uses plural "N months ago" for 2-11 months', () => {
assert.strictEqual(timeAgo(dateAt(60 * 86400_000)), '2 months ago');
assert.strictEqual(timeAgo(dateAt(180 * 86400_000)), '6 months ago');
});
// ─── years boundary ───
test('transitions to years at 365 days', () => {
assert.strictEqual(timeAgo(dateAt(365 * 86400_000)), '1 year ago');
});
test('uses singular "1 year ago" for exactly one year', () => {
assert.strictEqual(timeAgo(dateAt(365 * 86400_000)), '1 year ago');
});
test('uses plural "N years ago" for 2+ years', () => {
assert.strictEqual(timeAgo(dateAt(2 * 365 * 86400_000)), '2 years ago');
assert.strictEqual(timeAgo(dateAt(10 * 365 * 86400_000)), '10 years ago');
});
// ─── edge cases ───
test('handles future dates as "just now" (negative elapsed)', () => {
// A date 5 seconds in the future has negative elapsed time, which floors to a negative
// number of seconds and hits the "under 5 seconds" branch.
assert.strictEqual(timeAgo(new Date(Date.now() + 5_000)), 'just now');
});
});