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>
This commit is contained in:
Tom Boucher
2026-05-16 12:03:44 -04:00
parent aac6d3635b
commit 23b52f1a14
7 changed files with 39 additions and 12 deletions

View File

@@ -22,7 +22,10 @@ const CANONICAL_TOOLS = new Set([
]);
function parseFrontmatter(content) {
const lines = content.split('\n');
// CRLF-tolerant split: Windows checkouts (autocrlf=true) leave a trailing
// \r on every line, making lines.indexOf('---', 1) return -1 (the value
// would be '---\r', not '---') → returns {} → every field appears missing.
const lines = content.split(/\r?\n/);
if (lines[0].trim() !== '---') return {};
const end = lines.indexOf('---', 1);
if (end === -1) return {};

View File

@@ -33,6 +33,8 @@ const os = require('os');
const { cleanup } = require('./helpers.cjs');
const isWindows = process.platform === 'win32';
// The exact discovery pipeline from get-shit-done/workflows/quick.md and
// get-shit-done/workflows/execute-phase.md (line: `WORKTREES=$(git worktree
// list --porcelain | grep "^worktree " | grep "\.claude/worktrees/agent-" |
@@ -176,7 +178,9 @@ describe('bug #2774 — worktree cleanup pipeline must not target the parent wor
);
});
test('while/read loop iterates each whitespace-bearing path exactly once', () => {
test('while/read loop iterates each whitespace-bearing path exactly once',
{ skip: isWindows ? 'POSIX bash process-substitution `< <(...)` under test; not portable to cmd.exe / git-bash variance' : false },
() => {
// Verify the actual consumer pattern from quick.md / execute-phase.md:
// while IFS= read -r WT; do ...; done < <(<pipeline>)
// Counts the lines yielded to the loop body. With the previous
@@ -222,7 +226,9 @@ done < <(${DISCOVERY_PIPELINE})
});
});
describe('end-to-end against real git worktrees', () => {
describe('end-to-end against real git worktrees',
{ skip: isWindows ? 'POSIX shell discovery pipeline under test + Windows 8.3 short-name (RUNNER~1) vs long-name path mismatch in temp dirs' : false },
() => {
let upstream;
let workspace;
let agentWorktree;

View File

@@ -50,6 +50,8 @@ function parseRescueFooter(content) {
}
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');
@@ -166,7 +168,9 @@ function cleanup(tmp) {
try { fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch (_) {}
}
describe('bug-2838: SUMMARY rescue handles gitignored .planning/', () => {
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);

View File

@@ -15,8 +15,10 @@ process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const ROOT = path.join(__dirname, '..');
const SETTINGS_PATH = path.join(os.tmpdir(), `gsd-test-settings-${process.pid}.json`);
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
function captureFinishInstallOutput(runtime, isGlobal) {
@@ -25,7 +27,7 @@ function captureFinishInstallOutput(runtime, isGlobal) {
console.log = (...args) => { lines.push(args.join(' ')); };
try {
installModule.finishInstall(
'/tmp/gsd-test-settings.json',
SETTINGS_PATH,
{},
null,
false,

View File

@@ -31,7 +31,10 @@ function parseAdr(filePath) {
throw new Error(`Cannot read ADR file: ${filePath} — ${err.message}`);
}
const lines = raw.split('\n');
// CRLF-tolerant split: Windows checkouts (autocrlf=true) include \r\n.
// Capture groups like /^##\s+(.+)$/ would otherwise pull a trailing \r
// into headings (e.g. "decision\r"), breaking heading equality checks.
const lines = raw.split(/\r?\n/);
let title = null;
const headings = [];
let status = null;
@@ -59,7 +62,10 @@ function parseReadmeIndex(filePath) {
throw new Error(`Cannot read ADR README: ${filePath} — ${err.message}`);
}
const lines = raw.split('\n');
// CRLF-tolerant split: Windows checkouts (autocrlf=true) include \r\n.
// Capture groups like /^##\s+(.+)$/ would otherwise pull a trailing \r
// into headings (e.g. "decision\r"), breaking heading equality checks.
const lines = raw.split(/\r?\n/);
const linkedFiles = [];
for (const line of lines) {

View File

@@ -107,7 +107,9 @@ 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 });
// 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 });
}
/**

View File

@@ -13,20 +13,24 @@
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const REPO_ROOT = path.join(__dirname, '..');
const BRIDGE_PATH = path.join(REPO_ROOT, 'sdk', 'dist', 'runtime-bridge-sync', 'index.js');
// Node's ESM loader rejects Windows absolute paths with `import()` — must be a
// file:// URL. pathToFileURL is a no-op for POSIX paths (produces file:///abs/...).
const BRIDGE_URL = pathToFileURL(BRIDGE_PATH).href;
describe('runtime-bridge-sync CJS smoke test', () => {
test('executeForCjs is exported and is a function', async () => {
// Use dynamic import because Node 24 supports require() of ESM but
// the module is ESM (NodeNext output). Dynamic import works in all contexts.
const mod = await import(BRIDGE_PATH);
const mod = await import(BRIDGE_URL);
assert.strictEqual(typeof mod.executeForCjs, 'function', 'executeForCjs must be a function');
});
test('executeForCjs returns ok:true for generate-slug (success path)', async () => {
const { executeForCjs } = await import(BRIDGE_PATH);
const { executeForCjs } = await import(BRIDGE_URL);
const result = executeForCjs({
registryCommand: 'generate-slug',
@@ -52,7 +56,7 @@ describe('runtime-bridge-sync CJS smoke test', () => {
});
test('executeForCjs returns ok:false for unknown command', async () => {
const { executeForCjs } = await import(BRIDGE_PATH);
const { executeForCjs } = await import(BRIDGE_URL);
const result = executeForCjs({
registryCommand: '__smoke_test_unknown_command__',
@@ -73,7 +77,7 @@ describe('runtime-bridge-sync CJS smoke test', () => {
});
test('executeForCjs result shape matches RuntimeBridgeSyncResult discriminated union', async () => {
const { executeForCjs } = await import(BRIDGE_PATH);
const { executeForCjs } = await import(BRIDGE_URL);
// Success shape
const success = executeForCjs({