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

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3597
---
**Test suites split into named lanes and CI matrix expanded to OS×Node** — `scripts/run-tests.cjs` now accepts `--suite <unit|integration|install|security|slow|all>` and filters by filename suffix (`*.security.test.cjs`, `*.integration.test.cjs`, etc.). New package scripts `test:unit`, `test:integration`, `test:install`, `test:security`, `test:slow`, `test:coverage:unit`, and `test:coverage:all` give callers stable entrypoints; `npm test` and `npm run test:coverage` keep their original behavior (full sweep). CI matrix grew from `ubuntu × {22, 24}` + a single macOS lane to `{ubuntu, macos, windows} × {22, 24, 26}` with Node 26 in `continue-on-error` until `actions/setup-node` stabilises it. `fail-fast: false` so a single lane failure no longer cancels the rest. Install and slow suites only run on `main` push to keep PR CI fast; a dedicated `coverage` job runs `test:coverage:unit` on ubuntu/Node 24 and uploads the report. New grouping policy documented in `docs/TESTING-SUITES.md`. Closes #3597.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3649
---
**`scripts/run-tests.cjs` no longer fails on Windows when invoking many test files at once** — Windows `CreateProcess` caps `lpCommandLine` at 32,767 chars, so an unchunked spawn of `node --test <546 paths>` aborted instantly with exit 1 and no test output (Linux/macOS allow ~2 MB so the same path worked there). The harness now batches selected files into chunks whose total argv stays under 28,000 chars and runs each chunk sequentially, reporting `run-tests: chunk N/M — K files` to stderr. The ceiling is overridable via `RUN_TESTS_MAX_CMDLINE_CHARS` for tuning and tests. Adds a cross-platform regression test that forces chunking with a low ceiling.

View File

@@ -39,20 +39,21 @@ jobs:
test:
runs-on: ${{ matrix.os }}
timeout-minutes: 10
timeout-minutes: 15
# Node 26 lanes report forward-compat status without gating the workflow
# (setup-node may not yet have stable Node 26 images). 22/24 still gate.
continue-on-error: ${{ matrix.node-version == 26 }}
strategy:
fail-fast: true
# fail-fast disabled so a single lane failure doesn't cancel the rest;
# makes OS-specific vs version-specific regressions easier to distinguish.
fail-fast: false
matrix:
os: [ubuntu-latest]
node-version: [22, 24]
include:
# Single macOS runner — verifies platform compatibility on the standard version
- os: macos-latest
node-version: 24
# Windows path/separator coverage is handled by hardcoded-paths.test.cjs
# and windows-robustness.test.cjs (static analysis, runs on all platforms).
# A dedicated windows-compat workflow runs on a weekly schedule.
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [22, 24, 26]
# Windows path/separator coverage is also reinforced by hardcoded-paths.test.cjs
# and windows-robustness.test.cjs (static analysis).
# A dedicated windows-compat workflow runs on a weekly schedule.
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -156,8 +157,75 @@ jobs:
shell: bash
run: node scripts/lint-shared-module-handsync.cjs
- name: Run tests with coverage
# Split lanes (issue #3597). Unit is the fast default lane; integration
# and security run alongside it on every PR. `install` and `slow` are
# skipped on PR CI by design — they run on the weekly windows-compat
# workflow and on `main` push only. See docs/TESTING-SUITES.md.
- name: Run unit tests
shell: bash
env:
NODE_OPTIONS: --max-old-space-size=6144
run: npm run test:coverage
run: npm run test:unit
- name: Run integration tests
shell: bash
run: npm run test:integration
- name: Run security tests
shell: bash
run: npm run test:security
# Install + slow lanes only on main-branch push (not PR CI) so PRs stay fast.
- name: Run install tests
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
shell: bash
run: npm run test:install
- name: Run slow tests
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
shell: bash
run: npm run test:slow
# Dedicated coverage job. Runs only on ubuntu/Node 24 (the canonical lane)
# because c8 coverage of one suite on one OS is enough signal — running it
# across the full matrix would 9x the cost for no extra coverage data.
# Replaces main's per-matrix `Run tests with coverage` step.
coverage:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Rebase check — merge origin/main into PR head
if: github.event_name == 'pull_request'
shell: bash
run: |
set -euo pipefail
git config user.email "ci@gsd-build"
git config user.name "CI Rebase Check"
git fetch origin main
if ! git merge --no-edit --no-ff origin/main; then
echo "::error::This PR cannot cleanly merge origin/main. Rebase your branch onto current main and push again."
git merge --abort
exit 1
fi
- name: Set up Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build SDK dist
run: npm run build:sdk
- name: Unit coverage
shell: bash
run: npm run test:coverage:unit
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-unit
path: |
coverage/
.nyc_output/
if-no-files-found: ignore

