/** * GSD Tools Test Helpers */ 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'); const TEST_ENV_BASE = { GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '', CLAUDE_CODE_SSE_PORT: '', OPENCODE_SESSION_ID: '', GEMINI_SESSION_ID: '', CURSOR_SESSION_ID: '', WINDSURF_SESSION_ID: '', TERM_SESSION_ID: '', WT_SESSION: '', TMUX_PANE: '', ZELLIJ_SESSION_NAME: '', TTY: '', SSH_TTY: '', }; /** * 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. * @param {object} [env] - Optional env overrides merged on top of process.env. * Pass { HOME: cwd } to sandbox ~/.gsd/ lookups in tests that assert concrete * config values that could be overridden by a developer's defaults.json. */ function runGsdTools(args, cwd = process.cwd(), env = {}) { try { let result; const childEnv = { ...process.env, ...TEST_ENV_BASE, ...env }; if (Array.isArray(args)) { result = execFileSync(process.execPath, [TOOLS_PATH, ...args], { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: childEnv, }); } else { // Split shell-style string into argv, stripping surrounding quotes, so we // can invoke execFileSync with process.execPath instead of relying on // `node` being on PATH (it isn't in Claude Code shell sessions). // Apply shell-style quote removal: strip surrounding quotes from quoted // sequences anywhere in a token (handles both "foo bar" and --"foo bar"). const argv = (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) .map(t => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1')); result = execFileSync(process.execPath, [TOOLS_PATH, ...argv], { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: childEnv, }); } return { success: true, output: result.trim() }; } catch (err) { return { success: false, output: err.stdout?.toString().trim() || '', error: err.stderr?.toString().trim() || err.message, }; } } // Create a bare temp directory (no .planning/ structure) function createTempDir(prefix = 'gsd-test-') { return fs.mkdtempSync(path.join(require('os').tmpdir(), prefix)); } // 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; } // 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; } function cleanup(tmpDir) { fs.rmSync(tmpDir, { recursive: true, force: true }); } /** * Parse a Markdown frontmatter block into a flat key→value map. * * Handles the YAML scalar forms emitted by the install converters: * key: "json-encoded value" → JSON.parse * key: 'value with ''escape'' → strip quotes, unescape '' * key: bare value → trimmed string * * Multi-line and block scalars are out of scope — every converter in * `bin/install.js` emits single-line scalars only. Throws if the content * has no closed `---` block so a regression in the emitter shape fails * loudly rather than silently returning {}. * * Tests use this helper instead of `result.includes('key: value')` to * follow the project's "tests parse, never grep" convention. * * @param {string} content - Full file content beginning with `---`. * @returns {Record} Map of frontmatter keys to decoded values. */ function parseFrontmatter(content) { if (!content.startsWith('---')) { throw new Error(`parseFrontmatter: content must start with '---', got: ${content.slice(0, 40)}`); } // CRLF tolerance: a Windows-authored file split on `\n` would leave a // trailing `\r` on every line, making `lines[i] === '---'` fail to // recognize delimiters. Same goes for whitespace-padded delimiter lines. // Normalize via a CRLF-aware split + trimmed comparison. const lines = content.split(/\r?\n/); let openIdx = -1; let closeIdx = -1; for (let i = 0; i < lines.length; i += 1) { if (lines[i].trim() === '---') { if (openIdx === -1) openIdx = i; else { closeIdx = i; break; } } } if (openIdx === -1 || closeIdx === -1) { throw new Error('parseFrontmatter: no closed --- block'); } const fields = {}; for (const line of lines.slice(openIdx + 1, closeIdx)) { const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); if (!match) continue; // skip block-list items, blank lines, comments const [, key, rawValue] = match; const value = rawValue.trim(); if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { fields[key] = JSON.parse(value); } else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) { fields[key] = value.slice(1, -1).replace(/''/g, "'"); } else { fields[key] = value; } } return fields; } // #3026 CR: shared `--help` output check used by bug-1818 + bug-3019 tests. // Render-on-help shape is `Usage: gsd-tools …\nCommands: …` — both lines // must be present; structural test, not prose substring matching. function isUsageOutput(text) { return /Usage:\s*gsd-tools/.test(text) && /Commands:/.test(text); } module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, TOOLS_PATH };