* fix: recurse test discovery so subdir test suites actually run
scripts/run-tests.cjs discovered tests with a flat readdirSync(testDir),
silently excluding tests/observability/ (4 files), tests/dispatch/ (1) and
tests/installer-migrations/ (1) — 94 passing tests — from `npm test` and all
CI lanes. Walk the tree recursively (relative subpaths preserved), classify
suites by basename, and add a fail-on-zero-executed guard for suite/default
runs (escape hatch GSD_ALLOW_EMPTY_SUITE=1) while preserving the empty
--files/--files-from path the CI inert lane relies on.
Unit suite 735 -> 741 files; surfaces ADR-227's observability/dispatch seam.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: retire 5 verified-worthless tests
Adversarial verification confirmed these 5 prove nothing — their coverage is
provided more strictly elsewhere:
- enh-2790 'has a name: field' spot-checks (command-contract enforces /^gsd[:-]/)
- command-routing-hub duplicate construct + duplicate ERROR_KINDS assertions
- no-cjs-sdk-handsync-tooling (guarded files that never existed on main; bug-190
covers the real retired SDK artifacts)
- runtime-artifact-layout cline edge case (subsumed by the explicit-global test
and bug-782-cline-skills-emission)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: add ADR-218 release version-validation coverage
ADR-218 (reject leading-zero versions like 1.01.0; npm duplicate pre-check) had
zero tests — the logic lived only in release.yml bash. Add a test that extracts
the actual rejection regexes from the workflow and exercises them against a
boundary table (leading-zero/malformed rejected, valid accepted) plus structural
wiring assertions. Goes red if the regex is reverted to [0-9]+.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: redesign weak tests into behavioral, deterministic assertions
Per the ADR test audit, rewrite 27 weak test files (test-only, no source
changes) so each can go red for the defect it guards:
- kill pass-always assert.ok(true) placeholders (research-cli, worktree-baseref,
bug-260 security guard, eslint-rules x24, clusters '|| true')
- replace source-text grep with behavioral calls (install Kilo, sh-hook-paths,
plan-review-convergence) and add a repo-layout governance test
- de-flake real-clock/Math.random coupling (phase last_updated, bug-3707 mtime,
context-utilization property, feat-3594)
- fix independence/shared-state violations (bug-492 singleton, issue-844 tmpRoot,
core reapStaleTempFiles, active-workstream TTY, feat-488 GSD_HOME)
- strengthen property/shape-only tests (research-provider/store classification +
collision) and unconditional plugin.json schema validation (issue-766)
Verified: all 28 files run together 1220 pass / 0 fail / 1 skip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: add no-tautological-assert lint rule, error in test suite
New custom ESLint rule (eslint-rules/no-tautological-assert.cjs) bans asserts
that can never fail: assert(true)/assert.ok(<always-truthy literal>),
'cond || true' inside an assert, and equality asserts comparing two identical
literals. Wired as error on tests/**; full sweep confirmed zero existing
violations so the suite stays green. Prevents the placeholder-assert regressions
the audit redesigns just removed. RuleTester coverage added (6 valid, 8 invalid).
Note: no-only-tests was already enforced via eslint-plugin-no-only-tests, so no
duplicate rule was added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: gate new allow-test-rule exemptions to require an issue ref
ADR-456 requires any allow-test-rule exemption added after the ADR to carry a
tracking issue number, but nothing enforced it. New ratchet gate
(scripts/lint-allow-test-rule-refs.cjs, wired into lint:ci) fails when a NEW
allow-test-rule comment lacks a #NNN/URL reference; the 323 existing untracked
exemptions are grandfathered in an allowlist that ratchets down as they gain
refs. Red-green verified (novel untracked offender fails; compliant passes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: add ADR test-audit evidence report (#1192)
Full risk-first qa-test-architect audit of the ADR portfolio (37 ADRs + 4
platform lenses, adversarial verification of retire verdicts) that drove the
P0 discovery fix, ADR-218 coverage, 5 retires, 27 redesigns, and the two new
lint gates. Filed as point-in-time evidence under docs/issueevidence/, named
for tracking issue #1192.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: replace pre-existing raw NUL byte with escape in feat-3594 fixture
feat-3594's null-byte parser fixture contained a literal NUL byte (pre-existing
on next at b10e5681 — confirmed: base blob has 1 NUL, this fix has 0), which
made git treat the file as binary and would break grep/editors. Switch to the
\x00 escape; the runtime string value (a real NUL in the parser input) is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: address adversarial-review findings
Codex adversarial pass over the branch:
- capability-registry drift test no longer mutates the committed generated
capability-registry.cjs in place (concurrency hazard) — uses in-memory
checkPipeline comparison instead.
- allow-test-rule ratchet now detects exemptions in ALL comment forms (block
/* */ too, matching no-source-grep) so a block comment can't bypass it;
one newly-surfaced pre-existing offender grandfathered (323->324).
- install.test Kilo case asserts on what install(false,'kilo') actually writes
rather than manually calling configureKiloPermissions (masked the call site).
- issue-766 drops the undeclared transitive ajv dep for explicit structural
assertions from the schema fixture.
- adr-218 test notes the hotfix leading-zero gap is tracked in #1186.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: address code-review findings (subdir discovery, rule + test gaps)
xhigh code review surfaced 15 confirmed issues, all fixed:
- run-tests.cjs --files now resolves subdir tests by bare basename + handles
Windows backslash paths (ambiguous basenames error clearly).
- affected-tests-lib.cjs listTestFiles made recursive — the targeted CI lane was
silently dropping changed subdir tests (same false-green class the audit fixed).
- no-tautological-assert now catches 'true || cond' and empty []/{} equality.
- verify-test-quality: restore provenance-classification coverage, tighten the
writeFile circular-detection check, guard the module-level file read.
- sh-hook-paths: cover the global-install .sh delegation branch (#2045 guard).
- active-workstream null-guard runs deterministically (no longer skipped on TTY).
- adr-218 structural guards tightened (major/minor leading-zero; needs: membership).
- repo-layout AGENTS.md guard no longer false-alarms on equivalent refactors.
- cross-ai ordering guard fails red when the step is missing.
- issue-766 parses required fields from the schema fixture (auto-enforced).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: stub USERPROFILE alongside HOME in feat-488 (Windows parity)
The feat-488 redesign stubbed process.env.HOME but not USERPROFILE; os.homedir()
resolves from USERPROFILE on Windows, so the home stub was not hermetic there —
caught by windows-test-parity-guard (stubsHomeNoUserProfile). Save/set/restore
USERPROFILE symmetrically with HOME (delete-if-originally-undefined).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: reconcile allow-test-rule allowlist after rebase onto next
Rebasing onto current next pulled in merged PR #1170, which added
inventory-headings-countfree.test.cjs (a baseline allow-test-rule exemption) and
deleted inventory-counts.test.cjs. Grandfather the former and prune the latter so
the ratchet matches the merged tree. No new debt from this PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
643 lines
24 KiB
JavaScript
643 lines
24 KiB
JavaScript
/**
|
|
* Tests for #683: installer sets worktree.baseRef:"head" in settings.local.json
|
|
* for local Claude Code installs.
|
|
*
|
|
* Cases:
|
|
* 1. Fresh local install: writes worktree.baseRef:"head" automatically (no-clobber).
|
|
* 2. Fresh install with pre-existing explicit baseRef: does NOT clobber it.
|
|
* 3a. Upgrade + isLocalClaude + use_worktrees absent/true → auto-applies baseRef.
|
|
* 3b. Upgrade + use_worktrees === false → does NOT apply baseRef.
|
|
* 3c. Upgrade + explicit baseRef already present (local or shared) → unchanged (no-clobber).
|
|
* 4. Idempotency: re-running a fresh-style install when baseRef is already "head"
|
|
* does not duplicate or error.
|
|
* 5. Global Claude install: does NOT set worktree.baseRef (only local Claude).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
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(() => {
|
|
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
});
|
|
});
|
|
|
|
// ─── Helper: run both install phases (mirrors installAllRuntimes two-phase) ──
|
|
|
|
function runInstall(isGlobal, opts = {}) {
|
|
const { shouldInstallStatusline = false } = opts;
|
|
const result = install(isGlobal, 'claude');
|
|
finishInstall(
|
|
result.settingsPath,
|
|
result.settings,
|
|
result.statuslineCommand,
|
|
shouldInstallStatusline,
|
|
'claude',
|
|
isGlobal
|
|
);
|
|
return { result };
|
|
}
|
|
|
|
// ─── Helper: write .planning/config.json in the project root ─────────────────
|
|
// For a local Claude install, targetDir = <cwd>/.claude, so project root = cwd.
|
|
// .planning/config.json lives at <cwd>/.planning/config.json.
|
|
|
|
function writePlanningConfig(projectRoot, config) {
|
|
const planningDir = path.join(projectRoot, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2) + '\n');
|
|
}
|
|
|
|
// ─── Case 1: fresh local install writes worktree.baseRef:"head" ──────────────
|
|
|
|
describe('#683 case 1: fresh local Claude install sets worktree.baseRef:"head"', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('settings.local.json contains worktree.baseRef:"head" after fresh install', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
runInstall(false);
|
|
|
|
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
|
assert.ok(
|
|
fs.existsSync(localSettingsPath),
|
|
'.claude/settings.local.json must exist after local Claude install'
|
|
);
|
|
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.ok(
|
|
settings && typeof settings === 'object',
|
|
'settings.local.json must be a valid JSON object'
|
|
);
|
|
assert.strictEqual(
|
|
settings.worktree && settings.worktree.baseRef,
|
|
'head',
|
|
'worktree.baseRef must be "head" after a fresh local Claude install (#683)'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 2: fresh install does not clobber a pre-existing explicit baseRef ──
|
|
|
|
describe('#683 case 2: fresh install does not clobber existing explicit worktree.baseRef', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-noclobber-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('pre-existing explicit baseRef is preserved on fresh install', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Pre-populate settings.local.json with an explicit non-"head" baseRef
|
|
const claudeDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
|
fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n');
|
|
|
|
runInstall(false);
|
|
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree && settings.worktree.baseRef,
|
|
'main',
|
|
'An explicit worktree.baseRef must not be overwritten by the installer (#683 no-clobber)'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 3a: upgrade + use_worktrees absent → auto-applies baseRef ──────────
|
|
|
|
describe('#683 case 3a: upgrade + use_worktrees absent → auto-applies worktree.baseRef', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-on-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is absent', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Simulate a prior install by pre-creating the VERSION file.
|
|
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
|
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
|
fs.writeFileSync(versionPath, '1.0.0');
|
|
// No .planning/config.json — use_worktrees defaults to enabled (true).
|
|
|
|
runInstall(false);
|
|
|
|
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
|
assert.ok(
|
|
fs.existsSync(localSettingsPath),
|
|
'.claude/settings.local.json must exist after upgrade'
|
|
);
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree && settings.worktree.baseRef,
|
|
'head',
|
|
'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees is absent (#683)'
|
|
);
|
|
});
|
|
|
|
test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is true', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Simulate a prior install by pre-creating the VERSION file.
|
|
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
|
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
|
fs.writeFileSync(versionPath, '1.0.0');
|
|
// .planning/config.json with use_worktrees: true
|
|
writePlanningConfig(tmpDir, { workflow: { use_worktrees: true } });
|
|
|
|
runInstall(false);
|
|
|
|
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
|
assert.ok(fs.existsSync(localSettingsPath), '.claude/settings.local.json must exist after upgrade');
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree && settings.worktree.baseRef,
|
|
'head',
|
|
'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees:true (#683)'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 3b: upgrade + use_worktrees === false → does NOT apply baseRef ─────
|
|
|
|
describe('#683 case 3b: upgrade + use_worktrees:false → does NOT apply worktree.baseRef', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-off-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('upgrade does not apply worktree.baseRef when use_worktrees is false', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Simulate a prior install by pre-creating the VERSION file.
|
|
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
|
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
|
fs.writeFileSync(versionPath, '1.0.0');
|
|
// .planning/config.json with use_worktrees: false
|
|
writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } });
|
|
|
|
runInstall(false);
|
|
|
|
// finishInstall always writes settings.local.json for local Claude installs.
|
|
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
|
assert.ok(
|
|
fs.existsSync(localSettingsPath),
|
|
'.claude/settings.local.json must exist after upgrade (finishInstall writes it)'
|
|
);
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree,
|
|
undefined,
|
|
'upgrade must NOT apply worktree block when use_worktrees:false (#683)'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 3c: upgrade + explicit baseRef already present → no-clobber ─────────
|
|
|
|
describe('#683 case 3c: upgrade + explicit baseRef present → no-clobber (unchanged)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-noclobber-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('upgrade preserves an explicit worktree.baseRef set by the user in local settings', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Simulate prior install with user-set explicit baseRef
|
|
const claudeDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION');
|
|
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
|
fs.writeFileSync(versionPath, '1.0.0');
|
|
|
|
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
|
fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n');
|
|
|
|
runInstall(false);
|
|
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree && settings.worktree.baseRef,
|
|
'fresh',
|
|
'upgrade must preserve an explicit user-set worktree.baseRef in local settings (#683 no-clobber)'
|
|
);
|
|
});
|
|
|
|
test('upgrade does not inject baseRef when shared settings.json already has one', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Simulate prior install
|
|
const claudeDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION');
|
|
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
|
fs.writeFileSync(versionPath, '1.0.0');
|
|
|
|
// Shared settings.json has an explicit baseRef; settings.local.json does not.
|
|
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
|
fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n');
|
|
|
|
runInstall(false);
|
|
|
|
// finishInstall always writes settings.local.json for local Claude installs.
|
|
// Shared no-clobber: sharedBaseRef !== null → installer must not inject.
|
|
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
|
assert.ok(
|
|
fs.existsSync(localSettingsPath),
|
|
'.claude/settings.local.json must exist after upgrade (finishInstall writes it)'
|
|
);
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree && settings.worktree.baseRef,
|
|
undefined,
|
|
'upgrade must NOT inject worktree.baseRef to settings.local.json when shared settings.json already has one (#683 no-clobber)'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 4: idempotency — re-running fresh-style install when already "head" ─
|
|
|
|
describe('#683 case 4: idempotency — re-installing when worktree.baseRef already "head"', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('second install does not error or duplicate worktree block', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Run once (sets baseRef:"head")
|
|
runInstall(false);
|
|
|
|
// Remove the VERSION file to simulate a fresh-style re-install (e.g. forced reinstall)
|
|
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
|
if (fs.existsSync(versionPath)) {
|
|
fs.unlinkSync(versionPath);
|
|
}
|
|
|
|
// Run again — should be idempotent
|
|
runInstall(false);
|
|
|
|
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree && settings.worktree.baseRef,
|
|
'head',
|
|
'worktree.baseRef must still be "head" after idempotent re-install (#683)'
|
|
);
|
|
// Ensure worktree block wasn't duplicated into an array or otherwise corrupted
|
|
assert.strictEqual(
|
|
typeof settings.worktree,
|
|
'object',
|
|
'worktree must be a plain object after idempotent re-install'
|
|
);
|
|
assert.ok(
|
|
!Array.isArray(settings.worktree),
|
|
'worktree must not have been duplicated into an array'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 2b (FIX 1): fresh install does not clobber baseRef set in shared settings.json ──
|
|
|
|
describe('#683 case 2b (FIX 1): fresh install does not clobber worktree.baseRef in shared settings.json', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-shared-noclobber-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('shared settings.json with worktree.baseRef:"fresh" → installer must NOT write baseRef to settings.local.json and must NOT print ✓ notice', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Pre-populate only the SHARED settings.json with an explicit baseRef.
|
|
// settings.local.json does NOT exist — this is a fresh install otherwise.
|
|
const claudeDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
|
fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n');
|
|
|
|
runInstall(false);
|
|
|
|
// settings.local.json must either not exist or have no worktree.baseRef.
|
|
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
|
if (fs.existsSync(localSettingsPath)) {
|
|
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
localSettings.worktree && localSettings.worktree.baseRef,
|
|
undefined,
|
|
'installer must NOT write worktree.baseRef to settings.local.json when shared settings.json already has an explicit baseRef (#683 FIX 1)'
|
|
);
|
|
}
|
|
// If settings.local.json wasn't written, the test passes (no injection occurred).
|
|
});
|
|
});
|
|
|
|
// ─── Case 6 (FIX 1): non-object settings.local.json does not crash installer ──
|
|
|
|
describe('#683 FIX 1: non-object settings.local.json does not crash the installer', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-nonobj-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('settings.local.json containing [] does not throw during fresh local install', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Pre-populate settings.local.json with an array — valid JSON but non-object
|
|
const claudeDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
|
fs.writeFileSync(localSettingsPath, '[]');
|
|
|
|
// Must not throw — baseRef logic must be silently skipped for non-objects
|
|
assert.doesNotThrow(() => runInstall(false));
|
|
});
|
|
|
|
test('settings.local.json containing JSON null does not crash installer and leaves no worktree.baseRef (#683 guard)', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Pre-populate settings.local.json with the JSON value `null` — valid JSON,
|
|
// but non-object. The install() function special-cases a null parsed result
|
|
// (indistinguishable from a parse error) and returns early, so we call install()
|
|
// directly here instead of runInstall() which calls finishInstall and would
|
|
// crash on the undefined result.
|
|
// The #683 guard in install() is:
|
|
// `settings !== null && typeof settings === 'object' && !Array.isArray(settings)`
|
|
// That guard prevents applyWorktreeBaseRef from being invoked with null (which
|
|
// would throw TypeError); this test verifies that guard fires and no crash occurs.
|
|
const claudeDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
|
fs.writeFileSync(localSettingsPath, 'null');
|
|
|
|
// install() must not throw; it returns undefined early for unparseable settings.
|
|
assert.doesNotThrow(() => install(false, 'claude'));
|
|
|
|
// settings.local.json must still read as null (installer bailed out and did not
|
|
// overwrite it), which means no worktree.baseRef was injected.
|
|
const raw = fs.readFileSync(localSettingsPath, 'utf-8');
|
|
const parsed = JSON.parse(raw);
|
|
// The file was not rewritten (install returned early), so it is still `null`.
|
|
// Either way, no worktree block should be present.
|
|
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
assert.strictEqual(
|
|
parsed.worktree,
|
|
undefined,
|
|
'installer must NOT write worktree block when settings.local.json held JSON null (#683 FIX 1 guard)'
|
|
);
|
|
} else {
|
|
// File is still null (or non-object) — no worktree.baseRef was written.
|
|
assert.ok(
|
|
parsed === null || Array.isArray(parsed) || typeof parsed !== 'object',
|
|
'settings.local.json remained non-object after install — no worktree.baseRef was injected'
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Case 7: fresh + use_worktrees:false → does NOT write worktree.baseRef ───
|
|
|
|
describe('#683 case 7: fresh local Claude install + use_worktrees:false → does NOT set worktree.baseRef', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-off-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('fresh install with use_worktrees:false does not write worktree.baseRef', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// No VERSION file → fresh install.
|
|
// .planning/config.json with use_worktrees: false → must suppress baseRef.
|
|
writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } });
|
|
|
|
runInstall(false);
|
|
|
|
// finishInstall always writes settings.local.json for local Claude installs.
|
|
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
|
assert.ok(
|
|
fs.existsSync(localSettingsPath),
|
|
'.claude/settings.local.json must exist after fresh local Claude install'
|
|
);
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree,
|
|
undefined,
|
|
'fresh install must NOT write worktree.baseRef when use_worktrees:false (#683 FIX A)'
|
|
);
|
|
});
|
|
|
|
test('fresh install with absent .planning/config.json still applies worktree.baseRef (default enabled)', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// No VERSION file → fresh install.
|
|
// No .planning/config.json → worktreesEnabled defaults to true.
|
|
|
|
runInstall(false);
|
|
|
|
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
|
assert.ok(
|
|
fs.existsSync(localSettingsPath),
|
|
'.claude/settings.local.json must exist after fresh local Claude install'
|
|
);
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree && settings.worktree.baseRef,
|
|
'head',
|
|
'fresh install must apply worktree.baseRef:"head" when .planning/config.json is absent (default enabled; #683 FIX A)'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 8: upgrade idempotency — two upgrade runs produce exactly one baseRef ─
|
|
|
|
describe('#683 case 8: upgrade→upgrade idempotency — two upgrade runs do not duplicate worktree.baseRef', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem2-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('two consecutive upgrade runs leave settings.local.json with exactly one worktree.baseRef:"head"', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// First upgrade run: VERSION present → upgrade path.
|
|
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
|
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
|
fs.writeFileSync(versionPath, '1.0.0');
|
|
// use_worktrees defaults to enabled (no .planning/config.json)
|
|
|
|
runInstall(false);
|
|
|
|
// Restore VERSION so the second run is also an upgrade.
|
|
if (!fs.existsSync(versionPath)) {
|
|
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
|
}
|
|
fs.writeFileSync(versionPath, '1.0.0');
|
|
|
|
runInstall(false);
|
|
|
|
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
|
assert.ok(
|
|
fs.existsSync(localSettingsPath),
|
|
'.claude/settings.local.json must exist after two upgrade runs'
|
|
);
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree && settings.worktree.baseRef,
|
|
'head',
|
|
'worktree.baseRef must be "head" after two upgrade runs (#683 idempotency)'
|
|
);
|
|
assert.strictEqual(
|
|
typeof settings.worktree,
|
|
'object',
|
|
'worktree must be a plain object after two upgrade runs'
|
|
);
|
|
assert.ok(
|
|
!Array.isArray(settings.worktree),
|
|
'worktree must not have been duplicated into an array after two upgrade runs'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 5: global Claude install does NOT set worktree.baseRef ─────────────
|
|
|
|
describe('#683 case 5: global Claude install does NOT set worktree.baseRef', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-global-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('global install does not write worktree.baseRef', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
|
|
// Point CLAUDE_CONFIG_DIR at a tmpDir subdir to avoid polluting ~/.claude
|
|
const configDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
const origEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
t.after(() => {
|
|
if (origEnv === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = origEnv;
|
|
}
|
|
});
|
|
|
|
runInstall(true);
|
|
|
|
const settingsPath = path.join(configDir, 'settings.json');
|
|
if (fs.existsSync(settingsPath)) {
|
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
assert.strictEqual(
|
|
settings.worktree,
|
|
undefined,
|
|
'global Claude install must not write worktree.baseRef into settings.json (#683)'
|
|
);
|
|
}
|
|
});
|
|
});
|