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:
5
.changeset/3597-test-suite-split-node-matrix.md
Normal file
5
.changeset/3597-test-suite-split-node-matrix.md
Normal 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.
|
||||
5
.changeset/3597-windows-argv-overflow.md
Normal file
5
.changeset/3597-windows-argv-overflow.md
Normal 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.
|
||||
98
.github/workflows/test.yml
vendored
98
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -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`.
|
||||
@@ -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
77
docs/TESTING-SUITES.md
Normal 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.
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ')}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}/);
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
240
tests/run-tests-harness.test.cjs
Normal file
240
tests/run-tests-harness.test.cjs
Normal 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')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
183
tests/windows-test-parity-guard.test.cjs
Normal file
183
tests/windows-test-parity-guard.test.cjs
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user