Merge pull request #3649 from gsd-build/feat/3597-split-suites-node-matrix

feat(3597): split test suites and add Node 22/24/26 OS matrix
This commit is contained in:
Tom Boucher
2026-05-17 01:51:25 -04:00
committed by GitHub
73 changed files with 1311 additions and 423 deletions

View File

@@ -22,6 +22,7 @@ const { execFileSync } = require('child_process');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const { install, copyCommandsAsClaudeSkills } = require(INSTALL_SRC);
const { cleanup } = require('./helpers.cjs');
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
// With --test-concurrency=4, other install tests (bug-1834, bug-1924) run
@@ -46,7 +47,9 @@ describe('#1736: local Claude install populates .claude/commands/gsd/', () => {
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
// Use the shared helper which has a 5s Windows-EBUSY retry budget
// (20×250ms). The inline 1s budget here was insufficient on cold runners.
cleanup(tmpDir);
});
test('local install creates .claude/commands/gsd/ directory', (t) => {

View File

@@ -18,6 +18,7 @@ const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
@@ -99,7 +100,18 @@ describe('#1974 context exhaustion auto-record', () => {
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
cleanup(tmpDir);
break;
} catch (err) {
const code = err && err.code;
const transient = code === 'EPERM' || code === 'EBUSY' || code === 'ENOTEMPTY';
if (!transient || attempt === 4) throw err;
sleep(250 * (attempt + 1));
}
}
// Clean up bridge files
try {
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);

View File

@@ -113,7 +113,7 @@ describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder
// — POSIX guarantees /bin/sh but not /bin/bash, and distros like NixOS
// do not ship /bin/bash by default.
for (const sh of SH_HOOKS) {
const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split('\n');
const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split(/\r?\n/);
assert.strictEqual(
lines[0],
'#!/usr/bin/env bash',

View File

@@ -28,6 +28,7 @@ const { execFileSync } = require('child_process');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const { install, finishInstall } = require(INSTALL_SRC);
const { cleanup } = require('./helpers.cjs');
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
before(() => {
@@ -47,7 +48,8 @@ describe('#2248: local Claude install does not clobber profile-level statusLine'
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
// Use the shared 5s Windows-EBUSY retry budget instead of inline 1s.
cleanup(tmpDir);
});
test('local install does not write statusLine to .claude/settings.json', (t) => {

View File

@@ -19,6 +19,8 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const isWindows = process.platform === 'win32';
const {
readGsdEffectiveModelOverrides,
generateCodexAgentToml,
@@ -26,9 +28,8 @@ const {
getCodexSkillAdapterHeader,
} = require('../bin/install.js');
function makeTmp(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2256-${prefix}-`));
}
const { createTempDir } = require('./helpers.cjs');
const makeTmp = (prefix) => createTempDir(`gsd-2256-${prefix}-`);
function writeJson(p, obj) {
fs.mkdirSync(path.dirname(p), { recursive: true });
@@ -43,17 +44,27 @@ describe('bug #2256 — readGsdEffectiveModelOverrides', () => {
let projectDir;
let homeDir;
let origHome;
let origUserProfile;
beforeEach(() => {
projectDir = makeTmp('proj');
homeDir = makeTmp('home');
origHome = process.env.HOME;
// On Windows, os.homedir() reads USERPROFILE (not HOME). Tests that
// need to redirect ~ must override both — otherwise the SUT reads
// the real user's home and the fixture is invisible.
origUserProfile = process.env.USERPROFILE;
process.env.HOME = homeDir;
if (isWindows) process.env.USERPROFILE = homeDir;
});
afterEach(() => {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (isWindows) {
if (origUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = origUserProfile;
}
rmr(projectDir);
rmr(homeDir);
});

View File

@@ -97,21 +97,25 @@ describe('bug #2647: outer tarball ships sdk/dist so gsd-sdk query works', () =>
// if sdk/dist/cli.js already exists, use it; otherwise build.
const sdkDir = path.join(REPO_ROOT, 'sdk');
const cliJs = path.join(sdkDir, 'dist', 'cli.js');
// On Windows `npm` is the shell script `npm.cmd`; without {shell:true}
// execFileSync only finds literal-name binaries and errors with ENOENT.
const isWindows = process.platform === 'win32';
const npmCmd = isWindows ? 'npm.cmd' : 'npm';
if (!fs.existsSync(cliJs)) {
// Build requires node_modules; install if missing, then build.
const sdkNodeModules = path.join(sdkDir, 'node_modules');
if (!fs.existsSync(sdkNodeModules)) {
execFileSync('npm', ['ci', '--silent'], { cwd: sdkDir, stdio: 'pipe', env: npmEnv });
execFileSync(npmCmd, ['ci', '--silent'], { cwd: sdkDir, stdio: 'pipe', env: npmEnv, shell: isWindows });
}
execFileSync('npm', ['run', 'build'], { cwd: sdkDir, stdio: 'pipe', env: npmEnv });
execFileSync(npmCmd, ['run', 'build'], { cwd: sdkDir, stdio: 'pipe', env: npmEnv, shell: isWindows });
}
assert.ok(fs.existsSync(cliJs), 'sdk build must produce sdk/dist/cli.js');
try {
const out = execFileSync(
'npm',
npmCmd,
['pack', '--dry-run', '--json', '--ignore-scripts'],
{ cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: npmEnv },
{ cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: npmEnv, shell: isWindows },
).toString('utf-8');
const manifest = JSON.parse(out);
const files = manifest[0].files.map((f) => f.path);

View File

@@ -43,6 +43,7 @@ const { execFileSync } = require('child_process');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const { install, GSD_CODEX_MARKER } = require(INSTALL_SRC);
const { cleanup } = require('./helpers.cjs');
// Ensure hooks/dist/ is populated before install tests
before(() => {
@@ -60,7 +61,8 @@ describe('#2698: CRLF stale gsd-update-check block is removed on Codex reinstall
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
// Use the shared 5s Windows-EBUSY retry budget instead of inline 1s.
cleanup(tmpDir);
});
// Helper: pre-populate .codex/config.toml with a GSD marker + stale hooks block

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

@@ -36,42 +36,14 @@ const fs = require('fs');
const path = require('path');
const installModule = require('../bin/install.js');
const isWindows = process.platform === 'win32';
const { installSdkIfNeeded } = installModule;
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
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;
}
// Re-throw any captured exception AFTER restoring console so callers don't
// have to destructure-and-assert on `threw` (and a future regression that
// crashes before printing won't falsely pass `!hasReady`). (#2775
// CodeRabbit follow-up)
if (threw) throw threw;
// strip ANSI for matching
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
return {
stdout: stdout.map(strip).join('\n'),
stderr: stderr.map(strip).join('\n'),
};
}
describe('bug #2775: installSdkIfNeeded must verify gsd-sdk on PATH before reporting ready', () => {
describe('bug #2775: installSdkIfNeeded must verify gsd-sdk on PATH before reporting ready',
{ skip: isWindows ? 'POSIX-only: asserts ~/.local/bin shebang shim with mode 0o755; Windows uses gsd-sdk.cmd + PATHEXT + registry Path' : false },
() => {
let tmpRoot;
let sdkDir;
let pathDir;

View File

@@ -61,10 +61,10 @@ describe('bug-2784: update.md cache-clear covers shared cache path', () => {
const stepContent = stepMatch[0];
const bashLines = [];
const fenceRe = /```(?:bash|sh)\n([\s\S]*?)```/g;
const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/g;
let m;
while ((m = fenceRe.exec(stepContent)) !== null) {
for (const line of m[1].split('\n')) {
for (const line of m[1].split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed) bashLines.push(trimmed);
}

View File

@@ -35,9 +35,8 @@ const {
install,
} = require('../bin/install.js');
function makeTmp(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2794-${prefix}-`));
}
const { createTempDir } = require('./helpers.cjs');
const makeTmp = (prefix) => createTempDir(`gsd-2794-${prefix}-`);
function writeJson(p, obj) {
fs.mkdirSync(path.dirname(p), { recursive: true });

View File

@@ -105,7 +105,7 @@ describe('bug-2801: ingest-docs.md workflow calls gsd-tools not gsd-sdk', () =>
const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8');
// Extract bash fenced code blocks structurally.
const bashBlocks = [];
const codeBlockRe = /```bash\n([\s\S]*?)```/g;
const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g;
let m;
while ((m = codeBlockRe.exec(content)) !== null) {
bashBlocks.push(m[1]);
@@ -129,7 +129,7 @@ describe('bug-2801: ingest-docs.md workflow calls gsd-tools not gsd-sdk', () =>
test('ingest-docs.md init step uses canonical node-path gsd-tools.cjs invocation', () => {
const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8');
// Parse fenced bash blocks structurally — do not match raw markdown text.
const codeBlockRe = /```bash\n([\s\S]*?)```/g;
const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g;
const bashLines = [...content.matchAll(codeBlockRe)]
.flatMap((m) => m[1].split('\n'))
.filter((l) => !/^\s*#/.test(l));

View File

@@ -29,36 +29,13 @@ const fs = require('fs');
const path = require('path');
const { installSdkIfNeeded } = require('../bin/install.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
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'),
};
}
const isWindows = process.platform === 'win32';
describe('bug #2829: local-mode install must materialize gsd-sdk on PATH', () => {
describe('bug #2829: local-mode install must materialize gsd-sdk on PATH',
{ skip: isWindows ? 'POSIX-only: asserts ~/.local/bin shebang shim; Windows uses gsd-sdk.cmd + USERPROFILE + PATHEXT' : false },
() => {
let tmpRoot;
let sdkDir;
let pathDir;

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');
@@ -163,10 +165,12 @@ ${rescueBlock}
}
function cleanup(tmp) {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
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

@@ -30,19 +30,9 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const SENTINEL_NAME = '.review-fix-recovery-pending.json';
const { parseFrontmatter } = require('./helpers.cjs');
function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
const body = match[1];
const out = {};
for (const line of body.split('\n')) {
const m = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (m) out[m[1]] = m[2].trim();
}
return out;
}
const SENTINEL_NAME = '.review-fix-recovery-pending.json';
function extractStep(content, stepName) {
const re = new RegExp(`<step\\s+name="${stepName}">([\\s\\S]*?)</step>`);

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

@@ -58,7 +58,9 @@ const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'releas
* one for a single test isn't justified.
*/
function extractStepRun(workflowText, stepName) {
const lines = workflowText.split('\n');
// CRLF-tolerant split: Windows checkout (autocrlf=true) leaves trailing
// \r on every line which downstream regex anchors don't tolerate.
const lines = workflowText.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(\s*)- name:\s*(.+?)\s*$/);
if (!m || m[2] !== stepName) continue;
@@ -197,7 +199,9 @@ describe('bug-2966: release-sdk hotfix cherry-pick classifies context-missing vs
const blocks = [];
let inHead = false;
let head = '';
for (const line of conflicted.split('\n')) {
for (const rawLine of conflicted.split(/\r?\n/)) {
// Strip residual \r so /^=======$/ matches even on CRLF content.
const line = rawLine.replace(/\r$/, '');
if (/^<<<<<<< /.test(line)) { inHead = true; head = ''; continue; }
if (/^=======$/.test(line) && inHead) { inHead = false; continue; }
if (/^>>>>>>> /.test(line)) { blocks.push(head); head = ''; continue; }
@@ -217,7 +221,7 @@ describe('bug-2966: release-sdk hotfix cherry-pick classifies context-missing vs
// exercises the exact predicate that runs in CI — not a copy.
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
const awkMatch = script.match(/awk '\n([\s\S]+?)' "\$CONFLICTED"/);
const awkMatch = script.match(/awk '\r?\n([\s\S]+?)' "\$CONFLICTED"/);
assert.ok(awkMatch, 'expected to find the conflict-classifying awk script in the workflow');
const awkProgram = awkMatch[1];

View File

@@ -128,7 +128,8 @@ describe('Bug #2969: deterministic Step 5 verification gate', () => {
assert.equal(status, 1);
assert.equal(report.failures, 1);
const r0 = report.results[0];
assert.equal(r0.file, 'skills/discuss-phase/SKILL.md');
// Normalize separators: on Windows the SUT emits 'skills\discuss-phase\SKILL.md'.
assert.equal(r0.file.replace(/\\/g, '/'), 'skills/discuss-phase/SKILL.md');
assert.equal(r0.status, 'fail');
assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING);
assert.ok(

View File

@@ -153,7 +153,7 @@ describe('Bug #3362 / #3413: Windows hook commands are runtime-aware', () => {
describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => {
test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => {
const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh');
const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh', { platform: 'linux' });
const parsed = parseHookCommand(cmd);
assert.equal(parsed.runner, 'bash');
});
@@ -364,7 +364,7 @@ describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites ba
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' });
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
@@ -381,7 +381,7 @@ describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites ba
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' });
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,

View File

@@ -124,8 +124,14 @@ describe('Bug #3017: buildCodexHookBlock emits absolute node runner', () => {
// pass — e.g. '/Users/x/notnode/foo'.
assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath,
`parsed runner must equal supplied absolute path: got ${parsed.runner}, want ${expectedRunnerPath}`);
assert.equal(parsed.hookPath, '/tmp/codex-test/.codex/hooks/gsd-check-update.js',
`hook path equality, got: ${parsed.hookPath}`);
// On Windows, path.resolve prepends the current drive letter ("D:") to
// the POSIX-shaped fixture path. Accept either form.
const expectedHookSuffix = '/tmp/codex-test/.codex/hooks/gsd-check-update.js';
assert.ok(
parsed.hookPath === expectedHookSuffix ||
parsed.hookPath.replace(/^[A-Za-z]:/, '') === expectedHookSuffix,
`hook path equality, got: ${parsed.hookPath}, want suffix: ${expectedHookSuffix}`,
);
});
test('returns null when absoluteRunner is null (caller skips registration)', () => {

View File

@@ -26,36 +26,13 @@ const path = require('path');
const os = require('os');
const { installSdkIfNeeded } = require('../bin/install.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
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'),
};
}
const isWindows = process.platform === 'win32';
describe('bug #3033: --sdk flag (opts.forceSdk) must be wired into installSdkIfNeeded', () => {
describe('bug #3033: --sdk flag (opts.forceSdk) must be wired into installSdkIfNeeded',
{ skip: isWindows ? 'POSIX-only: forces shebang gsd-sdk shim into ~/.local/bin and asserts mode 0o755' : false },
() => {
let tmpRoot;
let sdkDir;
let pathDir;

View File

@@ -13,13 +13,13 @@ function read(rel) {
function extractFindingsProbesFromBashBlocks(markdown) {
const probes = [];
const fenceRe = /```bash\n([\s\S]*?)```/g;
const fenceRe = /```bash\r?\n([\s\S]*?)```/g;
let fenceMatch;
while ((fenceMatch = fenceRe.exec(markdown)) !== null) {
const block = fenceMatch[1];
const baseLine = markdown.slice(0, fenceMatch.index).split('\n').length;
const lines = block.split('\n');
const baseLine = markdown.slice(0, fenceMatch.index).split(/\r?\n/).length;
const lines = block.split(/\r?\n/);
lines.forEach((line, idx) => {
if (!line.includes('.claude/skills/')) return;

View File

@@ -106,14 +106,14 @@ describe('bug #3126: runtime-homes env-var overrides', () => {
test('opencode uses XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR absent', () => {
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), '/xdg/opencode');
assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode'));
});
});
});
test('kilo uses XDG_CONFIG_HOME when KILO_CONFIG_DIR absent', () => {
withEnv('KILO_CONFIG_DIR', undefined, () => {
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), '/xdg/kilo');
assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo'));
});
});
});

View File

@@ -16,7 +16,7 @@ const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const { createTempProject, cleanup, runGsdTools, toPosixPath } = require('./helpers.cjs');
function setupMilestoneArchiveProject(tmpDir, options = {}) {
const {
@@ -151,6 +151,7 @@ describe('#3164 — validate consistency: milestone-archive layout', () => {
const out = JSON.parse(result.output);
const warnings = out.warnings || [];
const warningsPosix = warnings.map(w => toPosixPath(w));
const phase64Warnings = warnings.filter(w => w.includes('Phase 64 exists on disk but not in ROADMAP.md'));
assert.deepStrictEqual(
phase64Warnings,
@@ -158,12 +159,12 @@ describe('#3164 — validate consistency: milestone-archive layout', () => {
`Old archived milestone phase 64 should not be treated as active:\n ${phase64Warnings.join('\n ')}`
);
assert.ok(
warnings.some(w => w.includes('Gap in plan numbering in milestones/v1.7-phases/65-current')),
warningsPosix.some(w => /Gap in plan numbering in .*milestones\/v1\.7-phases\/65-current/.test(w)),
`Expected plan numbering warning from active archive root, got:\n ${warnings.join('\n ')}`
);
assert.ok(
warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-01-PLAN.md: missing 'wave'"))
|| warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-03-PLAN.md: missing 'wave'")),
warningsPosix.some(w => /milestones\/v1\.7-phases\/65-current\/65-01-PLAN\.md: missing 'wave'/.test(w))
|| warningsPosix.some(w => /milestones\/v1\.7-phases\/65-current\/65-03-PLAN\.md: missing 'wave'/.test(w)),
`Expected frontmatter warning from active archive plans, got:\n ${warnings.join('\n ')}`
);
});

View File

@@ -50,6 +50,9 @@ const cp = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
const { captureConsole } = require('./helpers.cjs');
const isWindows = process.platform === 'win32';
const {
filterNpxFromPath,
@@ -202,7 +205,9 @@ describe('bug #3211-C: getUserShellWindowsPersistentPath export', () => {
}
});
test('when the PowerShell probe is mocked to return a path, strips _npx dirs', () => {
test('when the PowerShell probe is mocked to return a path, strips _npx dirs',
{ skip: isWindows ? 'cp.execSync reassignment is not picked up by install.js on Windows; real registry Path is returned. POSIX coverage in mock; live Windows path is covered by 3211-D.' : false },
() => {
// Mock cp.execSync to return a Windows Path with both persistent and _npx dirs.
const savedExecSync = cp.execSync;
const winPersistentDir = 'C:\\Users\\user\\AppData\\Roaming\\npm';
@@ -250,27 +255,6 @@ describe('bug #3211-D: installSdkIfNeeded — Windows _npx false-positive', () =
let savedEnv;
let origExecSync;
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') };
}
function makeSdkDir(root) {
const dir = path.join(root, 'sdk');
fs.mkdirSync(path.join(dir, 'dist'), { recursive: true });

View File

@@ -36,6 +36,10 @@ const os = require('node:os');
const path = require('node:path');
const installModule = require('../bin/install.js');
const { captureConsole } = require('./helpers.cjs');
const isWindows = process.platform === 'win32';
const {
installSdkIfNeeded,
isGsdSdkOnPath,
@@ -46,33 +50,6 @@ const {
// ---------------------------------------------------------------------------
// Console capture helper (no ANSI)
// ---------------------------------------------------------------------------
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'),
};
}
// ---------------------------------------------------------------------------
// Shared fixture helpers
// ---------------------------------------------------------------------------
@@ -90,7 +67,9 @@ function makeSdkDir(root) {
// ---------------------------------------------------------------------------
// Bug 1: transient npx PATH hit + null login-shell PATH → false "GSD SDK ready"
// ---------------------------------------------------------------------------
describe('bug #3231: transient npx PATH + null login-shell PATH', () => {
describe('bug #3231: transient npx PATH + null login-shell PATH',
{ skip: isWindows ? 'Linux-specific: simulates getUserShellPath()=null + POSIX shebang shim; Windows path is covered by #3211' : false },
() => {
let tmpRoot;
let sdkDir;
let savedEnv;
@@ -205,7 +184,9 @@ describe('bug #3231: transient npx PATH + null login-shell PATH', () => {
// ---------------------------------------------------------------------------
// Bug 2: stale legacy symlink pointing at gsd-tools.cjs (deprecated binary)
// ---------------------------------------------------------------------------
describe('bug #3231: stale legacy symlink to deprecated gsd-tools.cjs', () => {
describe('bug #3231: stale legacy symlink to deprecated gsd-tools.cjs',
{ skip: isWindows ? 'POSIX-only: relies on fs.symlinkSync + mode bits + bare shim filename' : false },
() => {
let tmpRoot;
let sdkDir;
let savedEnv;
@@ -353,7 +334,9 @@ describe('bug #3231: stale legacy symlink to deprecated gsd-tools.cjs', () => {
// ---------------------------------------------------------------------------
// Test 3: clean install with gsd-sdk self-linked into a persistent PATH dir
// ---------------------------------------------------------------------------
describe('bug #3231: clean install — gsd-sdk self-linked into persistent PATH dir', () => {
describe('bug #3231: clean install — gsd-sdk self-linked into persistent PATH dir',
{ skip: isWindows ? 'POSIX-only: asserts bare gsd-sdk shim in ~/.local/bin' : false },
() => {
let tmpRoot;
let sdkDir;
let savedEnv;

View File

@@ -39,13 +39,10 @@ const { install } = require('../bin/install.js');
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeTmpDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
const { createTempDir, cleanup } = require('./helpers.cjs');
const makeTmpDir = createTempDir;
function rmTmpDir(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
const rmTmpDir = cleanup;
/**
* Silence console output during install to avoid noise in test output.
@@ -82,11 +79,17 @@ function silenceConsole(fn) {
describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
let tmpRoot;
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
beforeEach(() => {
tmpRoot = makeTmpDir('gsd-3288-');
savedHome = process.env.HOME;
// On Windows, os.homedir() reads USERPROFILE (and HOMEDRIVE+HOMEPATH), NOT
// HOME. install() resolves the install destination via os.homedir(), so the
// tests must also redirect USERPROFILE → tmpRoot on win32 to keep the
// installer writing inside the fixture.
savedUserProfile = process.env.USERPROFILE;
// Stash and clear explicitConfigDir via env so install() picks up our tmp dir.
// Must delete (not just save) so any CI-set value doesn't leak into install()
// and target a different directory than tmpRoot (CR finding, PR #3293).
@@ -96,6 +99,8 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
afterEach(() => {
process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedExplicitConfigDir === undefined) {
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
} else {
@@ -179,6 +184,7 @@ module.exports = { catalog };
const claudeDir = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
process.env.HOME = tmpRoot;
process.env.USERPROFILE = tmpRoot;
// Capture process.exit to prevent the test from being killed.
const origExit = process.exit;

View File

@@ -15,7 +15,7 @@ function verifierProbeContract(content) {
assert.notEqual(sectionEnd, -1, 'verifier must close Step 7c before Step 8');
const section = content.slice(sectionStart, sectionEnd);
const codeBlocks = [...section.matchAll(/```bash\n([\s\S]*?)\n```/g)].map((match) => match[1]);
const codeBlocks = [...section.matchAll(/```bash\r?\n([\s\S]*?)\r?\n```/g)].map((match) => match[1].split(/\r?\n/).join('\n'));
const executionSteps = [...section.matchAll(/^\d+\.\s+(.+)$/gm)].map((match) => match[1]);
return {
title: 'Step 7c: Probe Execution',

View File

@@ -19,36 +19,13 @@ const path = require('path');
const { installSdkIfNeeded, readGsdSdkVersion } = require('../bin/install.js');
const cp = require('node:child_process');
const pkg = require('../package.json');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
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'),
};
}
const isWindows = process.platform === 'win32';
describe('bug #3359: installer detects stale gsd-sdk earlier on PATH', () => {
describe('bug #3359: installer detects stale gsd-sdk earlier on PATH',
{ skip: isWindows ? 'POSIX-only: stages bare gsd-sdk shebang shims in a PATH dir; Windows uses .cmd + PATHEXT resolution' : false },
() => {
let tmpRoot;
let sdkDir;
let pathDir;

View File

@@ -41,11 +41,17 @@ const WORKFLOW_PATH = path.join(
// ─── Helper: create outer git repo with a nested workstream subdir ─────────
// On Windows the runtime emits forward slashes (git's convention) while
// path.join produces backslashes — normalize both sides via the shared
// toPosixPath helper before any equality comparison.
const { toPosixPath: normalizePath } = require('./helpers.cjs');
function createOuterRepoWithSubdir(prefix = 'bug-3491-') {
const outer = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
// macOS /tmp -> /private/tmp; resolve to the canonical path so comparisons
// against `git rev-parse --show-toplevel` succeed regardless of symlink.
const outerReal = fs.realpathSync(outer);
// macOS /tmp -> /private/tmp; on Windows the runner's %TEMP% is the 8.3
// short-name (RUNNER~1) and the runtime resolves to the long form.
// realpathSync.native handles both; then normalize separators for compare.
const outerReal = fs.realpathSync.native(outer);
execSync('git init', { cwd: outerReal, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: outerReal, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: outerReal, stdio: 'pipe' });
@@ -80,8 +86,8 @@ test('bug-3491: init new-project reports has_git: true inside parent git worktre
// The workflow needs the worktree root and a nesting flag to decide
// whether to skip `git init` and emit a friendly warning.
assert.strictEqual(
payload.git_worktree_root,
outer,
normalizePath(payload.git_worktree_root),
normalizePath(outer),
`expected git_worktree_root to be the outer repo (${outer}), got: ${payload.git_worktree_root}`,
);
assert.strictEqual(
@@ -102,7 +108,7 @@ test('bug-3491: init new-project reports has_git: true at worktree root with in_
const payload = JSON.parse(result.output);
assert.strictEqual(payload.has_git, true, 'has_git must be true at the worktree root');
assert.strictEqual(payload.git_worktree_root, outer);
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
assert.strictEqual(
payload.in_nested_subdir,
false,
@@ -114,7 +120,7 @@ test('bug-3491: init new-project reports has_git: true at worktree root with in_
});
test('bug-3491: init new-project reports has_git: false outside any git worktree', () => {
const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-')));
const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-')));
try {
const result = runGsdTools('init new-project', tmp);
assert.ok(result.success, `init new-project failed: ${result.error}`);
@@ -139,7 +145,7 @@ test('bug-3491: init ingest-docs mirrors the same has_git semantics', () => {
true,
'init ingest-docs must also detect parent worktree (#3491 related path)',
);
assert.strictEqual(payload.git_worktree_root, outer);
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
assert.strictEqual(payload.in_nested_subdir, true);
} finally {
cleanup(outer);

View File

@@ -160,7 +160,10 @@ test('bug-3542: stash pushed in main checkout is visible inside a linked worktre
// not just visibility. We pop into a clean working tree on a
// different branch, so any applied content is the contamination.
execSync('git stash pop -q', { cwd: linkedWorktree, stdio: 'pipe' });
const popped = fs.readFileSync(path.join(linkedWorktree, 'a.txt'), 'utf-8');
// On Windows autocrlf=true, git rewrites stashed content with CRLF on
// checkout. Strip \r before content compare — the test pins git's
// shared-stash behavior, not line endings.
const popped = fs.readFileSync(path.join(linkedWorktree, 'a.txt'), 'utf-8').replace(/\r\n/g, '\n');
assert.strictEqual(
popped,
'wip in main\n',

View File

@@ -20,9 +20,8 @@ const SDK_SHARED_DIR = path.join(REPO_ROOT, 'sdk', 'shared');
const { install } = require('../bin/install.js');
function makeTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3571-'));
}
const { createTempDir } = require('./helpers.cjs');
const makeTmpDir = () => createTempDir('gsd-3571-');
function silenceConsole(fn) {
const original = {
@@ -45,23 +44,28 @@ function silenceConsole(fn) {
describe('bug #3571: configuration generated manifests resolve in install layout', () => {
let tmpRoot;
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
beforeEach(() => {
tmpRoot = makeTmpDir();
savedHome = process.env.HOME;
// On Windows, os.homedir() reads USERPROFILE; install() resolves via it.
savedUserProfile = process.env.USERPROFILE;
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
});
afterEach(() => {
process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedExplicitConfigDir === undefined) {
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
} else {
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
}
fs.rmSync(tmpRoot, { recursive: true, force: true });
fs.rmSync(tmpRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
test('co-located bin/shared manifests let configuration.generated.cjs load without sdk/shared', () => {
@@ -93,6 +97,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
test('post-install: install() copies configuration manifests to co-located bin/shared', () => {
process.env.HOME = tmpRoot;
process.env.USERPROFILE = tmpRoot;
silenceConsole(() => {
install(true, 'codex');

View File

@@ -32,23 +32,38 @@ function auditProductionVulns(cwd) {
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
return null; // signal "skip" to caller
}
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const isWindows = process.platform === 'win32';
const npmCandidates = isWindows ? ['npm.cmd', 'npm'] : ['npm'];
const args = ['audit', '--omit=dev', '--json'];
let out;
try {
out = execFileSync(
npmCmd,
['audit', '--omit=dev', '--json'],
{ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
);
} catch (e) {
// `npm audit` exits non-zero when advisories are present; the JSON is
// still on stdout in that case. Recover and let the assertion classify.
if (e && typeof e.stdout !== 'undefined') {
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
} else {
throw e;
let lastErr = null;
for (const npmCmd of npmCandidates) {
try {
out = execFileSync(
npmCmd,
args,
{
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 60_000,
shell: isWindows,
}
);
lastErr = null;
break;
} catch (e) {
// `npm audit` exits non-zero when advisories are present; the JSON is
// still on stdout in that case. Recover and let the assertion classify.
if (e && typeof e.stdout !== 'undefined' && e.stdout !== undefined && e.stdout !== null) {
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
lastErr = null;
break;
}
lastErr = e;
}
}
if (lastErr) throw lastErr;
const parsed = JSON.parse(out);
// `null` is reserved for the "node_modules missing → skip" signal above.
// Any other unexpected JSON shape is a real failure of the audit harness

View File

@@ -89,7 +89,7 @@ describe('bug #3608: update.md models Antigravity as a first-class runtime', ()
// Extract the inference block — the if/elif ladder that maps env vars to runtime.
// Match from the comment marker through the closing `fi` of the inference block.
const blockMatch = content.match(
/If runtime is still unknown, infer from runtime env vars[\s\S]*?\nfi\n/,
/If runtime is still unknown, infer from runtime env vars[\s\S]*?\r?\nfi\r?\n/,
);
assert.ok(blockMatch, 'env-var inference block not found');

View File

@@ -953,14 +953,15 @@ describe('config-path command (#2282)', () => {
test('returns root config path when no workstream is active', () => {
const result = runGsdTools('config-path', tmpDir);
assert.ok(result.success, `config-path failed: ${result.error}`);
assert.ok(result.output.trim().endsWith('.planning/config.json'), `expected root config path, got: ${result.output}`);
// Normalize separators: Windows emits backslashes in the resolved path.
assert.ok(result.output.trim().replace(/\\/g, '/').endsWith('.planning/config.json'), `expected root config path, got: ${result.output}`);
assert.ok(!result.output.includes('workstreams'), 'should not include workstreams in path');
});
test('returns workstream config path when GSD_WORKSTREAM is set', () => {
const result = runGsdTools('config-path', tmpDir, { GSD_WORKSTREAM: 'my-stream' });
assert.ok(result.success, `config-path failed: ${result.error}`);
assert.ok(result.output.trim().includes('workstreams/my-stream/config.json'), `expected workstream config path, got: ${result.output}`);
assert.ok(result.output.trim().replace(/\\/g, '/').includes('workstreams/my-stream/config.json'), `expected workstream config path, got: ${result.output}`);
});
test('config-path and config-get agree on the active path', () => {

View File

@@ -99,7 +99,9 @@ describe('enh-2500: gsd-codebase-mapper arch focus — rich architecture output'
test('template includes data flow traces with numbered steps', () => {
const hasPrimaryRequestPath = /###\s+Primary Request Path/i.test(archTemplate);
const hasThreeNumberedSteps = /^\s*1\..+\n\s*2\..+\n\s*3\./m.test(archTemplate);
// [^\n]+ + \r?\n is CRLF-tolerant: .+ doesn't match \r in JS regex by
// default, so \r before the literal \n in CRLF content kills the match.
const hasThreeNumberedSteps = /^\s*1\.[^\n]+\r?\n\s*2\.[^\n]+\r?\n\s*3\./m.test(archTemplate);
const hasFileLineRefs = /\(`\[.*:(?:line|\d+)\]`\)/.test(archTemplate);
assert.ok(

View File

@@ -18,7 +18,9 @@ const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
*/
function parseFrontmatter(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
const lines = raw.split('\n');
// CRLF-tolerant: Windows checkouts leave \r on every line. lines.indexOf('---', 1)
// would never match because elements would be '---\r' instead of '---'.
const lines = raw.split(/\r?\n/);
if (lines[0].trim() !== '---') return {};
const endIdx = lines.indexOf('---', 1);
if (endIdx === -1) return {};

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

@@ -36,9 +36,8 @@ const {
} = require('../get-shit-done/bin/lib/model-profiles.cjs');
const { isValidConfigKey } = require('../get-shit-done/bin/lib/config-schema.cjs');
function makeTmp(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-3023-${prefix}-`));
}
const { createTempDir } = require('./helpers.cjs');
const makeTmp = (prefix) => createTempDir(`gsd-3023-${prefix}-`);
function writeConfig(projectDir, config) {
const planningDir = path.join(projectDir, '.planning');

View File

@@ -60,9 +60,8 @@ const {
} = require('../get-shit-done/bin/lib/model-profiles.cjs');
const { isValidConfigKey } = require('../get-shit-done/bin/lib/config-schema.cjs');
function makeTmp(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-3024-${prefix}-`));
}
const { createTempDir } = require('./helpers.cjs');
const makeTmp = (prefix) => createTempDir(`gsd-3024-${prefix}-`);
function writeConfig(dir, config) {
const planningDir = path.join(dir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });

View File

@@ -36,6 +36,8 @@ const os = require('node:os');
const ROOT = path.join(__dirname, '..');
const HOOK = path.join(ROOT, 'hooks', 'gsd-graphify-update.sh');
const isWindows = process.platform === 'win32';
function createTempGitRepo(opts = {}) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3347-'));
cp.execFileSync('git', ['init', '-b', opts.defaultBranch || 'main'], {
@@ -119,7 +121,9 @@ function cleanup(tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 });
}
describe('#3347 hook — bail paths (no side effects)', () => {
describe('#3347 hook — bail paths (no side effects)',
{ skip: isWindows ? 'POSIX-only: harness spawns bash + kill -0 + sleep; the hook itself is a bash script under test' : false },
() => {
test('non-Bash tool call exits 0 with no status file', (t) => {
const tmpDir = createTempGitRepo({
config: { graphify: { enabled: true, auto_update: true } },
@@ -218,7 +222,9 @@ describe('#3347 hook — bail paths (no side effects)', () => {
});
});
describe('#3347 hook — dispatch path (all gates pass)', () => {
describe('#3347 hook — dispatch path (all gates pass)',
{ skip: isWindows ? 'POSIX-only: harness spawns bash + kill -0 + sleep; the hook itself is a bash script under test' : false },
() => {
test('writes status file with status=running synchronously before returning', (t) => {
const tmpDir = createTempGitRepo({
config: { graphify: { enabled: true, auto_update: true } },
@@ -374,7 +380,9 @@ describe('#3347 hook — dispatch path (all gates pass)', () => {
});
});
describe('#3347 hook — HEAD-advancing command matchers', () => {
describe('#3347 hook — HEAD-advancing command matchers',
{ skip: isWindows ? 'POSIX-only: harness spawns bash to invoke the .sh hook under test' : false },
() => {
for (const cmd of [
'git commit -m fix',
'git merge feature',

View File

@@ -48,9 +48,8 @@ const {
* Create a fresh real-fs scratch dir per test so no two faults share
* state. Returns the directory; caller must clean up.
*/
function mkScratch(name) {
return fs.mkdtempSync(path.join(os.tmpdir(), `fs-fault-${name}-`));
}
const { createTempDir } = require('./helpers.cjs');
const mkScratch = (name) => createTempDir(`fs-fault-${name}-`);
/**
* Enumerate orphan tmp files left behind by platformWriteSync. The
@@ -234,12 +233,12 @@ test('platformWriteSync handles paths with spaces, unicode, and newline characte
const cases = [
'has spaces in name.json',
'unicode-日本語-name.json',
'with tab.json',
// Note: newlines in filenames are POSIX-valid but Windows-illegal.
// The test runs on whatever platform CI picks; we skip the
// newline case on Windows to keep cross-platform CI green.
];
if (process.platform !== 'win32') {
// Tab (0x09) and newline (0x0A) in filenames are POSIX-valid but
// Windows-illegal (NTFS forbids control characters 0x00–0x1F). Append
// both only on POSIX so cross-platform CI stays green.
cases.push('with\ttab.json');
cases.push('with\nnewline.json');
}

View File

@@ -45,6 +45,10 @@ function sha256(buf) {
return crypto.createHash('sha256').update(buf).digest('hex');
}
function normalizeEol(text) {
return String(text).replace(/\r\n/g, '\n');
}
/** Read a directory recursively into a Map<relPath, sha256>. */
function snapshotDir(dir) {
const out = new Map();
@@ -98,7 +102,7 @@ describe('feat-3598: build*Cjs() output matches committed .generated.cjs (stale-
assert.equal(typeof fresh, 'string', `${g.exportName}() must return a string`);
const committedPath = path.join(REPO_ROOT, g.committed);
const committed = fs.readFileSync(committedPath, 'utf-8');
assert.equal(fresh, committed,
assert.equal(normalizeEol(fresh), normalizeEol(committed),
`${g.committed} drifted from generator output — run "cd sdk && npm run gen:${g.script.replace(/^gen-/, '').replace(/\.mjs$/, '')}" to regenerate`);
});
}

View File

@@ -32,7 +32,7 @@ describe('few-shot calibration examples', () => {
describe('frontmatter metadata', () => {
test('plan-checker.md has version and component in frontmatter', () => {
const content = readFile(path.join(REFS_DIR, 'plan-checker.md'));
assert.match(content, /^---\n/);
assert.match(content, /^---\r?\n/);
assert.match(content, /component:\s*plan-checker/);
assert.match(content, /version:\s*\d+/);
assert.match(content, /last_calibrated:\s*\d{4}-\d{2}-\d{2}/);
@@ -40,7 +40,7 @@ describe('few-shot calibration examples', () => {
test('verifier.md has version and component in frontmatter', () => {
const content = readFile(path.join(REFS_DIR, 'verifier.md'));
assert.match(content, /^---\n/);
assert.match(content, /^---\r?\n/);
assert.match(content, /component:\s*verifier/);
assert.match(content, /version:\s*\d+/);
assert.match(content, /last_calibrated:\s*\d{4}-\d{2}-\d{2}/);

View File

@@ -85,7 +85,7 @@ describe('gsd-settings-advanced — file scaffolding', () => {
test('command frontmatter has name, description, allowed-tools', () => {
const text = fs.readFileSync(COMMAND_PATH, 'utf-8');
const fmMatch = text.match(/^---\n([\s\S]*?)\n---/);
const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(fmMatch, 'command file missing frontmatter block');
const fm = fmMatch[1];
assert.match(fm, /name:\s*gsd:config/, 'frontmatter missing name (gsd:config)');

View File

@@ -104,7 +104,19 @@ function createTempGitProject(prefix = 'gsd-test-') {
}
function cleanup(tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
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 });
}
/**
@@ -170,4 +182,52 @@ function isUsageOutput(text) {
return /Usage:\s*gsd-tools/.test(text) && /Commands:/.test(text);
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, TOOLS_PATH };
/**
* 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('/');
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, TOOLS_PATH };

View File

@@ -17,6 +17,8 @@ const os = require('os');
const path = require('path');
const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js');
const isWindows = process.platform === 'win32';
const PROJECTION_PATH = path.join(
__dirname,
'..',
@@ -40,7 +42,9 @@ function cleanup(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
describe('installer HOME-relative PATH detection (#2620)', () => {
describe('installer HOME-relative PATH detection (#2620)',
{ skip: isWindows ? 'POSIX-only: parses sh-style "export PATH=" rc files; Windows has no rc files and uses registry Path' : false },
() => {
let installer;
let projection;
before(() => {

View File

@@ -284,7 +284,7 @@ describe('docs-required lint: readFragmentsFromDisk', () => {
fs.mkdirSync(path.join(tmp, '.changeset'), { recursive: true });
fn(tmp);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
}
}

View File

@@ -110,7 +110,7 @@ describe('lint-pr-check-project-dir', () => {
assert.notStrictEqual(result.status, 0);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
}
});

View File

@@ -27,9 +27,8 @@ before(async () => {
// ── Fixture helpers ─────────────────────────────────────────────────────────
function makeTmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-'));
}
const { createTempDir } = require('./helpers.cjs');
const makeTmp = () => createTempDir('gsd-parity-');
function writeConfig(dir, content) {
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });

View File

@@ -101,7 +101,10 @@ describe('codebase prompt injection scan', () => {
const findings = [];
for (const file of agentFiles) {
const relPath = path.relative(PROJECT_ROOT, file);
// Normalize to POSIX separators so ALLOWLIST.has() works on Windows
// (path.relative returns 'get-shit-done\bin\...' on win32; allowlist
// keys are POSIX 'get-shit-done/bin/...').
const relPath = path.relative(PROJECT_ROOT, file).replace(/\\/g, '/');
if (ALLOWLIST.has(relPath)) continue;
const content = fs.readFileSync(file, 'utf-8');
@@ -131,7 +134,10 @@ describe('codebase prompt injection scan', () => {
const oversized = [];
for (const file of agentFiles) {
const relPath = path.relative(PROJECT_ROOT, file);
// Normalize to POSIX separators so ALLOWLIST.has() works on Windows
// (path.relative returns 'get-shit-done\bin\...' on win32; allowlist
// keys are POSIX 'get-shit-done/bin/...').
const relPath = path.relative(PROJECT_ROOT, file).replace(/\\/g, '/');
if (ALLOWLIST.has(relPath)) continue;
const content = fs.readFileSync(file, 'utf-8');
@@ -152,7 +158,10 @@ describe('codebase prompt injection scan', () => {
const findings = [];
for (const file of workflowFiles) {
const relPath = path.relative(PROJECT_ROOT, file);
// Normalize to POSIX separators so ALLOWLIST.has() works on Windows
// (path.relative returns 'get-shit-done\bin\...' on win32; allowlist
// keys are POSIX 'get-shit-done/bin/...').
const relPath = path.relative(PROJECT_ROOT, file).replace(/\\/g, '/');
if (ALLOWLIST.has(relPath)) continue;
const content = fs.readFileSync(file, 'utf-8');
@@ -175,7 +184,10 @@ describe('codebase prompt injection scan', () => {
const findings = [];
for (const file of commandFiles) {
const relPath = path.relative(PROJECT_ROOT, file);
// Normalize to POSIX separators so ALLOWLIST.has() works on Windows
// (path.relative returns 'get-shit-done\bin\...' on win32; allowlist
// keys are POSIX 'get-shit-done/bin/...').
const relPath = path.relative(PROJECT_ROOT, file).replace(/\\/g, '/');
if (ALLOWLIST.has(relPath)) continue;
const content = fs.readFileSync(file, 'utf-8');
@@ -198,7 +210,10 @@ describe('codebase prompt injection scan', () => {
const findings = [];
for (const file of hookFiles) {
const relPath = path.relative(PROJECT_ROOT, file);
// Normalize to POSIX separators so ALLOWLIST.has() works on Windows
// (path.relative returns 'get-shit-done\bin\...' on win32; allowlist
// keys are POSIX 'get-shit-done/bin/...').
const relPath = path.relative(PROJECT_ROOT, file).replace(/\\/g, '/');
if (ALLOWLIST.has(relPath)) continue;
const content = fs.readFileSync(file, 'utf-8');
@@ -221,7 +236,10 @@ describe('codebase prompt injection scan', () => {
const findings = [];
for (const file of libFiles) {
const relPath = path.relative(PROJECT_ROOT, file);
// Normalize to POSIX separators so ALLOWLIST.has() works on Windows
// (path.relative returns 'get-shit-done\bin\...' on win32; allowlist
// keys are POSIX 'get-shit-done/bin/...').
const relPath = path.relative(PROJECT_ROOT, file).replace(/\\/g, '/');
if (ALLOWLIST.has(relPath)) continue;
const content = fs.readFileSync(file, 'utf-8');
@@ -244,7 +262,10 @@ describe('codebase prompt injection scan', () => {
const invisiblePattern = /[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD]/;
for (const file of allFiles) {
const relPath = path.relative(PROJECT_ROOT, file);
// Normalize to POSIX separators so ALLOWLIST.has() works on Windows
// (path.relative returns 'get-shit-done\bin\...' on win32; allowlist
// keys are POSIX 'get-shit-done/bin/...').
const relPath = path.relative(PROJECT_ROOT, file).replace(/\\/g, '/');
if (ALLOWLIST.has(relPath)) continue;
const content = fs.readFileSync(file, 'utf-8');
@@ -273,7 +294,10 @@ describe('codebase prompt injection scan', () => {
const boundaryPattern = /<\/?(?:system|assistant|human)>/i;
for (const file of allFiles) {
const relPath = path.relative(PROJECT_ROOT, file);
// Normalize to POSIX separators so ALLOWLIST.has() works on Windows
// (path.relative returns 'get-shit-done\bin\...' on win32; allowlist
// keys are POSIX 'get-shit-done/bin/...').
const relPath = path.relative(PROJECT_ROOT, file).replace(/\\/g, '/');
if (ALLOWLIST.has(relPath)) continue;
// Allow .md files to use common tags in examples/docs
// But flag .js/.cjs files that embed these

View File

@@ -171,9 +171,14 @@ describe('pruneOrphanedWorktrees', () => {
execSync('git worktree add "' + worktreeDir + '" -b fix/stale-ref', { cwd: repoDir, stdio: 'pipe' });
assert.ok(fs.existsSync(worktreeDir), 'worktree dir should exist before manual deletion');
// Verify it appears in git worktree list
const beforeList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf8' });
assert.ok(beforeList.includes(worktreeDir), 'worktree should appear in list before deletion');
// Use the canonicalPath helper so Windows 8.3 short-name (RUNNER~1) vs
// long-form (runneradmin) and slash-direction differences both collapse
// to the same key before comparison. git stores the long-form path in
// its administrative files; substring matching on the raw path fails.
// Capture the canonical key BEFORE deletion since canonicalPath calls
// realpathSync.native which fails on missing paths.
const wantedKey = canonicalPath(worktreeDir);
assert.ok(listedWorktreePaths(repoDir).has(wantedKey), 'worktree should appear in list before deletion');
// Manually delete the worktree directory (simulate orphan)
fs.rmSync(worktreeDir, { recursive: true, force: true });
@@ -182,11 +187,10 @@ describe('pruneOrphanedWorktrees', () => {
const pruneOrphanedWorktrees = getPruneOrphanedWorktrees();
pruneOrphanedWorktrees(repoDir);
// Assert: git worktree list no longer shows the stale entry
const afterList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf8' });
// Assert: git worktree list no longer shows the stale entry.
assert.ok(
!afterList.includes(worktreeDir),
'git worktree list still shows stale entry after prune:\n' + afterList
!listedWorktreePaths(repoDir).has(wantedKey),
'git worktree list still shows stale entry after prune'
);
});
});

View File

@@ -0,0 +1,240 @@
// allow-test-rule: run-tests.cjs is a CLI test harness whose only IR is its
// stable stderr line `run-tests: suite="X" files=N: name1 name2 ...` plus its
// exit code. No typed IR is exposable from a shell script; the printed line
// IS the contract this test pins. See docs/TESTING-SUITES.md and issue #3597.
//
// Tests for scripts/run-tests.cjs --suite filtering (issue #3597).
//
// Drives the harness through its subprocess seam — the same seam CI uses —
// rather than importing internals. Each test seeds a temporary directory
// with mock `.test.cjs` files (each one a trivial node:test no-op) and
// runs the harness against it via GSD_TEST_DIR.
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs');
// Minimal valid node:test file. Each fixture file passes when executed.
const PASS_BODY = `'use strict';
const { test } = require('node:test');
test('noop', () => {});
`;
function seed(dir, names) {
for (const name of names) {
fs.writeFileSync(path.join(dir, name), PASS_BODY, 'utf8');
}
}
function runHarness(testDir, args = [], extraEnv = {}) {
// Clear node:test parent-context env so the harness's child `node --test`
// doesn't refuse to run with "recursive run() skipping running files".
const env = { ...process.env, GSD_TEST_DIR: testDir, ...extraEnv };
delete env.NODE_TEST_CONTEXT;
return spawnSync(process.execPath, [HARNESS, ...args], {
cwd: path.join(__dirname, '..'),
env,
encoding: 'utf8',
});
}
describe('run-tests.cjs harness (issue #3597)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-3597-harness-');
});
afterEach(() => {
cleanup(tmpDir);
});
describe('argument parsing', () => {
test('unknown suite name exits non-zero with valid-suites hint', () => {
seed(tmpDir, ['a.test.cjs']);
const r = runHarness(tmpDir, ['--suite', 'bogus']);
assert.notStrictEqual(r.status, 0);
assert.match(r.stderr, /unknown suite/i);
assert.match(r.stderr, /unit/);
assert.match(r.stderr, /security/);
});
test('missing --suite value exits non-zero', () => {
seed(tmpDir, ['a.test.cjs']);
const r = runHarness(tmpDir, ['--suite']);
assert.notStrictEqual(r.status, 0);
assert.match(r.stderr, /requires a value/i);
});
test('duplicate --suite flag is rejected', () => {
seed(tmpDir, ['a.test.cjs']);
const r = runHarness(tmpDir, ['--suite', 'unit', '--suite', 'security']);
assert.notStrictEqual(r.status, 0);
assert.match(r.stderr, /duplicate/i);
});
test('unknown positional argument is rejected', () => {
seed(tmpDir, ['a.test.cjs']);
const r = runHarness(tmpDir, ['unit']);
assert.notStrictEqual(r.status, 0);
assert.match(r.stderr, /unknown argument/i);
});
test('--suite=value syntax is accepted', () => {
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
const r = runHarness(tmpDir, ['--suite=security']);
assert.strictEqual(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`);
});
});
describe('suite filtering', () => {
test('no flag runs ALL test files (backcompat)', () => {
seed(tmpDir, [
'a.test.cjs',
'b.security.test.cjs',
'c.integration.test.cjs',
]);
const r = runHarness(tmpDir);
assert.strictEqual(r.status, 0);
// node:test TAP output mentions each file path.
assert.ok(r.stderr.includes('a.test.cjs'), 'expected a.test.cjs in output');
assert.ok(
r.stderr.includes('b.security.test.cjs'),
'expected b.security.test.cjs in output',
);
assert.ok(
r.stderr.includes('c.integration.test.cjs'),
'expected c.integration.test.cjs in output',
);
});
test('--suite all is equivalent to no flag', () => {
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
const r = runHarness(tmpDir, ['--suite', 'all']);
assert.strictEqual(r.status, 0);
assert.ok(r.stderr.includes('a.test.cjs'));
assert.ok(r.stderr.includes('b.security.test.cjs'));
});
test('--suite unit excludes marked suites', () => {
seed(tmpDir, [
'a.test.cjs',
'b.security.test.cjs',
'c.integration.test.cjs',
'd.install.test.cjs',
'e.slow.test.cjs',
]);
const r = runHarness(tmpDir, ['--suite', 'unit']);
assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`);
assert.ok(r.stderr.includes('a.test.cjs'));
assert.ok(!r.stderr.includes('b.security.test.cjs'));
assert.ok(!r.stderr.includes('c.integration.test.cjs'));
assert.ok(!r.stderr.includes('d.install.test.cjs'));
assert.ok(!r.stderr.includes('e.slow.test.cjs'));
});
test('--suite security selects only *.security.test.cjs', () => {
seed(tmpDir, [
'a.test.cjs',
'b.security.test.cjs',
'c.integration.test.cjs',
]);
const r = runHarness(tmpDir, ['--suite', 'security']);
assert.strictEqual(r.status, 0);
assert.ok(r.stderr.includes('b.security.test.cjs'));
assert.ok(!r.stderr.includes('a.test.cjs'));
assert.ok(!r.stderr.includes('c.integration.test.cjs'));
});
test('--suite integration selects only *.integration.test.cjs', () => {
seed(tmpDir, ['a.test.cjs', 'b.integration.test.cjs']);
const r = runHarness(tmpDir, ['--suite', 'integration']);
assert.strictEqual(r.status, 0);
assert.ok(r.stderr.includes('b.integration.test.cjs'));
assert.ok(!r.stderr.includes('a.test.cjs'));
});
test('--suite install selects only *.install.test.cjs', () => {
seed(tmpDir, ['a.test.cjs', 'b.install.test.cjs']);
const r = runHarness(tmpDir, ['--suite', 'install']);
assert.strictEqual(r.status, 0);
assert.ok(r.stderr.includes('b.install.test.cjs'));
});
test('--suite slow selects only *.slow.test.cjs', () => {
seed(tmpDir, ['a.test.cjs', 'b.slow.test.cjs']);
const r = runHarness(tmpDir, ['--suite', 'slow']);
assert.strictEqual(r.status, 0);
assert.ok(r.stderr.includes('b.slow.test.cjs'));
});
});
describe('empty-suite behavior', () => {
test('--suite security with zero matching files exits 0 with a notice', () => {
seed(tmpDir, ['a.test.cjs']);
const r = runHarness(tmpDir, ['--suite', 'security']);
assert.strictEqual(r.status, 0);
assert.match(r.stderr, /no tests in suite/i);
});
test('completely empty test dir still exits non-zero (preserves prior behavior)', () => {
const r = runHarness(tmpDir);
assert.notStrictEqual(r.status, 0);
assert.match(r.stderr, /no test files/i);
});
});
describe('failure propagation', () => {
test('non-zero from node:test propagates through harness', () => {
const FAIL = `'use strict';
const { test } = require('node:test');
test('boom', () => { throw new Error('intentional'); });
`;
fs.writeFileSync(path.join(tmpDir, 'a.test.cjs'), FAIL, 'utf8');
const r = runHarness(tmpDir);
assert.notStrictEqual(
r.status,
0,
`expected non-zero exit; got status=${r.status} signal=${r.signal}\nSTDOUT:\n${r.stdout}\nSTDERR:\n${r.stderr}`,
);
});
});
describe('Windows argv-overflow chunking (issue #3597)', () => {
// Windows CreateProcess caps lpCommandLine at 32,767 chars. With ~550
// tests the unchunked spawn fails instantly on Windows with no test
// output. Linux/macOS allow ~2 MB so the same path works there. The
// harness chunks selected files so each spawn stays under the ceiling,
// and chunking is observable via the `run-tests: chunk N/M …` stderr
// line. Long filenames force chunking even with a modest file count so
// the test stays fast on every platform.
test('chunks when total argv would exceed configured ceiling', () => {
// Use a deliberately low MAX_CMDLINE_CHARS so the test is independent
// of tmp-path length (varies by OS). With a 2000-char ceiling and 30
// tests at ≥100 char paths, chunking must engage and at least one
// `chunk N/M …` marker must appear in stderr.
const longPrefix = 'a-deliberately-long-test-filename-to-force-chunking-behavior-cross-platform-';
const names = Array.from({ length: 30 }, (_, i) => `${longPrefix}${String(i).padStart(4, '0')}.test.cjs`);
seed(tmpDir, names);
const r = runHarness(tmpDir, [], { RUN_TESTS_MAX_CMDLINE_CHARS: '2000' });
assert.strictEqual(
r.status,
0,
`expected zero exit; got status=${r.status} signal=${r.signal}\nSTDERR (tail):\n${r.stderr.split('\n').slice(-20).join('\n')}`,
);
assert.match(
r.stderr,
/run-tests: chunk \d+\/\d+ — \d+ files/,
`expected chunking marker in stderr; STDERR (tail):\n${r.stderr.split('\n').slice(-20).join('\n')}`,
);
});
});
});

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({

View File

@@ -646,7 +646,7 @@ describe('validatePath: hostile path values are rejected before write', () => {
assert.strictEqual(r.safe, false,
'a symlink whose target is outside the base must fail containment');
// Cleanup the outside dir; the link itself is cleaned by cleanup(tmpDir).
fs.rmSync(outside, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
});
});

View File

@@ -69,7 +69,7 @@ function runScript(scriptPath, content, extraArgs) {
stderr: err.stderr || '',
};
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
}
}

View File

@@ -52,7 +52,10 @@ describe('skill-manifest', () => {
});
test('returns normalized inventory across canonical roots', () => {
const result = runGsdTools(['skill-manifest'], tmpDir, { HOME: homeDir });
// On Windows, os.homedir() reads USERPROFILE (not HOME). The SUT scans
// global skill roots via os.homedir(), so the test must also override
// USERPROFILE to keep the fixture's homeDir visible.
const result = runGsdTools(['skill-manifest'], tmpDir, { HOME: homeDir, USERPROFILE: homeDir });
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
@@ -117,7 +120,7 @@ describe('skill-manifest', () => {
});
test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => {
const result = runGsdTools(['skill-manifest', '--write'], tmpDir, { HOME: homeDir });
const result = runGsdTools(['skill-manifest', '--write'], tmpDir, { HOME: homeDir, USERPROFILE: homeDir });
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json');
@@ -131,6 +134,7 @@ describe('skill-manifest', () => {
test('global roots honor runtime-home env overrides instead of hardcoded home paths', () => {
const result = runGsdTools(['skill-manifest'], tmpDir, {
HOME: homeDir,
USERPROFILE: homeDir,
CLAUDE_CONFIG_DIR: path.join(homeDir, 'claude-custom'),
CODEX_HOME: path.join(homeDir, 'codex-custom'),
});

View File

@@ -0,0 +1,183 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Ratchet-style lint guard against Windows-test-parity regressions.
*
* PR #3649 cleared ~270 Windows-only test failures from the chunking fix
* in #3597 surfaced. Each cluster reduced to a handful of repeating
* patterns. This guard prevents the patterns from being re-introduced.
*
* Strategy: per-pattern offender list is snapshotted at the count present
* at the time of PR #3649. The test fails if a NEW file is added that
* matches the anti-pattern (count grows above the baseline). Existing
* offenders are acknowledged as technical debt that can be cleared
* incrementally without blocking this PR.
*
* When you fix an existing offender, lower the corresponding BASELINE
* count by 1. When CI breaks because BASELINE is set higher than the
* actual offender count, lower BASELINE to match (one-way ratchet down).
*
* Scope: tests/ only. Production-code Windows-compat is enforced via
* behavioural tests (see no-unconditional-win32-skip.test.cjs).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const TESTS_DIR = path.join(__dirname);
const SELF = path.basename(__filename);
// ── Baseline counts after PR #3649 batch ─────────────────────────────────
// Set these to the exact number of offending files at the time of merge.
// Each rule must not exceed its baseline; CI fails when a new offender appears.
// Decrement when an existing offender is fixed.
const BASELINE = {
splitNewlineOnFileContent: 3,
fenceRegexLiteralNewline: 2,
frontmatterAnchorLiteralNewline: 5,
hardcodedTmpToFsCall: 0,
bareNpmExecWithoutShell: 0,
stubsHomeNoUserProfile: 8,
rmSyncNoMaxRetries: 95,
};
function listTestFiles() {
return fs.readdirSync(TESTS_DIR)
.filter((f) => /\.(test|spec)\.cjs$/.test(f))
.filter((f) => f !== SELF)
.map((f) => path.join(TESTS_DIR, f));
}
function readFileText(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
// Strip line comments and block comments before pattern matching to avoid
// false-positives in commentary describing the very pattern we forbid.
function stripComments(text) {
return text
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
function countMatchingFiles(predicate) {
let count = 0;
const offenders = [];
for (const file of listTestFiles()) {
const text = stripComments(readFileText(file));
if (predicate(text, file)) {
count += 1;
offenders.push(path.basename(file));
}
}
return { count, offenders };
}
function ratchetAssert(rule, actualCount, baselineCount, offenders, guidance) {
if (actualCount > baselineCount) {
const newCount = actualCount - baselineCount;
assert.fail(
`Windows-parity guard "${rule}": ${actualCount} offenders, baseline is ${baselineCount} ` +
`(+${newCount} new). New occurrences of this anti-pattern were added. ` +
`${guidance}\n\nFull offender list (${actualCount}):\n ` +
offenders.join('\n '),
);
}
}
describe('Windows test-parity lint guards (ratchet baseline: PR #3649)', () => {
// ── G1 — CRLF: file-content split on literal '\n' ─────────────────────
test('split-on-newline after readFileSync (use /\\r?\\n/)', () => {
const { count, offenders } = countMatchingFiles((text) => {
return /\.readFileSync\s*\([^)]*\)[^;]*\.split\(\s*['"]\\n['"]\s*\)/.test(text);
});
ratchetAssert(
'splitNewlineOnFileContent', count, BASELINE.splitNewlineOnFileContent, offenders,
"Replace .split('\\n') with .split(/\\r?\\n/) so the test tolerates CRLF " +
"checkout (autocrlf=true on Windows leaves trailing \\r on every line).",
);
});
// ── G2 — CRLF: ```bash|sh\n fence regex on file content ──────────────
test('markdown-fence regex with literal \\n after ```bash/sh', () => {
const { count, offenders } = countMatchingFiles((text) => {
return /\/[^/]*```(?:bash|sh)\\n[^/]*\//.test(text);
});
ratchetAssert(
'fenceRegexLiteralNewline', count, BASELINE.fenceRegexLiteralNewline, offenders,
"Use /```(?:bash|sh)\\r?\\n([\\s\\S]*?)```/g — Windows CRLF makes the byte after " +
"`bash` be \\r, the regex never matches, and bash-block extraction returns empty.",
);
});
// ── G3 — CRLF: frontmatter regex with literal '\n' ────────────────────
test('frontmatter regex anchors on /^---\\n/', () => {
const { count, offenders } = countMatchingFiles((text) => {
return /\/\^---\\n/.test(text);
});
ratchetAssert(
'frontmatterAnchorLiteralNewline', count, BASELINE.frontmatterAnchorLiteralNewline, offenders,
"Use /^---\\r?\\n/ — on Windows the byte after --- is \\r, not \\n, so the " +
"anchor fails to match and parseFrontmatter returns null/{}.",
);
});
// ── G4 — POSIX-tmp: hardcoded '/tmp/' literal passed to fs.* ─────────
test('fs.* call receives a hardcoded "/tmp/..." literal', () => {
const { count, offenders } = countMatchingFiles((text) => {
return /\bfs\.[A-Za-z]+\s*\([^)]*['"]\/tmp\/[^'"]+['"][^)]*\)/.test(text);
});
ratchetAssert(
'hardcodedTmpToFsCall', count, BASELINE.hardcodedTmpToFsCall, offenders,
"Use os.tmpdir() — on Windows '/tmp/foo' becomes 'D:\\tmp\\foo' where D:\\tmp " +
"doesn't exist by default → ENOENT.",
);
});
// ── G5 — npm.cmd: bare 'npm' to exec*Sync without shell:true ─────────
test('bare npm exec without shell-true Windows fallback', () => {
const { count, offenders } = countMatchingFiles((text) => {
const re = /\b(?:execFileSync|spawnSync)\s*\(\s*['"]npm['"]\s*,[^)]*\)/g;
const matches = text.match(re) || [];
return matches.some((m) =>
!/shell\s*:\s*true/.test(m) && !/shell\s*:\s*isWindows/.test(m),
);
});
ratchetAssert(
'bareNpmExecWithoutShell', count, BASELINE.bareNpmExecWithoutShell, offenders,
"On Windows npm is npm.cmd — pass {shell: process.platform === 'win32'} or " +
"use npm.cmd directly, otherwise execFileSync errors ENOENT.",
);
});
// ── G6 — Test stubs HOME without USERPROFILE ─────────────────────────
test('test stubs process.env.HOME but never references USERPROFILE', () => {
const { count, offenders } = countMatchingFiles((text) => {
return /process\.env\.HOME\s*=\s*/.test(text) && !/USERPROFILE/.test(text);
});
ratchetAssert(
'stubsHomeNoUserProfile', count, BASELINE.stubsHomeNoUserProfile, offenders,
"On Windows os.homedir() reads USERPROFILE (not HOME). Tests redirecting ~ " +
"must override both, or the SUT sees the real user's home.",
);
});
// ── G7 — rmSync cleanup without retry budget ─────────────────────────
test('test teardown rmSync without maxRetries', () => {
const { count, offenders } = countMatchingFiles((text) => {
const re = /fs\.rmSync\s*\([^)]*recursive\s*:\s*true[^)]*force\s*:\s*true[^)]*\)/g;
const matches = text.match(re) || [];
return matches.some((m) => !/maxRetries/.test(m));
});
ratchetAssert(
'rmSyncNoMaxRetries', count, BASELINE.rmSyncNoMaxRetries, offenders,
"Use helpers.cleanup() (shared 5s retry budget) or pass " +
"{maxRetries: 10, retryDelay: 100} — Windows AV scanners can hold handles for " +
"seconds after a process exits, surfacing as flaky EBUSY teardown failures.",
);
});
});

View File

@@ -326,11 +326,18 @@ describe('workspace command files', () => {
* substring matching on the file as a whole.
*/
function parseCommandFile(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
// Strip UTF-8 BOM if present (some editors inject on save under Windows);
// a BOM byte at offset 0 defeats the ^--- anchor, making fmMatch null.
const raw = fs.readFileSync(filePath, 'utf8').replace(/^/, '');
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
assert.ok(fmMatch, `${path.basename(filePath)} must start with a YAML frontmatter block`);
const fm = {};
for (const line of fmMatch[1].split('\n')) {
for (const rawLine of fmMatch[1].split('\n')) {
// Explicit \r strip: split('\n') on CRLF content leaves a trailing
// \r on every line, which the value regex pulls into `kv[2]` and trim
// is enough for most values — but be defensive so future keys with
// exact-string compare don't surprise us.
const line = rawLine.replace(/\r$/, '');
const kv = line.match(/^([a-zA-Z_-]+):\s*(.*)$/);
if (!kv) continue;
const key = kv[1];

View File

@@ -13,6 +13,8 @@ const {
snapshotWorktreeInventory,
} = require('../get-shit-done/bin/lib/worktree-safety.cjs');
const isWindows = process.platform === 'win32';
describe('worktree-safety policy module', () => {
test('resolveWorktreeContext prefers current directory when .planning exists', () => {
const context = resolveWorktreeContext('/repo/wt', {
@@ -24,7 +26,9 @@ describe('worktree-safety policy module', () => {
assert.strictEqual(context.mode, 'current_directory');
});
test('resolveWorktreeContext maps linked worktree to common-dir parent', () => {
test('resolveWorktreeContext maps linked worktree to common-dir parent',
{ skip: isWindows ? 'POSIX-rooted fixture paths cannot be expressed on Windows path.resolve; resolveWorktreeContext uses platform-native path module and would prepend a drive letter to "/repo" inputs. Behaviour is covered indirectly by real-fs worktree tests.' : false },
() => {
const context = resolveWorktreeContext('/repo/wt', {
existsSync: () => false,
execGit: (args) => {