Files
msd-core/tests/settings-jsonc.test.cjs
Tom Boucher b783410815 refactor(#1191): inject clock/reset testability seams + handle valid-null settings (#1233)
* refactor(#1191): inject clock/reset testability seams + handle valid-null settings

- worktree-safety reapOrphanWorktrees: injectable deps.nowMs clock for deterministic stale-lock boundary tests (mirrors snapshotWorktreeInventory's options.nowMs).

- active-workstream-store: _resetControllingTtyCacheForTests() seam clears the memoized controlling-TTY probe cache; test replaces require.cache busting.

- gen-capability-registry: export stripGeneratedComment (additive); test imports the real helper + equivalence assertion, keeping the deliberate drift oracle.

- install.js readSettings: a successfully-parsed JSON null is treated as empty settings ({}) instead of being mis-reported as malformed; genuine parse failures still warn. readSettings/stripJsonComments exported (GSD_TEST_MODE-guarded require) for real behavioral tests.

Closes #1191

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1191): add changeset for valid-null settings fix (#1233)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1191): replace Stryker-incompatible structural reset test with behavioral isTTY-spy

The seam-2 reset test read the BUILT active-workstream-store.cjs and grepped for 'didProbeControllingTtyToken = false' — Stryker instruments that file so the literal is absent, failing the mutation DRY RUN. Replaced with a behavioral test that spies on process.stdin.isTTY access count to prove a post-reset probe re-runs (kills the didProbe-reset mutant) without reading source text. Local stryker: dry run passes, score 85.21% >= 80.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:52:56 -04:00

267 lines
9.9 KiB
JavaScript

// allow-test-rule: structural-regression-guard
// Reads hook .js or bin/install.js source to assert structural invariants
// (search array order, function wiring, path constants) that cannot be
// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception matrix.
/**
* GSD Tools Tests - settings.json JSONC (JSON with comments) support
*
* Validates that the installer's readSettings() correctly handles
* settings.json files containing comments (line and block) without
* silently overwriting them with empty objects.
*
* Closes: #1461
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
// ─── load real install.js exports once ───────────────────────────────────────
//
// install.js prints a banner at module-load time (outside its GSD_TEST_MODE
// guard) — silence stdout for the duration of the require() so test output
// stays clean. The main-logic block IS gated on GSD_TEST_MODE, so no
// installer side-effects run.
//
// Guard line (bin/install.js:12287):
// if (require.main === module && !process.env.GSD_TEST_MODE) {
//
let installExports;
{
process.env.GSD_TEST_MODE = '1';
const _origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = () => true; // suppress banner
try {
installExports = require('../bin/install.js');
} finally {
process.stdout.write = _origWrite;
}
}
const { readSettings, stripJsonComments } = installExports;
// ─── tests ───────────────────────────────────────────────────────────────────
describe('stripJsonComments (#1461)', () => {
test('strips line comments', () => {
const input = `{
// This is a comment
"key": "value"
}`;
const result = JSON.parse(stripJsonComments(input));
assert.deepStrictEqual(result, { key: 'value' });
});
test('strips block comments', () => {
const input = `{
/* Block comment */
"key": "value"
}`;
const result = JSON.parse(stripJsonComments(input));
assert.deepStrictEqual(result, { key: 'value' });
});
test('strips multi-line block comments', () => {
const input = `{
/*
* Multi-line
* block comment
*/
"key": "value"
}`;
const result = JSON.parse(stripJsonComments(input));
assert.deepStrictEqual(result, { key: 'value' });
});
test('preserves comments inside string values', () => {
const input = `{
"url": "https://example.com/path",
"description": "Use // for line comments"
}`;
const result = JSON.parse(stripJsonComments(input));
assert.strictEqual(result.url, 'https://example.com/path');
assert.strictEqual(result.description, 'Use // for line comments');
});
test('handles trailing commas', () => {
const input = `{
"a": 1,
"b": 2,
}`;
const result = JSON.parse(stripJsonComments(input));
assert.deepStrictEqual(result, { a: 1, b: 2 });
});
test('handles inline comments after values', () => {
const input = `{
"timeout": 5000, // milliseconds
"retries": 3 // max attempts
}`;
const result = JSON.parse(stripJsonComments(input));
assert.strictEqual(result.timeout, 5000);
assert.strictEqual(result.retries, 3);
});
test('handles standard JSON (no comments) unchanged', () => {
const input = '{"key": "value", "num": 42}';
const result = JSON.parse(stripJsonComments(input));
assert.deepStrictEqual(result, { key: 'value', num: 42 });
});
test('handles empty object', () => {
const result = JSON.parse(stripJsonComments('{}'));
assert.deepStrictEqual(result, {});
});
test('handles real-world settings.json with comments', () => {
const input = `{
// My configuration
"hooks": {
"SessionStart": [
{
"matcher": "", /* match all */
"hooks": [
{
"type": "command",
"command": "node ~/.claude/hooks/gsd-statusline.js"
}
]
}
]
},
"statusLine": {
"command": "node ~/.claude/hooks/gsd-statusline.js",
"refreshInterval": 10
}
}`;
const result = JSON.parse(stripJsonComments(input));
assert.ok(result.hooks, 'should have hooks');
assert.ok(result.statusLine, 'should have statusLine');
assert.strictEqual(result.statusLine.refreshInterval, 10);
});
});
describe('readSettings null return on malformed files (#1461)', () => {
test('install.js contains JSONC stripping in readSettings', () => {
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
const content = fs.readFileSync(installPath, 'utf8');
assert.ok(content.includes('stripJsonComments'),
'install.js should use stripJsonComments in readSettings');
});
test('readSettings returns null on truly malformed files (not empty object)', () => {
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
const content = fs.readFileSync(installPath, 'utf8');
assert.ok(content.includes('return null'),
'readSettings should return null on parse failure, not empty object');
});
test('callers guard against null readSettings return', () => {
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
const content = fs.readFileSync(installPath, 'utf8');
// Should have null guards at the settings configuration call sites
assert.ok(
content.includes('=== null') || content.includes('rawSettings === null'),
'callers should check for null return from readSettings'
);
});
});
// ─── seam-4 (#1191): real readSettings via exported function ─────────────────
//
// These tests exercise the REAL readSettings from bin/install.js (not a
// replica), using real temp files. The structural grep below is a secondary
// belt-and-suspenders anchoring the source text; the primary assertions are
// the behavioural ones beneath it.
describe('readSettings: JSON null coalesced to empty, malformed warns (#1191)', () => {
test('source contains the null-coalescing guard (parsed === null ? {})', () => {
// Structural anchor: if someone removes the coalescing, this test catches it
// before the behavioural test below even runs.
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
const content = fs.readFileSync(installPath, 'utf8');
assert.ok(
content.includes('parsed === null ? {}'),
'install.js readSettings must coalesce valid JSON null to {} (not malformed warning)'
);
});
test('valid JSON null content returns empty object with no malformed warning (real function)', () => {
// A settings file containing literally `null` is valid JSON.
// readSettings must treat it as empty settings ({}) — no warning emitted.
const tmpFile = path.join(os.tmpdir(), `gsd-settings-test-null-${process.pid}.json`);
fs.writeFileSync(tmpFile, 'null');
const warnCalls = [];
const origWarn = console.warn;
console.warn = (...args) => warnCalls.push(args.join(' '));
let result;
try {
result = readSettings(tmpFile);
} finally {
console.warn = origWarn;
fs.unlinkSync(tmpFile);
}
assert.deepStrictEqual(result, {}, 'JSON null must coalesce to {}');
const malformedWarns = warnCalls.filter(w => w.includes('malformed') || w.includes('Could not parse'));
assert.strictEqual(malformedWarns.length, 0, 'no malformed warning expected for valid JSON null');
});
test('malformed content returns null and emits malformed warning (real function)', () => {
// A file containing `{ broken` is not valid JSON (even after comment-stripping).
// readSettings must emit a malformed warning and return null.
const tmpFile = path.join(os.tmpdir(), `gsd-settings-test-broken-${process.pid}.json`);
fs.writeFileSync(tmpFile, '{ broken');
const warnCalls = [];
const origWarn = console.warn;
console.warn = (...args) => warnCalls.push(args.join(' '));
let result;
try {
result = readSettings(tmpFile);
} finally {
console.warn = origWarn;
fs.unlinkSync(tmpFile);
}
assert.strictEqual(result, null, 'malformed JSON must return null');
const malformedWarns = warnCalls.filter(w => w.includes('malformed') || w.includes('Could not parse'));
assert.strictEqual(malformedWarns.length, 1, 'exactly one malformed warning expected');
});
test('valid object content returns parsed object with no warning (real function)', () => {
const tmpFile = path.join(os.tmpdir(), `gsd-settings-test-valid-${process.pid}.json`);
fs.writeFileSync(tmpFile, '{"hooks":{}}');
const warnCalls = [];
const origWarn = console.warn;
console.warn = (...args) => warnCalls.push(args.join(' '));
let result;
try {
result = readSettings(tmpFile);
} finally {
console.warn = origWarn;
fs.unlinkSync(tmpFile);
}
assert.deepStrictEqual(result, { hooks: {} }, 'valid object must be returned as-is');
const malformedWarns = warnCalls.filter(w => w.includes('malformed') || w.includes('Could not parse'));
assert.strictEqual(malformedWarns.length, 0, 'no warning expected for valid JSON object');
});
test('absent file returns empty object with no warning (real function)', () => {
const tmpFile = path.join(os.tmpdir(), `gsd-settings-test-absent-${process.pid}.json`);
// ensure file does NOT exist
try { fs.unlinkSync(tmpFile); } catch { /* already absent */ }
const warnCalls = [];
const origWarn = console.warn;
console.warn = (...args) => warnCalls.push(args.join(' '));
let result;
try {
result = readSettings(tmpFile);
} finally {
console.warn = origWarn;
}
assert.deepStrictEqual(result, {}, 'absent file must return {}');
assert.strictEqual(warnCalls.length, 0, 'no warning expected for absent file');
});
});