View File

@@ -1,21 +0,0 @@
## GitHub access
Use the configured GitHub CLI session for this checkout. Always pass
`--repo gsd-build/get-shit-done` on `gh` commands so issue and PR operations
stay scoped to the canonical repository.
---
## Agent skills
### Issue tracker
Issues live in GitHub Issues (`gsd-build/get-shit-done`). See `docs/agents/issue-tracker.md`.
### Triage labels
Custom label mapping: `confirmed` = AFK-agent-ready (bugs); `approved-enhancement` / `approved-feature` = human-ready (enhancements/features); `needs-reproduction` = waiting on reporter. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context repo — `CONTEXT.md` + `docs/adr/` at the root. See `docs/agents/domain.md`.

View File

@@ -190,6 +190,8 @@ When unsure whether a change is user-facing, **update the docs**.
All tests use Node.js built-in test runner (`node:test`) and assertion library (`node:assert`). **Do not use Jest, Mocha, Chai, or any external test framework.**
> **Suite grouping.** Tests live in named suites (`unit`, `integration`, `install`, `security`, `slow`) selected by **filename suffix**: a file named `foo.security.test.cjs` belongs to the `security` suite; a file with no suffix (`foo.test.cjs`) belongs to `unit`. See [docs/TESTING-SUITES.md](docs/TESTING-SUITES.md) for the full policy, CI matrix, and per-suite scripts (`npm run test:unit`, `npm run test:security`, `npm run test:coverage:unit`, …). Default `npm test` still runs every test — backwards compatible.
### Required Imports
```javascript

77
docs/TESTING-SUITES.md Normal file
View File

@@ -0,0 +1,77 @@
# Testing Suites
This project's `tests/` directory uses **filename suffix markers** to group tests into named suites. The harness `scripts/run-tests.cjs` filters by suite when given `--suite <name>`. Without a flag it runs every `*.test.cjs` file (the historical default — unchanged).
> Tracked by issue [#3597](https://github.com/gsd-build/get-shit-done/issues/3597).
## Suites
| Suite | Filename pattern | What goes here |
|---|---|---|
| `unit` | `*.test.cjs` (no other marker) | Default fast lane. Pure logic, no network, no external processes beyond `gsd-tools`. Most tests live here. |
| `integration` | `*.integration.test.cjs` | Cross-module flows: full installer end-to-end, multi-tool orchestration, anything that crosses two or more bin entry points. |
| `install` | `*.install.test.cjs` | Tests that perform a real install/uninstall against a sandbox project. Slower; PR CI skips these on PRs and runs them on `main` push only. |
| `security` | `*.security.test.cjs` | Adversarial input, prompt-injection guards, fixture-driven hostile-payload sweeps. |
| `slow` | `*.slow.test.cjs` | Anything that routinely takes >5s wall-clock or holds significant memory. |
| `all` | (any) | Explicit alias for "no filter". Equivalent to running with no `--suite` flag. |
## How to place a new test
1. Pick the most specific bucket above.
2. Name the file with the matching suffix: `tests/<feature>.<suite>.test.cjs`.
3. If unsure, leave the suffix off — the file lands in `unit`, the default fast lane.
Examples:
- `tests/agent-frontmatter.test.cjs` — `unit`
- `tests/prompt-injection-guards.security.test.cjs` — `security`
- `tests/installer-end-to-end.install.test.cjs` — `install`
- `tests/sdk-mutation-stress.slow.test.cjs` — `slow`
The suite-suffix convention was chosen over a directory layout (`tests/security/`) so the 545+ existing test files don't need to move. Existing files all classify as `unit` until someone explicitly retags them.
## Running suites locally
```bash
npm test # everything (backcompat — same as before)
npm run test:unit # only unit
npm run test:integration # only integration
npm run test:install # only install
npm run test:security # only security
npm run test:slow # only slow
npm run test:coverage # backcompat — coverage over EVERY test
npm run test:coverage:unit # fast coverage signal — only unit suite
npm run test:coverage:all # alias for test:coverage
```
Direct harness invocation also works:
```bash
node scripts/run-tests.cjs --suite security
node scripts/run-tests.cjs --suite=security
```
Unknown suites exit non-zero with the list of valid suites. Empty suites (e.g. `--suite security` before any security-tagged file exists) exit `0` with a `no tests in suite "..."` notice on stderr so CI lanes don't go red while a suite is being populated.
## CI matrix
The `Tests` workflow runs on:
| OS | Node 22 | Node 24 | Node 26 |
|---|---|---|---|
| `ubuntu-latest` | gate | gate | forward-compat (`continue-on-error`) |
| `macos-latest` | gate | gate | forward-compat (`continue-on-error`) |
| `windows-latest` | gate | gate | forward-compat (`continue-on-error`) |
- **Node 22** is the `engines.node` floor (`>=22.0.0`) — must stay green.
- **Node 24** is the default development lane.
- **Node 26** is forward-compat. The lane reports status but does not gate the workflow — `actions/setup-node` may not yet have a stable Node 26 image at any given moment. When it stabilises, flip `continue-on-error` off.
Each matrix cell runs `unit`, `integration`, and `security` on every PR. `install` and `slow` only run on `main`-branch push to keep PR CI fast. Coverage runs in a dedicated `coverage` job on `ubuntu-latest` / Node 24 — running coverage across the full matrix would 9x the cost for no extra coverage data.
## Best practices for forward-compat (Node 24/26)
- Use `process.execPath` when spawning Node in tests so each matrix lane exercises the lane's Node version.
- Avoid stack-trace or error-message prose assertions. Assert `err.code`, structured JSON fields, or enums — Node minor releases routinely tweak error wording.
- Prefer `node:test`, `node:assert/strict`, and `node:test` mocks. No external test frameworks.
- Coverage uses `c8` and propagates `NODE_V8_COVERAGE` through the harness's child process.

View File

@@ -76,6 +76,77 @@ function withProjectRoot(cwd, result) {
return result;
}
/**
* Return git-worktree state for init payloads with robust nested-subdir
* detection across Windows short/long path forms and slash variants.
*/
function getInitGitState(cwd) {
const info = gitWorktreeInfoInternal(cwd);
const worktreeRoot = info.worktreeRoot;
const normalizeForCompare = (p) => {
if (typeof p !== 'string' || p.length === 0) return null;
let resolved;
try {
resolved = fs.realpathSync.native(p);
} catch {
resolved = path.resolve(p);
}
resolved = path.resolve(resolved);
if (process.platform === 'win32') {
return resolved.replace(/\//g, '\\').toLowerCase();
}
return resolved;
};
let inNestedSubdir = false;
if (info.inside) {
let resolvedByGitPrefix = false;
try {
const prefixResult = execGit(['rev-parse', '--show-prefix'], { cwd, timeout: 5000 });
if (prefixResult.exitCode === 0) {
const prefix = String(prefixResult.stdout || '').trim().replace(/\\/g, '/');
inNestedSubdir = prefix.length > 0 && prefix !== '.' && prefix !== './';
resolvedByGitPrefix = true;
}
} catch {}
if (!resolvedByGitPrefix) {
const rootNorm = normalizeForCompare(worktreeRoot);
const cwdNorm = normalizeForCompare(cwd);
if (rootNorm && cwdNorm) {
if (rootNorm === cwdNorm) {
inNestedSubdir = false;
} else {
const rel = path.relative(rootNorm, cwdNorm);
const relNorm = process.platform === 'win32' ? rel.replace(/\//g, '\\') : rel;
inNestedSubdir =
relNorm !== '' &&
relNorm !== '.' &&
!relNorm.startsWith('..') &&
!path.isAbsolute(relNorm);
}
} else {
inNestedSubdir = worktreeRoot !== null;
}
}
}
// Defensive final guard: if git reports the same root path as cwd (after
// slash/case normalization), we are at the worktree root, never nested.
if (inNestedSubdir && typeof worktreeRoot === 'string') {
const toComparableRaw = (p) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase();
if (toComparableRaw(worktreeRoot) === toComparableRaw(String(cwd))) {
inNestedSubdir = false;
}
}
return {
has_git: info.inside,
git_worktree_root: worktreeRoot,
in_nested_subdir: inNestedSubdir,
};
}
function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
if (!phase) {
error('phase required for init execute-phase');
@@ -475,16 +546,7 @@ function cmdInitNewProject(cwd, raw) {
needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'),
// Git state (Bug #3491: detect parent worktree to avoid nested .git init)
...(() => {
const info = gitWorktreeInfoInternal(cwd);
const worktreeRoot = info.worktreeRoot;
const inNestedSubdir = info.inside && worktreeRoot !== null && worktreeRoot !== cwd;
return {
has_git: info.inside,
git_worktree_root: worktreeRoot,
in_nested_subdir: inNestedSubdir,
};
})(),
...getInitGitState(cwd),
// Enhanced search
brave_search_available: hasBraveSearch,
@@ -618,17 +680,7 @@ function cmdInitIngestDocs(cwd, raw) {
const result = {
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
planning_exists: fs.existsSync(planningRoot(cwd)),
...(() => {
// Bug #3491 — see cmdInitNewProject above. Same shallow-check bug.
const info = gitWorktreeInfoInternal(cwd);
const worktreeRoot = info.worktreeRoot;
const inNestedSubdir = info.inside && worktreeRoot !== null && worktreeRoot !== cwd;
return {
has_git: info.inside,
git_worktree_root: worktreeRoot,
in_nested_subdir: inNestedSubdir,
};
})(),
...getInitGitState(cwd),
project_path: '.planning/PROJECT.md',
commit_docs: config.commit_docs,
};

View File

@@ -134,19 +134,21 @@ HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
REBUILD_SCRIPT="$HOOK_DIR/lib/gsd-graphify-rebuild.sh"
[ -f "$REBUILD_SCRIPT" ] || exit 0
# Detach the rebuild. Portable double-fork via subshell + disown — works on
# macOS (no setsid) and Linux. Redirect all I/O to /dev/null so the hook
# returns instantly even if the child keeps stdout/stderr handles open.
(
bash "$REBUILD_SCRIPT" \
"$STATUS_FILE" \
"$LOCK_FILE" \
"$HEAD_SHA" \
"$MS_START" \
"$GRAPHIFY_BIN" \
</dev/null >/dev/null 2>&1 &
disown
) &
disown
# Detach the rebuild. Spawn as a regular background job so we can capture
# its PID via $! and write it to the lock file synchronously here in the
# parent. This eliminates a startup race where a caller (e.g. test cleanup)
# observing an absent lock could not distinguish "subprocess finished" from
# "subprocess hasn't started yet." With the lock written before this hook
# returns, lock-presence is a reliable in-flight signal.
bash "$REBUILD_SCRIPT" \
"$STATUS_FILE" \
"$LOCK_FILE" \
"$HEAD_SHA" \
"$MS_START" \
"$GRAPHIFY_BIN" \
</dev/null >/dev/null 2>&1 &
REBUILD_PID=$!
echo "$REBUILD_PID" > "$LOCK_FILE"
disown "$REBUILD_PID" 2>/dev/null || true
exit 0

View File

@@ -82,6 +82,13 @@
"changeset": "node scripts/changeset/new.cjs",
"changelog:render": "node scripts/changeset/cli.cjs render",
"test": "node scripts/run-tests.cjs",
"test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs"
"test:unit": "node scripts/run-tests.cjs --suite unit",
"test:integration": "node scripts/run-tests.cjs --suite integration",
"test:install": "node scripts/run-tests.cjs --suite install",
"test:security": "node scripts/run-tests.cjs --suite security",
"test:slow": "node scripts/run-tests.cjs --suite slow",
"test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs",
"test:coverage:unit": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs --suite unit",
"test:coverage:all": "npm run test:coverage"
}
}

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

@@ -2,32 +2,177 @@
// Cross-platform test runner — resolves test file globs via Node
// instead of relying on shell expansion (which fails on Windows PowerShell/cmd).
// Propagates NODE_V8_COVERAGE so c8 collects coverage from the child process.
//
// Suite filtering (issue #3597):
// node scripts/run-tests.cjs # default — runs ALL tests (backcompat)
// node scripts/run-tests.cjs --suite all # explicit "everything"
// node scripts/run-tests.cjs --suite unit # only files with no other suite marker
// node scripts/run-tests.cjs --suite security # *.security.test.cjs
// node scripts/run-tests.cjs --suite integration # *.integration.test.cjs
// node scripts/run-tests.cjs --suite install # *.install.test.cjs
// node scripts/run-tests.cjs --suite slow # *.slow.test.cjs
//
// Suite grouping convention: filename suffix marker before `.test.cjs`.
// A file named `foo.security.test.cjs` belongs to the `security` suite.
// A file named `foo.test.cjs` (no marker) belongs to the `unit` suite.
// See docs/TESTING-SUITES.md for full grouping policy.
'use strict';
const { readdirSync } = require('fs');
const { join } = require('path');
const { execFileSync } = require('child_process');
const testDir = join(__dirname, '..', 'tests');
const files = readdirSync(testDir)
.filter(f => f.endsWith('.test.cjs'))
.sort()
.map(f => join('tests', f));
const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow'];
const MARKED_SUITES = ['integration', 'install', 'security', 'slow'];
if (files.length === 0) {
console.error('No test files found in tests/');
process.exit(1);
function parseArgs(argv) {
let suite = null;
let seen = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--suite') {
if (seen) {
return { error: 'duplicate --suite flag' };
}
seen = true;
const v = argv[i + 1];
if (!v || v.startsWith('--')) {
return { error: '--suite requires a value' };
}
suite = v;
i++;
} else if (a.startsWith('--suite=')) {
if (seen) {
return { error: 'duplicate --suite flag' };
}
seen = true;
suite = a.slice('--suite='.length);
if (!suite) {
return { error: '--suite requires a value' };
}
} else {
return { error: `unknown argument: ${a}` };
}
}
return { suite };
}
const concurrency = process.env.TEST_CONCURRENCY
? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
: '--test-concurrency=4';
try {
execFileSync(process.execPath, ['--test', concurrency, ...files], {
stdio: 'inherit',
env: { ...process.env },
});
} catch (err) {
process.exit(err.status || 1);
// Return the marked suite name embedded in a filename, or null if it's unmarked.
// foo.security.test.cjs -> "security"
// foo.test.cjs -> null (unit)
function suiteOf(filename) {
if (!filename.endsWith('.test.cjs')) return null;
const base = filename.slice(0, -'.test.cjs'.length);
const lastDot = base.lastIndexOf('.');
if (lastDot === -1) return null;
const marker = base.slice(lastDot + 1);
return MARKED_SUITES.includes(marker) ? marker : null;
}
function selectFiles(allFiles, suite) {
if (suite === null || suite === 'all') {
return allFiles;
}
if (suite === 'unit') {
return allFiles.filter(f => suiteOf(f) === null);
}
return allFiles.filter(f => suiteOf(f) === suite);
}
function main() {
const args = process.argv.slice(2);
const parsed = parseArgs(args);
if (parsed.error) {
console.error(`run-tests: ${parsed.error}`);
console.error(`Valid suites: ${SUITES.join(', ')}`);
process.exit(2);
}
const suite = parsed.suite;
if (suite !== null && !SUITES.includes(suite)) {
console.error(`run-tests: unknown suite "${suite}"`);
console.error(`Valid suites: ${SUITES.join(', ')}`);
process.exit(2);
}
const testDir = process.env.GSD_TEST_DIR
? process.env.GSD_TEST_DIR
: join(__dirname, '..', 'tests');
const allFiles = readdirSync(testDir)
.filter(f => f.endsWith('.test.cjs'))
.sort();
if (allFiles.length === 0) {
console.error(`No test files found in ${testDir}`);
process.exit(1);
}
const selected = selectFiles(allFiles, suite).map(f => join(testDir, f));
if (selected.length === 0) {
// Empty suite: report and exit 0 so empty lanes (e.g. `security` before
// adversarial tests land) don't gate CI. CI consumers wanting strictness
// can grep stderr for "no tests in suite".
console.error(`run-tests: no tests in suite "${suite || 'all'}"`);
process.exit(0);
}
// Log selected files to stderr for CI / harness-test visibility.
// node:test default reporter doesn't echo filenames, so this gives
// operators a single stable line they can grep.
console.error(
`run-tests: suite="${suite || 'all'}" files=${selected.length}: ${selected
.map(f => f.split(/[\\/]/).pop())
.join(' ')}`,
);
const concurrency = process.env.TEST_CONCURRENCY
? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
: '--test-concurrency=4';
// Windows `CreateProcess` caps the full command line at 32,767 chars
// (lpCommandLine). With 500+ test paths the spawn fails instantly with no
// test output. Linux/macOS allow ~2 MB (ARG_MAX) so unchunked spawns are
// fine there. Split into chunks sized for the tightest target so behavior
// is identical across platforms. (#3597)
// Operator override (also used by tests to force chunking with short paths).
const MAX_CMDLINE_CHARS = process.env.RUN_TESTS_MAX_CMDLINE_CHARS
? Number(process.env.RUN_TESTS_MAX_CMDLINE_CHARS)
: 28000; // headroom below the 32,767 Windows ceiling
const FIXED_OVERHEAD = process.execPath.length + '--test'.length + concurrency.length + 8;
const chunks = [];
let current = [];
let currentLen = FIXED_OVERHEAD;
for (const file of selected) {
const add = file.length + 1; // +1 for the inter-arg separator
if (current.length > 0 && currentLen + add > MAX_CMDLINE_CHARS) {
chunks.push(current);
current = [];
currentLen = FIXED_OVERHEAD;
}
current.push(file);
currentLen += add;
}
if (current.length > 0) chunks.push(current);
let firstFailureExit = 0;
for (let i = 0; i < chunks.length; i++) {
if (chunks.length > 1) {
console.error(`run-tests: chunk ${i + 1}/${chunks.length} — ${chunks[i].length} files`);
}
try {
execFileSync(process.execPath, ['--test', concurrency, ...chunks[i]], {
stdio: 'inherit',
env: { ...process.env },
});
} catch (err) {
const code = err.status || 1;
// Run every chunk so the operator sees all failures in one pass; report
// the first non-zero exit at the end.
if (firstFailureExit === 0) firstFailureExit = code;
}
}
if (firstFailureExit !== 0) process.exit(firstFailureExit);
}
main();

View File

@@ -99,6 +99,27 @@ function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string
}
}
function detectNestedSubdir(base: string, info: { inside: boolean; worktreeRoot: string | null }): boolean {
if (!info.inside) return false;
try {
const prefix = execSync('git rev-parse --show-prefix', {
cwd: base,
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}).trim().replace(/\\/g, '/');
if (prefix.length > 0) return prefix !== '.' && prefix !== './';
return false;
} catch {}
if (!info.worktreeRoot) return false;
const normalize = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase();
const root = normalize(info.worktreeRoot);
const cwd = normalize(base);
return root !== cwd;
}
const NEW_PROJECT_REQUIRED_AGENTS = [
'gsd-project-researcher',
@@ -283,6 +304,7 @@ export const initNewProject: QueryHandler = async (_args, projectDir, workstream
]);
const runtime = detectRuntime(config as { runtime?: unknown });
const agentsDir = resolveAgentsDir(runtime);
const gitInfo = gitWorktreeInfo(projectDir);
const missingRequiredAgents = NEW_PROJECT_REQUIRED_AGENTS.filter(
agent => !hasAgentDefinition(agentsDir, agent),
);
@@ -309,12 +331,9 @@ export const initNewProject: QueryHandler = async (_args, projectDir, workstream
(hasExistingCode || hasPackageFile) && !pathExists(projectDir, '.planning/codebase'),
// Bug #3491: detect parent worktree to avoid nested .git init.
has_git: (() => gitWorktreeInfo(projectDir).inside)(),
git_worktree_root: (() => gitWorktreeInfo(projectDir).worktreeRoot)(),
in_nested_subdir: (() => {
const info = gitWorktreeInfo(projectDir);
return info.inside && info.worktreeRoot !== null && info.worktreeRoot !== projectDir;
})(),
has_git: gitInfo.inside,
git_worktree_root: gitInfo.worktreeRoot,
in_nested_subdir: detectNestedSubdir(projectDir, gitInfo),
brave_search_available: hasBraveSearch,
firecrawl_available: hasFirecrawl,

View File

@@ -118,6 +118,27 @@ function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string
}
}
function detectNestedSubdir(base: string, info: { inside: boolean; worktreeRoot: string | null }): boolean {
if (!info.inside) return false;
try {
const prefix = execSync('git rev-parse --show-prefix', {
cwd: base,
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}).trim().replace(/\\/g, '/');
if (prefix.length > 0) return prefix !== '.' && prefix !== './';
return false;
} catch {}
if (!info.worktreeRoot) return false;
const normalize = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase();
const root = normalize(info.worktreeRoot);
const cwd = normalize(base);
return root !== cwd;
}
/**
* Compute the canonical phase directory name for a known phase entry from the
@@ -1278,16 +1299,14 @@ export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => {
*/
export const initIngestDocs: QueryHandler = async (_args, projectDir) => {
const config = await loadConfig(projectDir);
const gitInfo = gitWorktreeInfo(projectDir);
const result: Record<string, unknown> = {
project_exists: pathExists(projectDir, '.planning/PROJECT.md'),
planning_exists: pathExists(projectDir, '.planning'),
// Bug #3491: detect parent worktree to avoid nested .git init.
has_git: (() => gitWorktreeInfo(projectDir).inside)(),
git_worktree_root: (() => gitWorktreeInfo(projectDir).worktreeRoot)(),
in_nested_subdir: (() => {
const info = gitWorktreeInfo(projectDir);
return info.inside && info.worktreeRoot !== null && info.worktreeRoot !== projectDir;
})(),
has_git: gitInfo.inside,
git_worktree_root: gitInfo.worktreeRoot,
in_nested_subdir: detectNestedSubdir(projectDir, gitInfo),
project_path: '.planning/PROJECT.md',
commit_docs: config.commit_docs,
};

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) => {