From 06cf7826e3df5fdf52a646531fff85f4284540d9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 26 May 2026 11:23:42 -0400 Subject: [PATCH] test: deepen fixture module v2 for reliability (#329) * test: deepen fixture seam for init/state/workstream suites * test: deepen fixture module v2 with declarative builders --- tests/fixture-builder.test.cjs | 86 ++++++++++++++++++++++++ tests/fixtures/index.cjs | 101 ++++++++++++++++++++++++++++ tests/helpers.cjs | 25 ++----- tests/init.test.cjs | 118 ++++++++++++++++----------------- tests/state.test.cjs | 49 +++++++------- tests/workstream.test.cjs | 57 ++++++++-------- 6 files changed, 304 insertions(+), 132 deletions(-) create mode 100644 tests/fixture-builder.test.cjs create mode 100644 tests/fixtures/index.cjs diff --git a/tests/fixture-builder.test.cjs b/tests/fixture-builder.test.cjs new file mode 100644 index 000000000..3e8d991e6 --- /dev/null +++ b/tests/fixture-builder.test.cjs @@ -0,0 +1,86 @@ +const { test, describe, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const { createFixture, seedPhase, seedWorkstream, writeState } = require('./fixtures/index.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const created = []; +afterEach(() => { + while (created.length > 0) cleanup(created.pop()); +}); + +describe('fixture builder module', () => { + test('creates canonical planning layout by default', () => { + const dir = createFixture(); + created.push(dir); + + assert.ok(fs.existsSync(path.join(dir, '.planning')), 'creates .planning directory'); + assert.ok(fs.existsSync(path.join(dir, '.planning', 'phases')), 'creates .planning/phases directory'); + }); + + test('seeds phase files declaratively', () => { + const dir = createFixture(); + created.push(dir); + + const phaseDir = seedPhase(dir, '03-api', { + '03-01-PLAN.md': '# Plan', + '03-CONTEXT.md': '# Context', + }); + + assert.ok(fs.existsSync(path.join(phaseDir, '03-01-PLAN.md'))); + assert.ok(fs.existsSync(path.join(phaseDir, '03-CONTEXT.md'))); + }); + + test('seeds workstream and active pointer declaratively', () => { + const dir = createFixture(); + created.push(dir); + + const wsDir = seedWorkstream(dir, { + name: 'alpha', + state: '# State\n', + roadmap: '# Roadmap\n', + active: true, + }); + + assert.ok(fs.existsSync(path.join(wsDir, 'STATE.md'))); + assert.ok(fs.existsSync(path.join(wsDir, 'ROADMAP.md'))); + assert.strictEqual( + fs.readFileSync(path.join(dir, '.planning', 'active-workstream'), 'utf8').trim(), + 'alpha' + ); + }); + + test('rejects invalid workstream names', () => { + const dir = createFixture(); + created.push(dir); + assert.throws(() => seedWorkstream(dir, { name: '../escape' }), /invalid name/); + }); + + test('writes STATE.md in canonical location', () => { + const dir = createFixture(); + created.push(dir); + + const p = writeState(dir, '# Project State\n'); + assert.ok(fs.existsSync(p)); + assert.strictEqual(path.basename(p), 'STATE.md'); + }); + + test('initializes git fixture with initial commit', () => { + const dir = createFixture({ git: true }); + created.push(dir); + + const isWorkTree = execSync('git rev-parse --is-inside-work-tree', { cwd: dir, encoding: 'utf8' }).trim(); + assert.strictEqual(isWorkTree, 'true', 'fixture should be a git worktree'); + + const head = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + assert.ok(head.length > 0, 'fixture should include initial commit'); + + assert.ok( + fs.existsSync(path.join(dir, '.planning', 'PROJECT.md')), + 'git fixture writes canonical PROJECT.md' + ); + }); +}); diff --git a/tests/fixtures/index.cjs b/tests/fixtures/index.cjs new file mode 100644 index 000000000..25862a95b --- /dev/null +++ b/tests/fixtures/index.cjs @@ -0,0 +1,101 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/** + * Create a temp test fixture directory with canonical planning layout. + * + * @param {object} [options] + * @param {string} [options.prefix='gsd-test-'] + * @param {boolean} [options.git=false] - initialize git repo with initial commit + * @param {boolean} [options.planning=true] - create .planning/phases layout + * @param {boolean} [options.projectDoc=true] - write .planning/PROJECT.md + * @returns {string} absolute fixture directory path + */ +function createFixture(options = {}) { + const { + prefix = 'gsd-test-', + git = false, + planning = true, + projectDoc = git, + } = options; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + + if (planning) { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + } + + if (projectDoc) { + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# Project\n\nTest project.\n' + ); + } + + if (git) { + execSync('git init', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git config commit.gpgsign false', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git commit -m "initial commit"', { cwd: tmpDir, stdio: 'pipe' }); + } + + return tmpDir; +} + +/** + * Seed a canonical phase directory with files. + * @param {string} tmpDir + * @param {string} phaseSlug e.g. "03-api" + * @param {Record} files map of filename->content + * @returns {string} phase directory path + */ +function seedPhase(tmpDir, phaseSlug, files = {}) { + const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug); + fs.mkdirSync(phaseDir, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(phaseDir, name), content); + } + return phaseDir; +} + +/** + * Seed a canonical workstream tree and optionally set active pointer. + * @param {string} tmpDir + * @param {{name:string, state?:string, roadmap?:string, active?:boolean}} options + * @returns {string} workstream directory path + */ +function seedWorkstream(tmpDir, options) { + const { name, state = '', roadmap = '', active = false } = options || {}; + if (!name || /[^a-zA-Z0-9._-]/.test(name) || name.includes('..')) { + throw new Error(`seedWorkstream: invalid name "${name}"`); + } + const wsDir = path.join(tmpDir, '.planning', 'workstreams', name); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + if (state) fs.writeFileSync(path.join(wsDir, 'STATE.md'), state); + if (roadmap) fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), roadmap); + if (active) { + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), `${name}\n`); + } + return wsDir; +} + +/** + * Write STATE.md in canonical location. + * @param {string} tmpDir + * @param {string} content + * @returns {string} file path + */ +function writeState(tmpDir, content) { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync(statePath, content); + return statePath; +} + +module.exports = { createFixture, seedPhase, seedWorkstream, writeState }; diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 077152fd4..a26f2a269 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -2,9 +2,10 @@ * GSD Tools Test Helpers */ -const { execSync, execFileSync } = require('child_process'); +const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const { createFixture } = require('./fixtures/index.cjs'); const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); const TEST_ENV_BASE = { @@ -86,30 +87,12 @@ function createTempDir(prefix = 'gsd-test-') { // Create temp directory structure function createTempProject(prefix = 'gsd-test-') { - const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), prefix)); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); - return tmpDir; + return createFixture({ prefix, planning: true, git: false }); } // Create temp directory with initialized git repo and at least one commit function createTempGitProject(prefix = 'gsd-test-') { - const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), prefix)); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); - - execSync('git init', { cwd: tmpDir, stdio: 'pipe' }); - execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' }); - execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' }); - execSync('git config commit.gpgsign false', { cwd: tmpDir, stdio: 'pipe' }); - - fs.writeFileSync( - path.join(tmpDir, '.planning', 'PROJECT.md'), - '# Project\n\nTest project.\n' - ); - - execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' }); - execSync('git commit -m "initial commit"', { cwd: tmpDir, stdio: 'pipe' }); - - return tmpDir; + return createFixture({ prefix, planning: true, git: true, projectDoc: true }); } function cleanup(tmpDir) { diff --git a/tests/init.test.cjs b/tests/init.test.cjs index d50f4b3c6..f5dbab0db 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -6,13 +6,14 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); +const { createFixture, seedPhase } = require('./fixtures/index.cjs'); describe('init commands', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -20,9 +21,9 @@ describe('init commands', () => { }); test('init execute-phase returns file paths', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan'); + seedPhase(tmpDir, '03-api', { + '03-01-PLAN.md': '# Plan', + }); const result = runGsdTools('init execute-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -34,9 +35,9 @@ describe('init commands', () => { }); test('init execute-phase respects model_overrides for executor_model', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + seedPhase(tmpDir, '01-foundation', { + '01-01-PLAN.md': '# Plan', + }); fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'balanced', model_overrides: { 'gsd-executor': 'openai/o4-mini' }, @@ -51,9 +52,9 @@ describe('init commands', () => { }); test('init execute-phase respects model_overrides when resolve_model_ids is omit', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + seedPhase(tmpDir, '01-foundation', { + '01-01-PLAN.md': '# Plan', + }); fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ resolve_model_ids: 'omit', model_overrides: { 'gsd-executor': 'openai/o4-mini' }, @@ -68,12 +69,12 @@ describe('init commands', () => { }); test('init plan-phase returns file paths', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); - fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research Findings'); - fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); - fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); + seedPhase(tmpDir, '03-api', { + '03-CONTEXT.md': '# Phase Context', + '03-RESEARCH.md': '# Research Findings', + '03-VERIFICATION.md': '# Verification', + '03-UAT.md': '# UAT', + }); const result = runGsdTools('init plan-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -121,12 +122,12 @@ describe('init commands', () => { }); test('init phase-op returns core and optional phase file paths', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); - fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research'); - fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); - fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); + seedPhase(tmpDir, '03-api', { + '03-CONTEXT.md': '# Phase Context', + '03-RESEARCH.md': '# Research', + '03-VERIFICATION.md': '# Verification', + '03-UAT.md': '# UAT', + }); const result = runGsdTools('init phase-op 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -142,9 +143,9 @@ describe('init commands', () => { }); test('init plan-phase detects has_reviews and reviews_path when REVIEWS.md exists', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-REVIEWS.md'), '# Cross-AI Reviews'); + seedPhase(tmpDir, '03-api', { + '03-REVIEWS.md': '# Cross-AI Reviews', + }); const result = runGsdTools('init plan-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -155,8 +156,7 @@ describe('init commands', () => { }); test('init plan-phase omits optional paths if files missing', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); + seedPhase(tmpDir, '03-api'); const result = runGsdTools('init plan-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -223,9 +223,9 @@ describe('init commands', () => { }); test('init execute-phase extracts phase_req_ids from ROADMAP', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan'); + seedPhase(tmpDir, '03-api', { + '03-01-PLAN.md': '# Plan', + }); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Requirements**: EX-01, EX-02\n**Plans:** 1 plans\n` @@ -287,9 +287,9 @@ describe('init commands', () => { }); test(`init execute-phase parses Requirements with ${variant.name}`, () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan'); + seedPhase(tmpDir, '03-api', { + '03-01-PLAN.md': '# Plan', + }); const roadmap = [ '# Roadmap', '', @@ -311,9 +311,9 @@ describe('init commands', () => { } test('init execute-phase returns null phase_req_ids when Requirements line is absent', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan'); + seedPhase(tmpDir, '03-api', { + '03-01-PLAN.md': '# Plan', + }); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Plans:** 1 plans\n` @@ -335,7 +335,7 @@ describe('init commands ROADMAP fallback when phase directory does not exist (#1 let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: Foundation Setup\n**Goal:** Bootstrap project\n**Requirements**: R-01, R-02\n**Plans:** TBD\n' @@ -395,9 +395,9 @@ describe('init commands ROADMAP fallback when phase directory does not exist (#1 }); test('init plan-phase prefers disk directory over ROADMAP fallback', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation-setup'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + seedPhase(tmpDir, '01-foundation-setup', { + '01-01-PLAN.md': '# Plan', + }); const result = runGsdTools('init plan-phase 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -418,7 +418,7 @@ describe('init commands ignore archived phases from prior milestones sharing a n let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); // Current milestone ROADMAP has Phase 2 but no disk directory yet fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), @@ -488,7 +488,7 @@ describe('init plan-phase zero-padded phase number (bug #2391)', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); // Current milestone ROADMAP has Phase 3 (unpadded heading) fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), @@ -548,7 +548,7 @@ describe('cmdInitTodos', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -674,7 +674,7 @@ describe('cmdInitMilestoneOp', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -771,7 +771,7 @@ describe('cmdInitPhaseOp fallback', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -779,10 +779,10 @@ describe('cmdInitPhaseOp fallback', () => { }); test('normal path with existing directory', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Context'); - fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan'); + seedPhase(tmpDir, '03-api', { + '03-CONTEXT.md': '# Context', + '03-01-PLAN.md': '# Plan', + }); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Plans:** 1 plans\n' @@ -884,7 +884,7 @@ describe('cmdInitProgress', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1008,7 +1008,7 @@ describe('cmdInitQuick', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1126,7 +1126,7 @@ describe('cmdInitMapCodebase', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1194,7 +1194,7 @@ describe('cmdInitNewProject', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1335,7 +1335,7 @@ describe('cmdInitNewMilestone', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1420,7 +1420,7 @@ describe('findProjectRoot integration via --cwd', () => { let projectRoot; beforeEach(() => { - projectRoot = createTempProject(); + projectRoot = createFixture(); // Add ROADMAP.md so init quick doesn't error fs.writeFileSync( path.join(projectRoot, '.planning', 'ROADMAP.md'), @@ -1485,7 +1485,7 @@ describe('#2192: init plan-phase includes auto-advance config to prevent separat let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-auth'), { recursive: true }); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), @@ -1552,7 +1552,7 @@ describe('withProjectRoot project identity', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1610,7 +1610,7 @@ describe('withProjectRoot project identity', () => { path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ project_code: 'CK' }) ); - // Ensure no PROJECT.md exists (createTempProject doesn't create one) + // Ensure no PROJECT.md exists (createFixture doesn't create one) const projectMdPath = path.join(tmpDir, '.planning', 'PROJECT.md'); if (fs.existsSync(projectMdPath)) fs.unlinkSync(projectMdPath); diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 6e1433df0..42c06f4a8 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -10,13 +10,14 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); +const { createFixture } = require('./fixtures/index.cjs'); describe('state-snapshot command', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -197,7 +198,7 @@ describe('state-snapshot — bug #3265 frontmatter precedence', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -287,7 +288,7 @@ describe('state mutation commands', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -408,7 +409,7 @@ describe('state json command', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -513,7 +514,7 @@ describe('STATE.md frontmatter sync', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -759,7 +760,7 @@ describe('cmdStateLoad (state load)', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -821,7 +822,7 @@ describe('cmdStateGet (state get)', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -903,7 +904,7 @@ describe('cmdStatePatch and cmdStateUpdate (state patch, state update)', () => { ].join('\n') + '\n'; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -990,7 +991,7 @@ describe('cmdStateAdvancePlan (state advance-plan)', () => { ].join('\n') + '\n'; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1115,7 +1116,7 @@ describe('cmdStateRecordMetric (state record-metric)', () => { ].join('\n') + '\n'; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1186,7 +1187,7 @@ describe('cmdStateUpdateProgress (state update-progress)', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1280,7 +1281,7 @@ describe('cmdStateResolveBlocker (state resolve-blocker)', () => { ].join('\n') + '\n'; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1374,7 +1375,7 @@ describe('cmdStateRecordSession (state record-session)', () => { ].join('\n') + '\n'; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1462,7 +1463,7 @@ describe('milestone-scoped phase counting in frontmatter', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1586,7 +1587,7 @@ describe('state begin-phase preserves Current Position fields (#1365)', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1710,7 +1711,7 @@ describe('progress counters correct after plan execution (#1589)', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -1843,7 +1844,7 @@ describe('updatePerformanceMetricsSection', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -2013,7 +2014,7 @@ describe('state planned-phase command', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -2077,7 +2078,7 @@ describe('state validate command', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -2161,7 +2162,7 @@ describe('state sync command', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -2252,7 +2253,7 @@ describe('stopped_at frontmatter not overwritten by historical prose (bug #2444) let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -2351,7 +2352,7 @@ describe('stale phase dirs do not corrupt phase counts (bug #2445)', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { @@ -2480,7 +2481,7 @@ describe('state complete-phase: decorated Phase fallback (#2761 nitpick)', () => let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); }); afterEach(() => { diff --git a/tests/workstream.test.cjs b/tests/workstream.test.cjs index 3dc794f57..6660a9a9a 100644 --- a/tests/workstream.test.cjs +++ b/tests/workstream.test.cjs @@ -8,7 +8,8 @@ const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); +const { createFixture, seedWorkstream, writeState } = require('./fixtures/index.cjs'); const { migrateToWorkstreams, getOtherActiveWorkstreams } = require('../get-shit-done/bin/lib/workstream.cjs'); // ─── Helper ────────────────────────────────────────────────────────────────── @@ -18,7 +19,7 @@ function createProjectWithState(tmpDir, roadmap, state) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap, 'utf-8'); } if (state) { - fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), state, 'utf-8'); + writeState(tmpDir, state); } } @@ -75,13 +76,13 @@ describe('planningDir workstream awareness via env var', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); - // Create workstream structure - const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha'); - fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); - fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 1\n'); - fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap v1.0: Alpha\n### Phase 1: Setup\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'alpha\n'); + tmpDir = createFixture(); + seedWorkstream(tmpDir, { + name: 'alpha', + state: '# State\n**Status:** In progress\n**Current Phase:** 1\n', + roadmap: '## Roadmap v1.0: Alpha\n### Phase 1: Setup\n', + active: true, + }); }); after(() => cleanup(tmpDir)); @@ -119,7 +120,7 @@ describe('session-scoped active workstream routing', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); @@ -193,7 +194,7 @@ describe('session resolution hardening', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); @@ -254,7 +255,7 @@ describe('pointer lifecycle hardening', () => { let tmpDir; beforeEach(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); @@ -323,7 +324,7 @@ describe('workstream create', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); }); @@ -365,7 +366,7 @@ describe('workstream create with migration', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); // Existing flat-mode work fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '## Roadmap v1.0: Existing\n### Phase 1: A\n'); @@ -389,7 +390,7 @@ describe('workstream create with migration', () => { }); test('normalizes --migrate-name to a valid workstream slug', () => { - const isolatedDir = createTempProject(); + const isolatedDir = createFixture(); try { fs.writeFileSync(path.join(isolatedDir, '.planning', 'PROJECT.md'), '# Project\n'); fs.writeFileSync(path.join(isolatedDir, '.planning', 'ROADMAP.md'), '## Roadmap v1.0: Existing\n### Phase 1: A\n'); @@ -414,7 +415,7 @@ describe('workstream create with migration', () => { describe('migrateToWorkstreams', () => { test('rejects invalid workstream names for migration', () => { - const tmpDir = createTempProject(); + const tmpDir = createFixture(); try { assert.throws( () => migrateToWorkstreams(tmpDir, 'bad/name'), @@ -426,7 +427,7 @@ describe('migrateToWorkstreams', () => { }); test('fails when already in workstream mode', () => { - const tmpDir = createTempProject(); + const tmpDir = createFixture(); try { fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'existing'), { recursive: true }); assert.throws( @@ -443,7 +444,7 @@ describe('workstream list', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); // Create two workstreams for (const ws of ['alpha', 'beta']) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); @@ -468,7 +469,7 @@ describe('workstream list', () => { let flatDir; beforeEach(() => { - flatDir = createTempProject(); + flatDir = createFixture(); }); afterEach(() => { @@ -488,7 +489,7 @@ describe('workstream status', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha'); fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'phases', '01-setup', 'PLAN.md'), '# Plan\n'); @@ -521,7 +522,7 @@ describe('workstream complete', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'done-ws'); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** Complete\n'); @@ -549,7 +550,7 @@ describe('workstream set/get', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); for (const ws of ['ws-a', 'ws-b']) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); @@ -596,7 +597,7 @@ describe('getOtherActiveWorkstreams', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); // Create 3 workstreams: alpha (active), beta (active), gamma (completed) for (const ws of ['alpha', 'beta', 'gamma']) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); @@ -643,7 +644,7 @@ describe('workstream progress', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'feature'); fs.mkdirSync(path.join(wsDir, 'phases', '01-init'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'phases', '01-init', 'PLAN.md'), '# Plan\n'); @@ -667,7 +668,7 @@ describe('workstream progress', () => { }); test('clamps progress percent when completed phase dirs exceed roadmap count', () => { - const isolatedDir = createTempProject(); + const isolatedDir = createFixture(); try { const wsDir = path.join(isolatedDir, '.planning', 'workstreams', 'overflow'); for (const phase of ['01-one', '02-two']) { @@ -689,7 +690,7 @@ describe('workstream progress', () => { }); test('returns flat mode when no workstreams exist', () => { - const emptyDir = createTempProject(); + const emptyDir = createFixture(); try { const result = runGsdTools(['workstream', 'progress', '--raw'], emptyDir); assert.ok(result.success, `progress in flat mode failed: ${result.error}`); @@ -707,7 +708,7 @@ describe('gsd-tools --ws flag integration', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); // Create a workstream with roadmap const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'test-ws'); fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true }); @@ -741,7 +742,7 @@ describe('path traversal rejection', () => { let tmpDir; before(() => { - tmpDir = createTempProject(); + tmpDir = createFixture(); fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'legit'); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });