* test(#3145): bound the installer/runtime cluster onto the process seam Migrates 156 unbounded sync spawn sites across 47 files. Allowlist 120 to 73. Timeouts are sized from evidence already in the tree rather than a house default, because this wave spawns installers rather than git plumbing and an undersized bound does not catch a hang -- it manufactures CI flake, which is worse, since a flake gets re-run instead of investigated. install.test.cjs records a real spawnSync ETIMEDOUT at a 60000ms cap on a loaded bench while another lane passed the same commit in 12.7s, so full installs are bound at 120000ms against that recorded incident. Also adds an auditable escape to the guard's timeout ceiling. The 600000ms cap was set in #3143 from partial evidence, but fragment-single-edit- propagation carries a documented, load-tested 900000ms bound on a run that chains a full build plus eight generators -- the guard would have rejected a correct timeout the moment that file left the allowlist. A value above the ceiling is now permitted only with an inline allow-spawn-timeout-ceiling marker carrying a non-empty reason. It raises the ceiling; it never waives the requirement for a bound, which is asserted directly. install-shared.cjs keeps its hand-rolled assert rather than routing through throwIfFailed: its message embeds both streams, and throwIfFailed carries only a trimmed stderr. The message now also names the outcome, so a bounded timeout reads as such across its 38 importers instead of as expected null to equal 0. * test(#3145): extract class-norm timeouts and correct the build-hooks sizing A pre-PR review found 52 copies of four class-norm timeout constants across this wave. These are not per-suite fixture bindings -- they are shared facts about how long a class of subprocess takes, derived from a recorded bench incident. That norm already moved once (60000 to 120000 after a real ETIMEDOUT), and 52 copies would have drifted the next time it moved. Extracts tests/helpers/timeouts.cjs, where each norm is justified once, and converts the copies. A site that genuinely differs -- a real tsc compile, or regen:derived -- keeps its own local constant with its own justification. Also corrects a misclassification: scripts/build-hooks.js was sized as a build at 120000 in twelve places and 60000 in another, but it compiles and bundles nothing. Its own header says no bundling needed; it copies pre-built files and syntax-checks them with vm. Three different values bounded one script; now there is one. * test(#3145): fix red CI — lint self-match and a Windows chunk overrun Two failures on PR 3176. lint-allow-test-rule-refs read a RuleTester fixture as a real exemption. The fixture exists to prove an unrelated marker does NOT suppress the rule, so it carries that marker's literal text as test data. Split via concatenation, the same idiom no-unbounded-spawn-allowlist.test.cjs already uses for its own self-match problem. The explanatory comment needed the same treatment. The Windows shard 3/3 chunk was killed at its 600000ms budget. Output stopped seven minutes before the kill, so this was an overrun rather than a slow chunk: regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface runs regen:derived bounded at 900000ms, which is larger than the whole chunk budget, so the chunk killer always fires first and it can never complete there. Both the test and that bound predate this change; modifying the file pulled it into the Windows targeted set and exposed it. Skipped on Windows with the reason recorded; the Linux lanes cover it. The 900000 bound and its ceiling marker are unchanged -- they are correct. * test(#3145): refresh the stale test-timings cost table The Windows shard was killed at its 600000ms per-chunk budget. run-tests.cjs packs chunks by measured duration from tests/test-timings.json, and an unknown file falls back to the table's median weight -- advisory by design, but it silently underweights exactly the files that matter. Four of the failing chunk's 22 files were absent from the table, including the two heaviest: fragment-single-edit-propagation.install.test.cjs at 230s (it runs regen:derived) and agent-fragments-emission.install.test.cjs at 79s. Both were weighted as average, so the chunk's total weight read 53.68 against a budget of 60 and the packer produced a single chunk. Regenerated from a passing full-suite run, per the remedy the script itself documents. 700 to 770 entries, 70 added, 0 dropped -- verified, since gen-test-timings.cjs replaces the table wholesale rather than merging. Proven against the real packer: the same 22 files now weigh 103.91 and split into two chunks. No logic, budget, or timeout was changed; raising a budget to make a red gate pass is not a fix. --------- Co-authored-by: sim <sim@local>
554 lines
25 KiB
JavaScript
554 lines
25 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* capability-writer.test.cjs — TDD tests for capability-writer.cjs.
|
|
*
|
|
* ADR-1213: write-side inverse of the capability resolver.
|
|
* Uses node:test + node:assert/strict.
|
|
* Tests run against the REAL registry and compiled .cjs in gsd-core/bin/lib/.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const { setCapabilityState } = require('../gsd-core/bin/lib/capability-writer.cjs');
|
|
const { readSurface } = require('../gsd-core/bin/lib/surface.cjs');
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create a temp runtimeConfigDir and cwd pair for a test.
|
|
* - rcd has no .gsd-profile file → default 'full' profile → installedSkills='*'
|
|
* - cwd has .planning/config.json (empty)
|
|
*/
|
|
function makeTempDirs() {
|
|
const rcd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-writer-rcd-'));
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-writer-cwd-'));
|
|
// Ensure .planning dir + empty config
|
|
const planningDir = path.join(cwd, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
|
|
return { rcd, cwd };
|
|
}
|
|
|
|
function readConfig(cwd) {
|
|
const configPath = path.join(cwd, '.planning', 'config.json');
|
|
if (!fs.existsSync(configPath)) return {};
|
|
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('capability-writer: setCapabilityState', () => {
|
|
|
|
// ── Test 1: disable a skill-owning capability ─────────────────────────────
|
|
test('disable ui: surface gets disabledClusters=["ui"], ui.enabled===false, all ui hooks active===false', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
|
|
assert.ok(Array.isArray(result.capabilities), 'capabilities is array');
|
|
assert.ok(Array.isArray(result.warnings), 'warnings is array');
|
|
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap, 'ui capability present in result');
|
|
assert.equal(uiCap.enabled, false, 'ui.enabled should be false');
|
|
// All hooks for ui should be active===false
|
|
assert.ok(uiCap.hooks.every((h) => h.active === false), 'all ui hooks should be inactive');
|
|
|
|
// Surface file should have ui in disabledClusters
|
|
const surface = readSurface(rcd);
|
|
assert.ok(surface !== null, 'surface file written');
|
|
assert.ok(surface.disabledClusters.includes('ui'), 'ui in disabledClusters');
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 2: re-enable ─────────────────────────────────────────────────────
|
|
test('re-enable ui: off then on → ui.enabled===true; disabledClusters excludes ui', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
// First disable
|
|
setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
|
|
// Then re-enable
|
|
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: true }]);
|
|
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap, 'ui capability present');
|
|
assert.equal(uiCap.enabled, true, 'ui.enabled should be true after re-enable');
|
|
|
|
const surface = readSurface(rcd);
|
|
assert.ok(surface !== null, 'surface file exists');
|
|
assert.ok(!surface.disabledClusters.includes('ui'), 'ui NOT in disabledClusters after re-enable');
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 3: gate within enabled capability ────────────────────────────────
|
|
test('gate code-review off: hook configured===false; config.json has workflow.code_review===false', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
const result = setCapabilityState(cwd, rcd, [
|
|
{ id: 'code-review', gates: { 'workflow.code_review': false } },
|
|
]);
|
|
|
|
const crCap = result.capabilities.find((c) => c.id === 'code-review');
|
|
assert.ok(crCap, 'code-review capability present');
|
|
// code-review has skills so enabled should be true (full install + surfaced)
|
|
assert.equal(crCap.enabled, true, 'code-review should be enabled (full install)');
|
|
// The hook gated by workflow.code_review should be configured===false
|
|
const gatedHook = crCap.hooks.find((h) => h.when === 'workflow.code_review');
|
|
assert.ok(gatedHook !== undefined, 'found hook with when=workflow.code_review');
|
|
assert.equal(gatedHook.configured, false, 'hook configured===false after gate set to false');
|
|
|
|
// Config should have the value persisted
|
|
const config = readConfig(cwd);
|
|
assert.ok(config.workflow, 'workflow section in config');
|
|
assert.equal(config.workflow.code_review, false, 'workflow.code_review===false in config');
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 4: re-enable preserves prior gates ───────────────────────────────
|
|
test('re-enable preserves prior gates: gate false → disable → re-enable → gate still false', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
// Set gate to false
|
|
setCapabilityState(cwd, rcd, [
|
|
{ id: 'code-review', gates: { 'workflow.code_review': false } },
|
|
]);
|
|
// Disable code-review
|
|
setCapabilityState(cwd, rcd, [{ id: 'code-review', enabled: false }]);
|
|
// Re-enable code-review (no gate change)
|
|
setCapabilityState(cwd, rcd, [{ id: 'code-review', enabled: true }]);
|
|
|
|
// Config should still have workflow.code_review===false
|
|
const config = readConfig(cwd);
|
|
assert.equal(config?.workflow?.code_review, false, 'workflow.code_review still false after re-enable');
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 5: present-but-dead warning ─────────────────────────────────────
|
|
test('present-but-dead warning: gate all hooks off for research → warning emitted', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
// research has skills:[] (vacuously installed/surfaced) and a hook gated by workflow.research
|
|
// Set workflow.research=false to gate off its only hook
|
|
const result = setCapabilityState(cwd, rcd, [
|
|
{ id: 'research', gates: { 'workflow.research': false } },
|
|
]);
|
|
|
|
// research is enabled (vacuously surfaced since no skills) but hook is gated off
|
|
const researchCap = result.capabilities.find((c) => c.id === 'research');
|
|
assert.ok(researchCap, 'research capability present');
|
|
assert.equal(researchCap.enabled, true, 'research enabled (vacuously)');
|
|
assert.ok(researchCap.hooks.length > 0, 'research has hooks');
|
|
assert.ok(researchCap.hooks.every((h) => !h.configured), 'all research hooks configured===false');
|
|
|
|
// Should have a present-but-dead warning
|
|
const deadWarning = result.warnings.find(
|
|
(w) => w.includes('research') && w.includes('gated off'),
|
|
);
|
|
assert.ok(deadWarning !== undefined, `expected present-but-dead warning for research, got: ${JSON.stringify(result.warnings)}`);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 6: skill-less enabled:false warns ────────────────────────────────
|
|
test('skill-less enabled:false: research warns "owns no skills", disabledClusters unchanged', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
const result = setCapabilityState(cwd, rcd, [{ id: 'research', enabled: false }]);
|
|
|
|
const noSkillsWarning = result.warnings.find(
|
|
(w) => w.includes('research') && w.includes('no skills'),
|
|
);
|
|
assert.ok(noSkillsWarning !== undefined, `expected no-skills warning, got: ${JSON.stringify(result.warnings)}`);
|
|
|
|
// Surface should NOT have research in disabledClusters
|
|
const surface = readSurface(rcd);
|
|
// No surface file written (or if it exists, research not in disabledClusters)
|
|
if (surface !== null) {
|
|
assert.ok(!surface.disabledClusters.includes('research'), 'research NOT in disabledClusters');
|
|
}
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 7: unknown id ────────────────────────────────────────────────────
|
|
test('unknown id: error emitted (not warning), no throw', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
let result;
|
|
assert.doesNotThrow(() => {
|
|
result = setCapabilityState(cwd, rcd, [{ id: 'does-not-exist', enabled: false }]);
|
|
});
|
|
assert.ok(result, 'result returned');
|
|
assert.ok(Array.isArray(result.errors), 'errors is array');
|
|
const unknownError = result.errors.find((e) => e.includes('does-not-exist'));
|
|
assert.ok(unknownError !== undefined, `expected unknown error, got errors: ${JSON.stringify(result.errors)}`);
|
|
// Must NOT be in warnings
|
|
const unknownWarning = result.warnings.find((w) => w.includes('does-not-exist'));
|
|
assert.equal(unknownWarning, undefined, `unknown id must not appear in warnings, got: ${JSON.stringify(result.warnings)}`);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 8: invalid gate key ──────────────────────────────────────────────
|
|
test('invalid gate key: error emitted (not warning), no throw, key NOT written to config', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
let result;
|
|
assert.doesNotThrow(() => {
|
|
result = setCapabilityState(cwd, rcd, [
|
|
{ id: 'ui', gates: { 'workflow.not_a_key': false } },
|
|
]);
|
|
});
|
|
assert.ok(result, 'result returned');
|
|
assert.ok(Array.isArray(result.errors), 'errors is array');
|
|
const invalidKeyError = result.errors.find((e) => e.includes('workflow.not_a_key'));
|
|
assert.ok(invalidKeyError !== undefined, `expected invalid-gate error, got errors: ${JSON.stringify(result.errors)}`);
|
|
// Must NOT be in warnings
|
|
const invalidKeyWarning = result.warnings.find((w) => w.includes('workflow.not_a_key'));
|
|
assert.equal(invalidKeyWarning, undefined, `invalid gate key must not appear in warnings, got: ${JSON.stringify(result.warnings)}`);
|
|
|
|
// Config should NOT have the key
|
|
const config = readConfig(cwd);
|
|
assert.equal(config?.workflow?.not_a_key, undefined, 'invalid key not written to config');
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 9: batch disable ─────────────────────────────────────────────────
|
|
test('batch: disable ui and code-review → both in disabledClusters, both enabled===false', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
const result = setCapabilityState(cwd, rcd, [
|
|
{ id: 'ui', enabled: false },
|
|
{ id: 'code-review', enabled: false },
|
|
]);
|
|
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
const crCap = result.capabilities.find((c) => c.id === 'code-review');
|
|
assert.ok(uiCap, 'ui capability present');
|
|
assert.ok(crCap, 'code-review capability present');
|
|
assert.equal(uiCap.enabled, false, 'ui.enabled===false');
|
|
assert.equal(crCap.enabled, false, 'code-review.enabled===false');
|
|
|
|
const surface = readSurface(rcd);
|
|
assert.ok(surface !== null, 'surface file written');
|
|
assert.ok(surface.disabledClusters.includes('ui'), 'ui in disabledClusters');
|
|
assert.ok(surface.disabledClusters.includes('code-review'), 'code-review in disabledClusters');
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 10: scoping regression — touching ui must not mention intel ──────
|
|
test('scoping: setCapabilityState([{id:"ui", enabled:false}]) must not mention intel in warnings or errors', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
|
|
assert.ok(Array.isArray(result.warnings), 'warnings is array');
|
|
assert.ok(Array.isArray(result.errors), 'errors is array');
|
|
|
|
const intelInWarnings = result.warnings.some((w) => w.includes('intel'));
|
|
assert.equal(intelInWarnings, false,
|
|
`warnings must not mention intel, got: ${JSON.stringify(result.warnings)}`);
|
|
|
|
const intelInErrors = result.errors.some((e) => e.includes('intel'));
|
|
assert.equal(intelInErrors, false,
|
|
`errors must not mention intel, got: ${JSON.stringify(result.errors)}`);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 11: CLI exit-code tests ──────────────────────────────────────────
|
|
test('CLI: capability set ui --off exits 0; capability set ui --on exits 0; unknown id exits non-zero', () => {
|
|
const rcd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-cli-rcd-'));
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-cli-cwd-'));
|
|
try {
|
|
// Create .planning/config.json so the resolver has a valid project
|
|
const planningDir = path.join(cwd, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
|
|
|
|
const gsdToolsBin = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs');
|
|
|
|
// Test: capability set ui --off (--config-dir rcd) exits 0
|
|
const offResult = runNode([gsdToolsBin, 'capability', 'set', 'ui', '--off', '--config-dir', rcd], {
|
|
cwd,
|
|
timeoutMs: 15000,
|
|
});
|
|
assert.equal(offResult.exitCode, 0,
|
|
`capability set ui --off should exit 0, got ${String(offResult.exitCode)}. stderr: ${offResult.stderr}`);
|
|
|
|
// Test: capability set ui --on (--config-dir rcd) exits 0
|
|
const onResult = runNode([gsdToolsBin, 'capability', 'set', 'ui', '--on', '--config-dir', rcd], {
|
|
cwd,
|
|
timeoutMs: 15000,
|
|
});
|
|
assert.equal(onResult.exitCode, 0,
|
|
`capability set ui --on should exit 0, got ${String(onResult.exitCode)}. stderr: ${onResult.stderr}`);
|
|
|
|
// Test: unknown id exits non-zero
|
|
const unknownResult = runNode([gsdToolsBin, 'capability', 'set', 'does-not-exist', '--off', '--config-dir', rcd], {
|
|
cwd,
|
|
timeoutMs: 15000,
|
|
});
|
|
assert.notEqual(unknownResult.exitCode, 0,
|
|
`capability set does-not-exist --off should exit non-zero, got ${String(unknownResult.exitCode)}`);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 12: validate-before-write atomicity ──────────────────────────────
|
|
test('validate-before-write: mixed-error batch leaves substrates untouched', () => {
|
|
// If a batch has ANY error (e.g. invalid gate key), NEITHER the surface
|
|
// NOR the config must be written — even for the valid entries in the batch.
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
// Batch: ui has an invalid gate key (error), plus code-review enabled:false (valid)
|
|
// Because there is an error, NEITHER should be written.
|
|
let result;
|
|
assert.doesNotThrow(() => {
|
|
result = setCapabilityState(cwd, rcd, [
|
|
{ id: 'ui', gates: { 'workflow.not_a_key': false } }, // invalid gate key → error
|
|
{ id: 'code-review', enabled: false }, // valid, but should NOT write
|
|
]);
|
|
});
|
|
|
|
assert.ok(result.errors.length > 0, `expected errors, got: ${JSON.stringify(result.errors)}`);
|
|
const invalidKeyError = result.errors.find((e) => e.includes('workflow.not_a_key'));
|
|
assert.ok(invalidKeyError !== undefined, `expected invalid-gate error, got: ${JSON.stringify(result.errors)}`);
|
|
|
|
// Surface must NOT be written (code-review must NOT be in disabledClusters)
|
|
const surface = readSurface(rcd);
|
|
if (surface !== null) {
|
|
assert.ok(
|
|
!surface.disabledClusters.includes('code-review'),
|
|
`code-review must not be in disabledClusters when batch has errors; surface: ${JSON.stringify(surface)}`,
|
|
);
|
|
}
|
|
|
|
// Config must NOT be written
|
|
const config = readConfig(cwd);
|
|
assert.equal(
|
|
config?.workflow?.not_a_key,
|
|
undefined,
|
|
'invalid gate key must not appear in config.json',
|
|
);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 13: disable→enable round-trip confirms enabled:true ─────────────
|
|
test('enable after disable: round-trip still yields ui.enabled===true', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
// Disable first
|
|
setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
|
|
// Re-enable
|
|
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: true }]);
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap, 'ui capability present in result');
|
|
assert.equal(uiCap.enabled, true, 'ui.enabled should be true after re-enable');
|
|
assert.equal(result.errors.length, 0,
|
|
`no errors expected on re-enable, got: ${JSON.stringify(result.errors)}`);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 13b (Fix A + Fix B): explicitAdds disable interaction ──────────
|
|
test('Fix A: disable when explicitAdds holds cap skill stems → skill stems removed, ui.enabled===false, no errors', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
// Seed surface with ui skill stems already in explicitAdds
|
|
const surfacePath = path.join(rcd, '.gsd-surface.json');
|
|
fs.writeFileSync(surfacePath, JSON.stringify({
|
|
baseProfile: 'full',
|
|
disabledClusters: [],
|
|
explicitAdds: ['ui-phase', 'ui-review'],
|
|
explicitRemoves: [],
|
|
}));
|
|
|
|
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
|
|
|
|
// Fix B covers this: if disable didn't work, the post-check would emit an error
|
|
assert.equal(result.errors.length, 0,
|
|
`no errors expected, got: ${JSON.stringify(result.errors)}`);
|
|
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap, 'ui capability present in result');
|
|
assert.equal(uiCap.enabled, false, 'ui.enabled should be false after disable');
|
|
|
|
const surface = readSurface(rcd);
|
|
assert.ok(surface !== null, 'surface file exists');
|
|
assert.ok(surface.disabledClusters.includes('ui'), 'ui in disabledClusters');
|
|
// Skill stems must have been removed from explicitAdds
|
|
assert.ok(!surface.explicitAdds.includes('ui-phase'),
|
|
`ui-phase must be removed from explicitAdds, got: ${JSON.stringify(surface.explicitAdds)}`);
|
|
assert.ok(!surface.explicitAdds.includes('ui-review'),
|
|
`ui-review must be removed from explicitAdds, got: ${JSON.stringify(surface.explicitAdds)}`);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 15 (Fix C): materialise failure → error, not warning ─────────────
|
|
test('Fix C: materialise failure is an error (not a warning)', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
// Use an invalid runtime that throws in resolveRuntimeArtifactLayout
|
|
const result = setCapabilityState(
|
|
cwd, rcd,
|
|
[{ id: 'ui', enabled: false }],
|
|
{ materialize: { runtime: 'definitely-not-a-runtime', scope: 'global' } },
|
|
);
|
|
|
|
assert.ok(result.errors.length > 0,
|
|
`expected errors for materialise failure, got: ${JSON.stringify(result.errors)}`);
|
|
const materialiseError = result.errors.find((e) => e.includes('materialize failed'));
|
|
assert.ok(materialiseError !== undefined,
|
|
`expected "materialize failed" error, got errors: ${JSON.stringify(result.errors)}`);
|
|
// Must NOT appear in warnings
|
|
const materialiseWarning = result.warnings.find((w) => w.includes('materialize failed'));
|
|
assert.equal(materialiseWarning, undefined,
|
|
`materialise failure must not appear in warnings, got: ${JSON.stringify(result.warnings)}`);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 16 (Fix D): malformed config.json → error, surface NOT written ───
|
|
test('Fix D: malformed config.json pre-check blocks write and surface not created', () => {
|
|
const { rcd, cwd } = makeTempDirs();
|
|
try {
|
|
// Write malformed config.json
|
|
const planningDirPath = path.join(cwd, '.planning');
|
|
fs.mkdirSync(planningDirPath, { recursive: true });
|
|
fs.writeFileSync(path.join(planningDirPath, 'config.json'), '{ not json');
|
|
|
|
const result = setCapabilityState(cwd, rcd, [
|
|
{ id: 'code-review', gates: { 'workflow.code_review': false } },
|
|
]);
|
|
|
|
assert.ok(result.errors.length > 0,
|
|
`expected errors for malformed config, got: ${JSON.stringify(result.errors)}`);
|
|
const malformedError = result.errors.find(
|
|
(e) => e.includes('malformed') || e.includes('config.json'),
|
|
);
|
|
assert.ok(malformedError !== undefined,
|
|
`expected malformed config error, got errors: ${JSON.stringify(result.errors)}`);
|
|
|
|
// Surface must NOT have been written (no partial write)
|
|
const surface = readSurface(rcd);
|
|
assert.equal(surface, null,
|
|
`surface must not have been written when config is malformed, got: ${JSON.stringify(surface)}`);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Test 14: CLI conflicting --on --off flags → non-zero exit ─────────────
|
|
test('CLI: capability set ui --on --off exits non-zero (conflicting flags)', () => {
|
|
const rcd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-cli-conflict-'));
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-cli-conflict-cwd-'));
|
|
try {
|
|
const planningDir = path.join(cwd, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
|
|
|
|
const gsdToolsBin = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs');
|
|
|
|
const conflictResult = runNode(
|
|
[gsdToolsBin, 'capability', 'set', 'ui', '--on', '--off', '--config-dir', rcd],
|
|
{ cwd, timeoutMs: 15000 },
|
|
);
|
|
assert.notEqual(
|
|
conflictResult.exitCode, 0,
|
|
`capability set ui --on --off should exit non-zero, got ${String(conflictResult.exitCode)}. stderr: ${conflictResult.stderr}`,
|
|
);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
// ─── Null-intermediate config tests ──────────────────────────────────────────
|
|
|
|
describe('capability-writer: null-intermediate config guard', () => {
|
|
|
|
test('null intermediate: gate write into {workflow:null} produces {workflow:{key:false}} without throw', () => {
|
|
// Regression for Fix 2: typeof null === 'object' caused _setNestedValue to
|
|
// traverse null and throw TypeError at null[key].
|
|
const rcd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-null-rcd-'));
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-null-cwd-'));
|
|
try {
|
|
const planningDir = path.join(cwd, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
// Pre-write a config where the intermediate 'workflow' key is null
|
|
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ workflow: null }));
|
|
|
|
// code-review owns workflow.code_review; writing it should replace null with {}
|
|
let result;
|
|
assert.doesNotThrow(() => {
|
|
result = setCapabilityState(cwd, rcd, [
|
|
{ id: 'code-review', gates: { 'workflow.code_review': false } },
|
|
]);
|
|
}, 'setCapabilityState must not throw when an intermediate config node is null');
|
|
|
|
assert.ok(result.errors.length === 0,
|
|
`expected no errors, got: ${JSON.stringify(result.errors)}`);
|
|
|
|
// Config should have workflow.code_review=false (null replaced with object)
|
|
const config = readConfig(cwd);
|
|
assert.equal(
|
|
config?.workflow?.code_review,
|
|
false,
|
|
'workflow.code_review must be false after gate write into null-intermediate config',
|
|
);
|
|
} finally {
|
|
cleanup(rcd);
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
});
|