Six independent windows-only failure clusters identified from the windows-22 CI log on1be0e4e2: - scripts/command-contract-helpers.cjs: parseFrontmatter split on `\n` → on Windows checkout (autocrlf=true) every line carries trailing \r, lines.indexOf('---', 1) returns -1, all fields read as missing. Drove the bulk of three "67 subtests failed" suite-level errors covering workstreams.md, workspace.md, verify-work.md, add-tests.md, etc. Fix: split(/\r?\n/). - tests/enh-3271-sdk-adr-structure.test.cjs: same CRLF pattern — H2 captures pulled '\r' into headings like "decision\r", breaking equality checks on "## Decision" / "## Consequences". Fix: same. - tests/runtime-bridge-sync-smoke.test.cjs: await import(BRIDGE_PATH) passed a Windows absolute path to Node's ESM loader, which rejects with "Only URLs with a scheme in: file, data, and node are supported." Fix: wrap once at module scope with pathToFileURL. pathToFileURL is a no-op for POSIX absolute paths. - tests/bug-2957-claude-global-postinstall-message.test.cjs: hardcoded '/tmp/gsd-test-settings.json' resolved to D:\tmp\... on Windows where the parent dir doesn't exist → ENOENT on fs.writeFileSync inside finishInstall. Fix: os.tmpdir() + pid suffix. - tests/bug-2774-worktree-cleanup-workspace-safety.test.cjs: the "while/read loop" subtest and the "end-to-end against real git worktrees" describe both assert POSIX shell behavior (process substitution `< <(...)`, RUNNER~1 8.3-shortname mismatch). Skip on win32 with explicit reasons (satisfies the no-unconditional-win32-skip guard). - tests/bug-2838-summary-rescue-gitignored-planning.test.cjs: entire describe extracts bash rescue blocks from workflow .md files and runs them; the shell contract itself is the test's point. Skip on win32 with reason. - tests/helpers.cjs cleanup(): bumped rmSync retry budget from 10×100ms to 20×250ms — 1s wasn't enough for Windows Defender's deferred handle release; bumping to 5s should absorb the residual EBUSY failures observed in bug-1736 / bug-2248 / bug-2698 after the first retry bump landed in1be0e4e2. Validated: holodeck (ubuntu docker) 11224/0 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
6.8 KiB
JavaScript
179 lines
6.8 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).
|
||
// Budget: 20 × 250ms = 5s total — Windows Defender's deferred scan can hold
|
||
// newly-written files for several seconds on cold runners.
|
||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
|
||
}
|
||
|
||
/**
|
||
* 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 };
|