Files
msd-core/tests/bug-2838-summary-rescue-gitignored-planning.test.cjs
Tom Boucher 23b52f1a14 fix(3597): windows test parity batch — CRLF parsers, ESM file URLs, posix-tmp, bash-only skips, retry bump
Six independent windows-only failure clusters identified from the
windows-22 CI log on 1be0e4e2:

- 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 in 1be0e4e2.

Validated: holodeck (ubuntu docker) 11224/0 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:03:44 -04:00

273 lines
11 KiB
JavaScript

/**
* Regression tests for #2838: SUMMARY rescue silently fails when .planning/
* is gitignored.
*
* The pre-fix rescue used `git ls-files --modified --others --exclude-standard`
* to detect uncommitted SUMMARY.md files. When projects gitignore .planning/
* (a common policy), --exclude-standard filters out the very files the rescue
* was meant to save, producing an empty result and skipping the rescue branch.
* The next line `git worktree remove --force` then permanently deleted the
* SUMMARY.
*
* The fix replaces git ls-files with a filesystem-level `find` + `cp` rescue
* that bypasses gitignore entirely.
*
* This test file:
* 1. Extracts the rescue block from each workflow file (parsed structurally
* by locating the labeled comment + closing fence — not free-form regex
* over file contents).
* 2. Runs the extracted block against a real temp repo whose .planning/
* directory is gitignored.
* 3. Asserts the SUMMARY is rescued into the main repo before worktree
* removal.
*/
'use strict';
// Migrated to typed-IR (#2974):
// - The "rescued: yes" text contract is now parsed into a typed
// { rescued: 'yes' | 'no' | null } record by parseRescueFooter().
// Tests assert on the parsed key, not on regex against raw content.
// - The idempotent-rescue test no longer greps stdout/stderr for
// "Rescued ..." prose. Instead it asserts the filesystem-level
// invariant: the pre-existing file's mtime is unchanged after the
// rescue runs (a true no-op on disk).
/**
* Parse a SUMMARY.md's footer-style key:value lines into a typed record.
* The rescue script appends `rescued: yes` and similar metadata; tests
* assert on the parsed values rather than regex-matching the raw content.
*
* Returns: { [key: string]: string }. Unknown lines are ignored.
*/
function parseRescueFooter(content) {
const out = {};
for (const line of content.split('\n')) {
const m = line.match(/^([a-z_][\w-]*):\s*(.+?)\s*$/);
if (m) out[m[1]] = m[2];
}
return out;
}
const { describe, test, before, after } = require('node:test');
const isWindows = process.platform === 'win32';
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync, spawnSync } = require('child_process');
const REPO_ROOT = path.join(__dirname, '..');
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-phase.md');
const QUICK_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'quick.md');
/**
* Extract the rescue block (the bash lines that detect+rescue the
* uncommitted SUMMARY.md). We locate it by:
* - Finding the line that contains "Safety net" AND "SUMMARY"
* - Reading forward until the indent drops back to the surrounding level
* OR we hit a blank line followed by a non-comment, non-rescue line.
*
* To keep this robust, we scan from the safety-net comment forward until we
* either reach `done` (new fix) or `fi` (old fix) followed by a blank line.
*/
function extractRescueBlock(filePath) {
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
let startIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (/Safety net/.test(lines[i]) && /SUMMARY/.test(lines[i])) {
startIdx = i;
break;
}
}
assert.notStrictEqual(startIdx, -1, `Could not find Safety net SUMMARY rescue block in ${filePath}`);
// Capture from comment through the terminator. The new fix ends with
// `done < <(find ... )`. The old fix ended with `fi` followed by a blank
// line. We accumulate until we find one of those terminators; if we see
// `done < <(find` we always stop there (it can only appear after `fi`).
const collected = [];
let sawFi = false;
for (let i = startIdx; i < lines.length; i++) {
collected.push(lines[i]);
const trimmed = lines[i].trim();
if (/^done\s*<\s*<\(find/.test(trimmed)) return collected.join('\n');
if (/^fi\s*$/.test(trimmed) && i > startIdx + 3) {
sawFi = true;
// Peek next line — if it's blank and the line after is not part of
// the new-fix `done`, treat fi as terminator (old block).
const next = (lines[i + 1] || '').trim();
const next2 = (lines[i + 2] || '').trim();
const newFixContinues = /^done\s*<\s*<\(find/.test(next) || /^done\s*<\s*<\(find/.test(next2);
if (!newFixContinues) {
return collected.join('\n');
}
}
}
assert.fail(`No terminator found in rescue block of ${filePath}`);
return collected.join('\n');
}
function sh(cwd, cmd) {
const r = spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf-8' });
if (r.status !== 0) {
throw new Error(`Command failed (${r.status}): ${cmd}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`);
}
return r.stdout;
}
/**
* Build a temp repo that mirrors the bug repro from issue #2838 and run
* the rescue block against it. Returns { tmp, wt, summaryFinalPath }.
*/
function runRescueScenario(rescueBlock) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2838-'));
const wt = path.join(tmp, 'wt');
sh(tmp, 'git init -q -b main');
sh(tmp, 'git config user.email test@example.com && git config user.name test');
fs.writeFileSync(path.join(tmp, '.gitignore'), '.planning/\n');
fs.writeFileSync(path.join(tmp, 'README.md'), 'init\n');
sh(tmp, 'git add .gitignore README.md && git commit -q -m init');
// Create worktree on a feature branch
sh(tmp, `git worktree add -q -b sim-executor "${wt}"`);
// Simulate the executor: untracked SUMMARY.md under gitignored .planning/
const summaryDir = path.join(wt, '.planning', 'quick', '260429-repro');
fs.mkdirSync(summaryDir, { recursive: true });
const summarySrc = path.join(summaryDir, '260429-repro-SUMMARY.md');
fs.writeFileSync(summarySrc, 'status: complete\nrescued: yes\n');
// Confirm precondition: --exclude-standard misses the file (this is the bug)
const ls = sh(tmp, `git -C "${wt}" ls-files --modified --others --exclude-standard -- "*SUMMARY.md" || true`);
assert.strictEqual(ls.trim(), '', 'Precondition: --exclude-standard must hide gitignored SUMMARY');
// Run the rescue block. It expects WT, WT_BRANCH to be set, and to be run
// from the main repo root.
const script = `
set -u
WT="${wt}"
WT_BRANCH="sim-executor"
cd "${tmp}"
${rescueBlock}
`;
const r = spawnSync('bash', ['-c', script], { cwd: tmp, encoding: 'utf-8' });
// Don't fail on non-zero — original block has || true everywhere; we
// judge by outcome.
// Now do the worktree removal that would have lost the file
sh(tmp, `git worktree remove "${wt}" --force`);
const summaryFinalPath = path.join(tmp, '.planning', 'quick', '260429-repro', '260429-repro-SUMMARY.md');
return { tmp, summaryFinalPath, rescueOut: r.stdout + r.stderr };
}
function cleanup(tmp) {
try { fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch (_) {}
}
describe('bug-2838: SUMMARY rescue handles gitignored .planning/',
{ skip: isWindows ? 'extracts and executes bash rescue blocks from quick.md/execute-phase.md (find | while read, `done < <(find ...)`); the POSIX shell contract itself is what is under test' : false },
() => {
test('execute-phase.md rescue block recovers SUMMARY when .planning/ is gitignored', () => {
const block = extractRescueBlock(EXECUTE_PHASE_PATH);
const { tmp, summaryFinalPath, rescueOut } = runRescueScenario(block);
try {
assert.ok(
fs.existsSync(summaryFinalPath),
`SUMMARY was lost — rescue did not surface the file into main repo.\nRescue output:\n${rescueOut}`
);
const content = fs.readFileSync(summaryFinalPath, 'utf-8');
const footer = parseRescueFooter(content);
assert.equal(footer.rescued, 'yes',
`expected typed footer.rescued === 'yes', got ${JSON.stringify(footer)}`);
} finally {
cleanup(tmp);
}
});
test('quick.md rescue block recovers SUMMARY when .planning/ is gitignored', () => {
const block = extractRescueBlock(QUICK_PATH);
const { tmp, summaryFinalPath, rescueOut } = runRescueScenario(block);
try {
assert.ok(
fs.existsSync(summaryFinalPath),
`SUMMARY was lost — rescue did not surface the file into main repo.\nRescue output:\n${rescueOut}`
);
const content = fs.readFileSync(summaryFinalPath, 'utf-8');
const footer = parseRescueFooter(content);
assert.equal(footer.rescued, 'yes',
`expected typed footer.rescued === 'yes', got ${JSON.stringify(footer)}`);
} finally {
cleanup(tmp);
}
});
test('rescue is idempotent when SUMMARY already present in main repo', () => {
const block = extractRescueBlock(EXECUTE_PHASE_PATH);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2838-idem-'));
const wt = path.join(tmp, 'wt');
try {
sh(tmp, 'git init -q -b main');
sh(tmp, 'git config user.email test@example.com && git config user.name test');
fs.writeFileSync(path.join(tmp, '.gitignore'), '.planning/\n');
fs.writeFileSync(path.join(tmp, 'README.md'), 'init\n');
sh(tmp, 'git add .gitignore README.md && git commit -q -m init');
sh(tmp, `git worktree add -q -b sim-executor "${wt}"`);
const dir = path.join(wt, '.planning', 'quick', 'x');
fs.mkdirSync(dir, { recursive: true });
const body = 'identical\n';
fs.writeFileSync(path.join(dir, 'x-SUMMARY.md'), body);
// Pre-place the same content in main repo
const mainDir = path.join(tmp, '.planning', 'quick', 'x');
fs.mkdirSync(mainDir, { recursive: true });
const mainSummary = path.join(mainDir, 'x-SUMMARY.md');
fs.writeFileSync(mainSummary, body);
// Capture a full filesystem snapshot BEFORE the rescue runs.
// Idempotent contract: when content already matches, the rescue must
// not touch the file. Migrated from a console-output grep
// (`stdout+stderr` did not contain "Rescued") to a typed on-disk
// check. mtimeMs alone is insufficient on coarse-grained filesystems
// (HFS+, FAT) where two rewrites within ~1s share an mtime — CR
// outside-diff finding (#3016). Snapshot includes mtime, ctime,
// size, ino, and a sha256 of contents so a rewrite is detectable
// even when the timestamp aliases.
const crypto = require('crypto');
const snapshotFile = (p) => {
const st = fs.statSync(p);
const hash = crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
return { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, ino: st.ino, hash };
};
const snapBefore = snapshotFile(mainSummary);
const script = `
set -u
WT="${wt}"
WT_BRANCH="sim-executor"
cd "${tmp}"
${block}
`;
const r = spawnSync('bash', ['-c', script], { cwd: tmp, encoding: 'utf-8' });
assert.strictEqual(
r.status,
0,
`Rescue block failed unexpectedly.\nstdout: ${r.stdout}\nstderr: ${r.stderr}`
);
// Typed-IR idempotency check (#2974): full snapshot unchanged. The
// sha256 hash catches rewrites that mtimeMs would miss on
// coarse-grained filesystems.
const snapAfter = snapshotFile(mainSummary);
assert.deepStrictEqual(snapAfter, snapBefore,
'rescue must not touch the file when content already matches (idempotent)');
assert.strictEqual(fs.readFileSync(mainSummary, 'utf-8'), body);
} finally {
try { sh(tmp, `git worktree remove "${wt}" --force`); } catch (_) {}
cleanup(tmp);
}
});
});