Two windows-only failure clusters surfaced when the chunking fix in
52f23ac0 made Windows tests actually run:
1) Cleanup EBUSY race. On Windows, fs.rmSync without {maxRetries,retryDelay}
races against AV scanners / file-indexers / just-exited child processes
that still hold handles when teardown fires. Surfaces as
EBUSY: resource busy or locked, rmdir 'C:\Users\...\AppData\Local\Temp\gsd-...'
in bug-1736, bug-2248, bug-2698, bug-2838, and every test routing through
the shared tests/helpers.cjs cleanup() (≈170 consumers — bug-2774 et al).
Fix: add {maxRetries: 10, retryDelay: 100} to the shared helper plus the
four inline cleanup sites. On POSIX the retry loop is dead code (first
try succeeds), so no impact on ubuntu/macos.
2) bug-2839 had a local parseFrontmatter that anchored on /^---\n/ — on a
Windows checkout with autocrlf=true the file content is CRLF, the regex
never matches, the function returns null, and the before() hook asserts
"agent must have YAML frontmatter" → entire suite cancels. The shared
tests/helpers.cjs parseFrontmatter is already CRLF-aware (split /\r?\n/);
switch to it.
Validated: plex2 (ubuntu docker) 11224/0 pass post-patch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
177 lines
6.7 KiB
JavaScript
177 lines
6.7 KiB
JavaScript
/**
|
|
* 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) {
|
|
// maxRetries/retryDelay absorbs transient Windows EBUSY where AV scanners,
|
|
// file-indexers, or just-exited child processes still hold handles when
|
|
// teardown runs. On POSIX the retry loop is a no-op (rmSync succeeds first try).
|
|
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
}
|
|
|
|
/**
|
|
* 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<string, string>} 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 };
|