diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d2600d461..94fb64bb49 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,9 +38,12 @@ jobs: - name: Run tests with coverage # c8 v11 requires Node 20+ (engines: ^20.0.0 || >=22.0.0). Node 18 EOL April 2025. + # Use bash on all platforms so shell glob expansion works on Windows. if: matrix.node-version != 18 + shell: bash run: npm run test:coverage - name: Run tests (Node 18, coverage not supported) if: matrix.node-version == 18 + shell: bash run: npm test diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 20aab28036..4543b0e4fe 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -6,6 +6,13 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); +// ─── Path helpers ──────────────────────────────────────────────────────────── + +/** Normalize a relative path to always use forward slashes (cross-platform). */ +function toPosixPath(p) { + return p.split(path.sep).join('/'); +} + // ─── Model Profile Table ───────────────────────────────────────────────────── const MODEL_PROFILES = { @@ -218,7 +225,7 @@ function searchPhaseInDir(baseDir, relBase, normalized) { return { found: true, - directory: path.join(relBase, match), + directory: toPosixPath(path.join(relBase, match)), phase_number: phaseNumber, phase_name: phaseName, phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null, @@ -241,7 +248,7 @@ function findPhaseInternal(cwd, phase) { const normalized = normalizePhaseName(phase); // Search current phases first - const current = searchPhaseInDir(phasesDir, path.join('.planning', 'phases'), normalized); + const current = searchPhaseInDir(phasesDir, '.planning/phases', normalized); if (current) return current; // Search archived milestone phases (newest first) @@ -259,7 +266,7 @@ function findPhaseInternal(cwd, phase) { for (const archiveName of archiveDirs) { const version = archiveName.match(/^(v[\d.]+)-phases$/)[1]; const archivePath = path.join(milestonesDir, archiveName); - const relBase = path.join('.planning', 'milestones', archiveName); + const relBase = '.planning/milestones/' + archiveName; const result = searchPhaseInDir(archivePath, relBase, normalized); if (result) { result.archived = version; @@ -409,4 +416,5 @@ module.exports = { pathExistsInternal, generateSlugInternal, getMilestoneInfo, + toPosixPath, }; diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index c2933a8b0f..7e551a01fb 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -5,7 +5,7 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, output, error } = require('./core.cjs'); +const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs'); function cmdInitExecutePhase(cwd, phase, raw) { if (!phase) { @@ -139,19 +139,19 @@ function cmdInitPlanPhase(cwd, phase, raw) { const files = fs.readdirSync(phaseDirFull); const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); if (contextFile) { - result.context_path = path.join(phaseInfo.directory, contextFile); + result.context_path = toPosixPath(path.join(phaseInfo.directory, contextFile)); } const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); if (researchFile) { - result.research_path = path.join(phaseInfo.directory, researchFile); + result.research_path = toPosixPath(path.join(phaseInfo.directory, researchFile)); } const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); if (verificationFile) { - result.verification_path = path.join(phaseInfo.directory, verificationFile); + result.verification_path = toPosixPath(path.join(phaseInfo.directory, verificationFile)); } const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); if (uatFile) { - result.uat_path = path.join(phaseInfo.directory, uatFile); + result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile)); } } catch {} } @@ -422,19 +422,19 @@ function cmdInitPhaseOp(cwd, phase, raw) { const files = fs.readdirSync(phaseDirFull); const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); if (contextFile) { - result.context_path = path.join(phaseInfo.directory, contextFile); + result.context_path = toPosixPath(path.join(phaseInfo.directory, contextFile)); } const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); if (researchFile) { - result.research_path = path.join(phaseInfo.directory, researchFile); + result.research_path = toPosixPath(path.join(phaseInfo.directory, researchFile)); } const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); if (verificationFile) { - result.verification_path = path.join(phaseInfo.directory, verificationFile); + result.verification_path = toPosixPath(path.join(phaseInfo.directory, verificationFile)); } const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); if (uatFile) { - result.uat_path = path.join(phaseInfo.directory, uatFile); + result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile)); } } catch {} } @@ -469,7 +469,7 @@ function cmdInitTodos(cwd, area, raw) { created: createdMatch ? createdMatch[1].trim() : 'unknown', title: titleMatch ? titleMatch[1].trim() : 'Untitled', area: todoArea, - path: path.join('.planning', 'todos', 'pending', file), + path: '.planning/todos/pending/' + file, }); } catch {} } @@ -629,7 +629,7 @@ function cmdInitProgress(cwd, raw) { const phaseInfo = { number: phaseNumber, name: phaseName, - directory: path.join('.planning', 'phases', dir), + directory: '.planning/phases/' + dir, status, plan_count: plans.length, summary_count: summaries.length, diff --git a/package.json b/package.json index a23f9f450a..7c571acdda 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "scripts": { "build:hooks": "node scripts/build-hooks.js", "prepublishOnly": "npm run build:hooks", - "test": "node --test tests/*.test.cjs", - "test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node --test tests/*.test.cjs" + "test": "node scripts/run-tests.cjs", + "test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs" } } diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs new file mode 100644 index 0000000000..d50d79fd1b --- /dev/null +++ b/scripts/run-tests.cjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Cross-platform test runner — resolves test file globs via Node +// instead of relying on shell expansion (which fails on Windows PowerShell/cmd). +// Propagates NODE_V8_COVERAGE so c8 collects coverage from the child process. +'use strict'; + +const { readdirSync } = require('fs'); +const { join } = require('path'); +const { execFileSync } = require('child_process'); + +const testDir = join(__dirname, '..', 'tests'); +const files = readdirSync(testDir) + .filter(f => f.endsWith('.test.cjs')) + .sort() + .map(f => join('tests', f)); + +if (files.length === 0) { + console.error('No test files found in tests/'); + process.exit(1); +} + +try { + execFileSync(process.execPath, ['--test', ...files], { + stdio: 'inherit', + env: { ...process.env }, + }); +} catch (err) { + process.exit(err.status || 1); +} diff --git a/tests/frontmatter-cli.test.cjs b/tests/frontmatter-cli.test.cjs index de014b7962..a07ec43665 100644 --- a/tests/frontmatter-cli.test.cjs +++ b/tests/frontmatter-cli.test.cjs @@ -107,7 +107,7 @@ describe('frontmatter set', () => { test('handles JSON array value', () => { const file = writeTempFile('---\nphase: 01\n---\nbody'); - const result = runGsdTools(`frontmatter set ${file} --field tags --value '["a","b"]'`); + const result = runGsdTools(['frontmatter', 'set', file, '--field', 'tags', '--value', '["a","b"]']); assert.ok(result.success, `Command failed: ${result.error}`); const content = fs.readFileSync(file, 'utf-8'); @@ -140,7 +140,7 @@ describe('frontmatter set', () => { describe('frontmatter merge', () => { test('merges multiple fields into frontmatter', () => { const file = writeTempFile('---\nphase: 01\n---\nbody'); - const result = runGsdTools(`frontmatter merge ${file} --data '{"plan":"02","type":"tdd"}'`); + const result = runGsdTools(['frontmatter', 'merge', file, '--data', '{"plan":"02","type":"tdd"}']); assert.ok(result.success, `Command failed: ${result.error}`); const content = fs.readFileSync(file, 'utf-8'); @@ -153,7 +153,7 @@ describe('frontmatter merge', () => { test('overwrites existing fields on conflict', () => { const file = writeTempFile('---\nphase: 01\ntype: execute\n---\nbody'); - const result = runGsdTools(`frontmatter merge ${file} --data '{"phase":"02"}'`); + const result = runGsdTools(['frontmatter', 'merge', file, '--data', '{"phase":"02"}']); assert.ok(result.success, `Command failed: ${result.error}`); const content = fs.readFileSync(file, 'utf-8'); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 68b773af73..4dddcf4615 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -2,20 +2,35 @@ * GSD Tools Test Helpers */ -const { execSync } = require('child_process'); +const { execSync, execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); -// Helper to run gsd-tools command +/** + * Run gsd-tools command. + * + * @param {string|string[]} args - Command string (shell-interpreted) or array + * of arguments (shell-bypassed via execFileSync, safe for JSON and dollar signs). + * @param {string} cwd - Working directory. + */ function runGsdTools(args, cwd = process.cwd()) { try { - const result = execSync(`node "${TOOLS_PATH}" ${args}`, { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); + let result; + if (Array.isArray(args)) { + result = execFileSync(process.execPath, [TOOLS_PATH, ...args], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } else { + result = execSync(`node "${TOOLS_PATH}" ${args}`, { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } return { success: true, output: result.trim() }; } catch (err) { return { diff --git a/tests/init.test.cjs b/tests/init.test.cjs index 3069ab7b58..13af9efd8e 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -256,7 +256,7 @@ describe('cmdInitTodos', () => { assert.strictEqual(task1.title, 'Fix bug'); assert.strictEqual(task1.area, 'backend'); assert.strictEqual(task1.created, '2026-02-25'); - assert.strictEqual(task1.path, path.join('.planning', 'todos', 'pending', 'task-1.md')); + assert.strictEqual(task1.path, '.planning/todos/pending/task-1.md'); }); test('area filter returns only matching todos', () => { diff --git a/tests/state.test.cjs b/tests/state.test.cjs index ff70b02a01..a8b2100e8a 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -205,7 +205,7 @@ None ); const result = runGsdTools( - "state add-decision --phase 11-01 --summary 'Benchmark prices moved from $0.50 to $2.00 to $5.00' --rationale 'track cost growth'", + ['state', 'add-decision', '--phase', '11-01', '--summary', 'Benchmark prices moved from $0.50 to $2.00 to $5.00', '--rationale', 'track cost growth'], tmpDir ); assert.ok(result.success, `Command failed: ${result.error}`); @@ -233,7 +233,7 @@ None ` ); - const result = runGsdTools("state add-blocker --text 'Waiting on vendor quote $1.00 before approval'", tmpDir); + const result = runGsdTools(['state', 'add-blocker', '--text', 'Waiting on vendor quote $1.00 before approval'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');