* chore(3686): add release-tarball lifecycle smoke to install-smoke workflow Closes #3686. Adds a non-interactive lifecycle smoke that runs against the installed tarball (not the working tree). Catches the two recent release-time bug classes that the working-tree test suite cannot see: * #3684 — symbol mismatches between init.cjs imports and secrets.cjs exports that landed in v1.42.3 (closed/fixed-pending-release). * #3668 — bare `gsd-sdk` invocations in 75 of 78 workflow files with no `command -v gsd-sdk … elif node "$GSD_TOOLS"` fallback (open). Shape: * `scripts/release-tarball-smoke.cjs` — pure CJS module exporting a frozen `SMOKE` enum and a `runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir, lifecycleCommands })` function. CLI `--json` mode prints `JSON.stringify(result)` and exits 0 iff `result.code === SMOKE.OK`. Install is `--prefix <tmpdir>` so it does not pollute global node_modules. * `tests/release-tarball-smoke.test.cjs` — 6 tests covering happy path, version mismatch, lifecycle command file resolution, missing-command detection, sdk binary callability, and structural workflow-body checks. Tests assert on the SMOKE enum directly; no `assert.match` on rendered prose, no try/finally in test bodies, no source-grep theater. Uses `before`/`after` to pack+install once across the test file. * `tests/release-tarball-smoke-workflow.test.cjs` — 7 structural assertions on the parsed install-smoke.yml IR (workflow_call trigger preserved, lifecycle step calls release-tarball-smoke.cjs with --json, jq check enforces result.code === "ok", path filter includes the new files, artifact-on-failure step present). * `.github/workflows/install-smoke.yml` — extended (not duplicated). New "Lifecycle smoke" step after the existing version check, on the same matrix. Artifact upload on failure for debugging. Path filter now triggers on changes to the new script + test. Per CONTRIBUTING.md §"Prohibited: Raw Text Matching on Test Outputs" this PR avoids the same anti-pattern that caused PR #3666 to be reverted (PR #3688) — the script returns frozen enum codes, tests assert on the enum, never on stdout strings. Workflow-body checks in Cycle 3 are INFORMATIONAL (count returned, not enforced) on this PR. After #3668's fix lands and the 75 missing fallbacks are added, the lane can be tightened to enforce zero. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(3686): harden release smoke workflow and query scanner * fix(3686): move tarball-smoke test to install suite to fix Windows ETIMEDOUT + coverage OOM Windows (Node 22/24/26, jobs 76565215694/76565215710/76565215812): release-tarball-smoke.test.cjs had no suite marker so run-tests.cjs classified it as 'unit', running it on Windows PR CI. The before() hook calls execFileSync(npm.cmd install -g ...) with a 55 s timeout; on Windows GHA runners this npm global install consistently hits ETIMEDOUT (~62 s observed), causing all 3 Windows lanes to fail. Coverage (job 76565215085): c8 ran test:coverage:unit (unit suite only) with V8 coverage tracking active across child processes. The tarball-smoke test's before() hook spawned npm install subprocesses while c8 held V8 coverage descriptors open, driving the Node heap to 4 GB+ and triggering an OOM abort during report generation (exit code 134, all 5682 tests had already passed). Fix: rename to tests/release-tarball-smoke.install.test.cjs so run-tests.cjs routes it to the 'install' suite. The install suite is already skipped on PR CI by design (test.yml lines 179-181: only runs on main push). The dedicated install-smoke.yml workflow continues to exercise this test on its own matrix. Also update the install-smoke.yml PR path filter and the structural wiring test assertion to match the new filename. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(test): route tarball-smoke install test through tests/helpers.cjs Replaces direct fs.mkdtempSync and execFileSync calls in tests/release-tarball-smoke.install.test.cjs with createTempDir() and a new runNpm() helper in tests/helpers.cjs. Cleanup is now automatic via the helper. Addresses CodeRabbit Major refactor at https://github.com/gsd-build/get-shit-done/pull/3692#discussion_r3260433892. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
259 lines
9.8 KiB
JavaScript
259 lines
9.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) {
|
||
if (typeof tmpDir !== 'string' || tmpDir.length === 0) return;
|
||
const target = path.resolve(tmpDir);
|
||
const cwd = path.resolve(process.cwd());
|
||
if (cwd === target || cwd.startsWith(`${target}${path.sep}`)) {
|
||
// Windows cannot remove a directory that is the current working directory.
|
||
process.chdir(path.dirname(target));
|
||
}
|
||
// 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(target, { 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);
|
||
}
|
||
|
||
/**
|
||
* Run `fn` with console.log/warn/error captured, returning {stdout, stderr}
|
||
* with ANSI colors stripped. Re-throws any exception fn threw AFTER restoring
|
||
* the real console so the caller's assertion path sees the failure (without
|
||
* this, a fn that crashes before printing would falsely pass !hasReady-style
|
||
* assertions). #2775 CR follow-up established this exact contract.
|
||
*
|
||
* Previously duplicated in bug-2775, bug-2829, bug-3033, bug-3211, bug-3231,
|
||
* bug-3359, and installer-migration-install-integration.
|
||
*/
|
||
function captureConsole(fn) {
|
||
const stdout = [];
|
||
const stderr = [];
|
||
const origLog = console.log;
|
||
const origWarn = console.warn;
|
||
const origError = console.error;
|
||
console.log = (...a) => stdout.push(a.join(' '));
|
||
console.warn = (...a) => stderr.push(a.join(' '));
|
||
console.error = (...a) => stderr.push(a.join(' '));
|
||
let threw = null;
|
||
try {
|
||
fn();
|
||
} catch (e) {
|
||
threw = e;
|
||
} finally {
|
||
console.log = origLog;
|
||
console.warn = origWarn;
|
||
console.error = origError;
|
||
}
|
||
if (threw) throw threw;
|
||
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
||
return {
|
||
stdout: stdout.map(strip).join('\n'),
|
||
stderr: stderr.map(strip).join('\n'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Normalize platform path separators to POSIX forward slashes. Use for
|
||
* cross-platform path comparisons in test assertions where the runtime
|
||
* emits the platform-native separator (\ on Windows) but the test
|
||
* fixture or expected literal is POSIX. Returns the input unchanged if
|
||
* null/undefined so it composes safely with optional chaining.
|
||
*/
|
||
function toPosixPath(p) {
|
||
return p == null ? p : p.split(path.sep).join('/');
|
||
}
|
||
|
||
/**
|
||
* Run an npm command via execFileSync with cross-platform portability.
|
||
*
|
||
* Handles the Windows `npm.cmd` vs POSIX `npm` distinction and the
|
||
* `shell: true` requirement on Windows so tests do not need to
|
||
* re-implement platform detection inline.
|
||
*
|
||
* @param {string[]} args - npm subcommand and flags (e.g. ['pack', '--pack-destination', dir]).
|
||
* @param {object} [options] - execFileSync options merged with platform defaults.
|
||
* `cwd`, `encoding`, `timeout`, and `env` are the commonly overridden keys.
|
||
* @returns {string} trimmed stdout string (encoding: 'utf-8').
|
||
* @throws {Error} re-throws the execFileSync error on non-zero exit so callers
|
||
* get the full stderr in the error message.
|
||
*/
|
||
function runNpm(args, options = {}) {
|
||
const isWindows = process.platform === 'win32';
|
||
const npmCmd = isWindows ? 'npm.cmd' : 'npm';
|
||
const defaults = {
|
||
encoding: 'utf-8',
|
||
shell: isWindows,
|
||
timeout: 55000,
|
||
};
|
||
return execFileSync(npmCmd, args, { ...defaults, ...options }).trim();
|
||
}
|
||
|
||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, TOOLS_PATH };
|