Files
msd-core/tests/bug-3245-codex-toml-floats.test.cjs
Tom Boucher deeb6deb67 fix(install): accept Codex TOML floats; idempotent rollback (#3245) (#3254)
* test: reproduce extractFrontmatter LAST-block bug (#3240)

* test: reproduce state.update progress trampling and percent formula (#3242)

Two failing regression tests:
- Bug A: state.update "Last Activity" tramples curated progress.* frontmatter via readModifyWriteStateMd → syncStateFrontmatter
- Bug B: 12 declared ROADMAP phases / 6 realized / 6/6 plans done → percent: 100 instead of 50 (phase-fraction ignored)

* test: reproduce TOML float rejection and partial rollback (#3245)

Two failing regression tests:
1. parseTomlToObject rejects valid Codex TOML floats (tool_timeout_sec = 20.0)
2. Post-install validation failure leaves skills/, agents/, VERSION on disk
   despite restoring config.toml — hybrid state after abort

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(install): accept TOML floats; idempotent codex rollback (#3245)

Two fixes for the Codex install failure introduced by #2760 CR4 finding 3:

1. parseTomlValue now accepts TOML 1.0 float literals (decimals,
   exponents, underscore separators, signed). Codex CLI's serde schema
   requires f64 for tool_timeout_sec / startup_timeout_sec — the prior
   strict-integer-only check was the inverse of what Codex requires,
   causing every config with a float to trigger a fatal schema validation
   failure. Date/time separators (-/:T/Z) are still rejected.

2. restoreCodexSnapshot is extended into a unified idempotent rollback
   that reverts ALL Codex-specific mutations on failure:
   - config.toml (existing behavior)
   - skills/gsd-* directories (new)
   - agents/gsd-*.{md,toml} files (new)
   - get-shit-done/VERSION (new)
   - orphaned atomic-write temp files (new)
   Pre-install state is captured before the first Codex write so the
   rollback reflects the true pre-GSD state. Non-gsd-* user content is
   untouched. The rollback is safe to call multiple times and before any
   snapshots are captured.

Fixes #3245

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* changeset: pr=3254 for #3245

* test: fix source-grep lint violation in bug-3242 test (#3242)

Replace content.includes() check with line-by-line parse of STATE.md body.
The lint enforces structural assertions over raw text matching.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: mark #3242 RED tests as todo pending fix (#3242)

The three failing tests are intentional regression tests for bugs in
state.cjs that will be fixed in a separate PR. Mark them { todo: true }
so they don't block CI on this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(install): tighten TOML underscore placement validation (CR finding 1)

The float regex used [\d_]* which accepts invalid forms like 1__0, 1_.0,
and 1._0. TOML 1.0 §2 requires underscores only between digits. Switch
both the integer pre-check and the full float pattern to (?:_?\d)* so
consecutive underscores, leading underscores on a segment, and trailing
underscores on a segment are all rejected before replace(/_/g,'') can
silently normalize them into valid JS numbers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(install): restore pre-existing gsd-* content on rollback (CR finding 2)

The snapshot only recorded names of pre-existing skills/gsd-* dirs and
agents/gsd-* files. On a failed reinstall the rollback could delete
newly-created dirs but could not restore the bytes of dirs/files that
were overwritten, leaving the user in a hybrid state (old config.toml,
new skill files).

Now snapshot the full file tree of every pre-existing gsd-* skill dir
into codexPreInstallSkillContents (Map<name, Map<relPath, Buffer>>) and
every pre-existing agent file into codexPreInstallAgentContents
(Map<filename, Buffer>). restoreCodexSnapshot() uses these maps to
wipe-and-restore overwritten entries and only removes entries that had
no pre-install state, giving a true atomic rollback guarantee.
Reads are best-effort so a partial snapshot is still better than none.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(install): scope temp-file cleanup to installer-owned writes (CR finding 3)

_cleanTmpFiles() was deleting any *.tmp-<pid>-<n> file found under
targetDir. This is too broad: other tools in the user's Codex/home
directory may create temp files matching the same suffix pattern, and a
GSD install rollback would silently delete them.

Add __atomicWrittenTmps (a module-level Set<string>) populated by
atomicWriteFileSync for every temp path it creates. _cleanTmpFiles()
now checks __atomicWrittenTmps.has(full) before unlinking, so only temp
files this installer process actually wrote are eligible for cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): remove no-op doesNotThrow wrapping try/catch (CR finding 4)

assert.doesNotThrow(() => { try { f(); } catch(_){} }) always passes
because the catch block swallows every exception before the outer
assertion can see it. This meant the rollback-idempotency guarantee was
never actually verified.

Replace with an explicit threw flag around runCodexInstall, assert that
the install did throw (validation failure is expected), and add a
post-rollback state assertion that skills/ was not created. This gives
a loud failure surface if runCodexInstall starts crashing from inside
the rollback path, matching the intent described in the test comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): correct describe title for float-acceptance tests (CR nitpick 1)

The describe block title said 'rejects malformed input that previously
slipped through', but the test inside now asserts that TOML floats are
accepted (the #3245 inversion). This misled readers expecting every
sub-test to assert rejection. Update the title to reflect the mixed
behaviour: floats are accepted; dates and trailing-garbage are rejected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): rename test to match what the assertion actually checks (CR nitpick 2)

The test name 'post-install config retains float literal form (20.0 not
truncated to 20)' promised a string-form invariant, but the assertion
uses numeric equality (assert.strictEqual(parsed.tool_timeout_sec, 20))
which cannot distinguish 20 from 20.0 in JS. Rename to 'post-install
config round-trips tool_timeout_sec as numeric 20' so the description
matches what the test actually verifies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): replace raw text scan with state json assertion (CR nitpick 3)

The 'Last Activity updates the body field' test was reading STATE.md as
raw text, splitting on newlines, and using lines.find/startsWith to
locate the 'Last Activity:' line — the exact pattern-match-on-source
approach prohibited by the no-source-grep testing standard.

Replace with runGsdTools('state json', tmpDir) which surfaces the body-
extracted Last Activity value as fm.last_activity in its JSON output,
and assert against that structured field instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): correct post-rollback state assertion for early-failure case

The previous assertion checked that skills/ didn't exist, but the
installer writes skills/ before the schema validator fires. Rollback
removes gsd-* dirs inside skills/, not skills/ itself. Update the
assertion to verify that no gsd-* skill dirs survive rollback, which
is the actual invariant the test name describes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* changeset: document full rollback scope (CR finding 1)

Adds config.toml restoration and orphaned atomic-write temp-file
cleanup to the changeset description — the previous text only listed
skills/, agents/, and VERSION.

* fix(install): wrap post-snapshot scope in rollback handler (CR finding 2)

Any throw between the pre-install snapshot capture and the Codex config
block (skills copy, agents copy, VERSION write, manifest write, leaked-
path scan, etc.) now triggers _codexPreConfigRollback() so the caller
is never left in a partially-installed state.  Previously only the later
config.toml mutation paths had rollback wired in.

Introduces _codexPreConfigRollback (defined right after snapshot capture)
and wraps the intervening operations in a try/catch that invokes it on
error for Codex installs; non-Codex paths are unaffected.

* test: assert threw=true to prevent vacuous pass (CR finding 4)

Two tests used bare try/catch without asserting threw === true, so they
would silently pass even if runCodexInstall never threw (k060 pattern).
Each bare catch block is replaced with a threw flag and a
strictEqual(threw, true, ...) assertion.

CR findings 2+3 are both addressed in the preceding install commit:
finding 3 (restore from snapshot manifest, not current FS state) lands
alongside the rollback-wrapper change as part of the restoreCodexSnapshot
refactor.

* fix(install): reject leading zeros in TOML float integer part per TOML 1.0 (CR finding round 4)

TOML 1.0 §2 disallows leading zeros in the integer part of numeric
literals — `01`, `00`, `01.5`, `00e2`, `+01.0`, `-01.0` are all invalid.
The pre-check and float regexes in parseTomlValue used `\d(?:_?\d)*` which
accepted any digit as the leading digit.

Both regexes are tightened to `(0|[1-9](?:_?\d)*)` for the integer part:
- `0` alone is valid
- a non-zero leading digit followed by optional underscored digits is valid
- `01`, `00`, and any variant with a leading zero and further digits is rejected

The "still rejects bare time (07:32:00)" test assertion is broadened from
`/unsupported TOML value/` to `/unsupported TOML value|trailing bytes/`
because the parser now stops at `0` and the remainder `7:32:00` is rejected
as trailing bytes — the invariant (time literals are not accepted) is unchanged.

25 new regression tests cover all rejection cases and valid TOML forms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:25:59 -04:00

508 lines
20 KiB
JavaScript

/**
* Regression: issue #3245 — Codex install rejects valid TOML floats.
*
* Two defects, two fixes:
*
* Defect 1 — parseTomlValue rejects TOML floats (e.g. tool_timeout_sec = 20.0).
* Codex CLI's serde schema requires f64 for tool_timeout_sec / startup_timeout_sec
* (integers fail with "invalid type: integer"). GSD's strict-integer-only parser
* was the inverse of what Codex requires — any float triggers the rejection branch.
* Fix: extend parseTomlValue to accept TOML 1.0 float literals and return them as
* JS Number. The merged config.toml preserves the float form verbatim so
* round-trip writes don't coerce 20.0 → 20.
*
* Defect 2 — Partial rollback leaves install in hybrid state.
* restoreCodexSnapshot only knew about config.toml, but skills/, agents/, and VERSION
* are written earlier in the install sequence. A post-install validation failure
* aborts with new agent text on disk, config.toml reverted, and .tmp files
* potentially orphaned.
* Fix: capture pre-install state of skills/, agents/, and VERSION before any
* Codex-specific mutation, and extend the rollback to cover all of them.
*/
// GSD_TEST_MODE must be set before require('../bin/install.js') so the module
// skips the main CLI entry point and exports its internals.
const previousGsdTestMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
const { test, describe, 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 { parseTomlToObject, validateCodexConfigSchema, install } = require('../bin/install.js');
const installModule = require('../bin/install.js');
if (previousGsdTestMode === undefined) {
delete process.env.GSD_TEST_MODE;
} else {
process.env.GSD_TEST_MODE = previousGsdTestMode;
}
// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs.
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
before(() => {
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
}
});
function runCodexInstall(codexHome) {
const previousCodexHome = process.env.CODEX_HOME;
const previousCwd = process.cwd();
process.env.CODEX_HOME = codexHome;
try {
process.chdir(path.join(__dirname, '..'));
return install(true, 'codex');
} finally {
process.chdir(previousCwd);
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = previousCodexHome;
}
}
}
function writeCodexConfig(codexHome, content) {
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
}
// ---------------------------------------------------------------------------
// Defect 1 — parseTomlValue must accept TOML floats
// ---------------------------------------------------------------------------
describe('#3245 — parseTomlToObject accepts TOML floats', () => {
test('parses bare decimal float (20.0)', () => {
const content = [
'tool_timeout_sec = 20.0',
'',
].join('\n');
const parsed = parseTomlToObject(content);
assert.strictEqual(typeof parsed.tool_timeout_sec, 'number',
'tool_timeout_sec should be a JS number');
assert.strictEqual(parsed.tool_timeout_sec, 20.0,
'value must equal 20.0');
});
test('parses startup_timeout_sec = 60.0', () => {
const content = [
'startup_timeout_sec = 60.0',
'',
].join('\n');
const parsed = parseTomlToObject(content);
assert.strictEqual(parsed.startup_timeout_sec, 60.0);
});
test('parses positive exponent notation (1e10)', () => {
const content = [
'x = 1e10',
'',
].join('\n');
const parsed = parseTomlToObject(content);
assert.strictEqual(parsed.x, 1e10);
});
test('parses negative exponent (1.5e-3)', () => {
const content = [
'x = 1.5e-3',
'',
].join('\n');
const parsed = parseTomlToObject(content);
assert.ok(Math.abs(parsed.x - 1.5e-3) < 1e-15, 'must be approximately 1.5e-3');
});
test('parses signed positive float (+1.0)', () => {
const content = [
'x = +1.0',
'',
].join('\n');
const parsed = parseTomlToObject(content);
assert.strictEqual(parsed.x, 1.0);
});
test('parses signed negative float (-0.5)', () => {
const content = [
'x = -0.5',
'',
].join('\n');
const parsed = parseTomlToObject(content);
assert.strictEqual(parsed.x, -0.5);
});
test('parses float with underscore separators (1_000.0)', () => {
const content = [
'x = 1_000.0',
'',
].join('\n');
const parsed = parseTomlToObject(content);
assert.strictEqual(parsed.x, 1000.0);
});
test('integer (no decimal) still parses as integer', () => {
const content = [
'x = 42',
'',
].join('\n');
const parsed = parseTomlToObject(content);
assert.strictEqual(parsed.x, 42);
});
test('still rejects bare date (1979-05-27)', () => {
const content = [
'x = 1979-05-27',
'',
].join('\n');
assert.throws(
() => parseTomlToObject(content),
/unsupported TOML value/,
'date literals must remain unsupported'
);
});
test('still rejects bare time (07:32:00)', () => {
const content = [
'x = 07:32:00',
'',
].join('\n');
// With leading-zero rejection (CR4 fix) the parser stops at `0`, and
// `7:32:00` is "trailing bytes". Either error form is acceptable — the
// key invariant is that time literals are never silently accepted.
assert.throws(
() => parseTomlToObject(content),
/unsupported TOML value|trailing bytes/,
'time literals must remain unsupported'
);
});
test('still rejects hex literal (0x1A)', () => {
const content = [
'x = 0x1A',
'',
].join('\n');
// 0 is parsed, then 'x1A' is trailing garbage — rejected with "trailing bytes"
// or "unsupported value" depending on where the parser catches it.
assert.throws(
() => parseTomlToObject(content),
/trailing bytes|unsupported (TOML value|value)/,
'hex literals must remain unsupported'
);
});
test('validateCodexConfigSchema passes a config with tool_timeout_sec = 20.0', () => {
const content = [
'[model]',
'name = "o3"',
'',
'tool_timeout_sec = 20.0',
'startup_timeout_sec = 60.0',
'',
].join('\n');
const result = validateCodexConfigSchema(content);
assert.strictEqual(result.ok, true,
'schema validation must pass for a config containing TOML floats: ' + result.reason);
});
});
// ---------------------------------------------------------------------------
// Defect 1 — full install must succeed and preserve float verbatim
// ---------------------------------------------------------------------------
// concurrency: false — drives the live install pipeline (shared CODEX_HOME env,
// process.chdir). Serialise to prevent stray mutations across parallel siblings.
describe('#3245 — install succeeds with TOML float in pre-existing config', { concurrency: false }, () => {
let tmpDir;
let codexHome;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-float-'));
codexHome = path.join(tmpDir, 'codex-home');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('install completes when config.toml contains tool_timeout_sec = 20.0', () => {
// Floats at the root level (before any table header) — this is where Codex
// CLI reads tool_timeout_sec / startup_timeout_sec according to its serde schema.
const preInstall = [
'tool_timeout_sec = 20.0',
'startup_timeout_sec = 60.0',
'',
'[model]',
'name = "o3"',
'',
].join('\n');
writeCodexConfig(codexHome, preInstall);
// Must not throw — pre-#3245 this threw "unsupported TOML value … floats … not supported".
assert.doesNotThrow(
() => runCodexInstall(codexHome),
'install must not throw when config.toml contains TOML floats'
);
// The merged config.toml must still contain the float values at root scope.
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
const parsed = parseTomlToObject(after);
assert.strictEqual(parsed.tool_timeout_sec, 20.0,
'tool_timeout_sec must be preserved as a number after install');
assert.strictEqual(parsed.startup_timeout_sec, 60.0,
'startup_timeout_sec must be preserved as a number after install');
});
test('post-install config round-trips tool_timeout_sec as numeric 20', () => {
const preInstall = [
'tool_timeout_sec = 20.0',
'',
].join('\n');
writeCodexConfig(codexHome, preInstall);
runCodexInstall(codexHome);
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
// The value must survive round-trip as a float-compatible representation.
// Parse structurally — don't grep for the literal string "20.0".
const parsed = parseTomlToObject(after);
assert.strictEqual(parsed.tool_timeout_sec, 20,
'tool_timeout_sec must round-trip as numeric 20 (=== 20.0 in JS)');
});
});
// ---------------------------------------------------------------------------
// CR round-4 finding — TOML 1.0 disallows leading zeros in integer part
// ---------------------------------------------------------------------------
//
// TOML 1.0 §2: integer literals follow decimal-integer rules, which disallow
// leading zeros except the value `0` itself. `01`, `01.5`, `00e2`, `+01.0`
// are therefore invalid. The `parseTomlValue` integer-part regex is tightened
// from `\d(?:_?\d)*` to `(0|[1-9](?:_?\d)*)`.
describe('#3245 CR4 — parseTomlValue rejects leading zeros in float integer part', () => {
function parseValue(raw) {
// Wrap in a minimal TOML assignment so parseTomlToObject drives the test.
return parseTomlToObject(`x = ${raw}`).x;
}
function assertRejects(raw, label) {
let threw = false;
try { parseValue(raw); } catch (_) { threw = true; }
assert.strictEqual(threw, true, `expected rejection for ${label}: ${raw}`);
}
function assertAccepts(raw, expected, label) {
let val;
let threw = false;
try { val = parseValue(raw); } catch (e) { threw = true; }
assert.strictEqual(threw, false, `expected acceptance for ${label}: ${raw}`);
if (expected !== undefined) {
assert.ok(Math.abs(val - expected) < 1e-12, `${label}: expected ${expected}, got ${val}`);
}
}
// --- rejection cases: leading zeros in the integer part ---
test('rejects 01 (leading zero on bare integer)', () => assertRejects('01', '01'));
test('rejects 00 (double-zero bare integer)', () => assertRejects('00', '00'));
test('rejects 01.5 (leading zero before decimal point)', () => assertRejects('01.5', '01.5'));
test('rejects 00.5 (double-zero before decimal)', () => assertRejects('00.5', '00.5'));
test('rejects +01 (leading zero with sign)', () => assertRejects('+01', '+01'));
test('rejects -01 (negative leading zero)', () => assertRejects('-01', '-01'));
test('rejects 00e2 (leading zero with exponent)', () => assertRejects('00e2', '00e2'));
test('rejects +01.0 (leading zero in positive float)', () => assertRejects('+01.0', '+01.0'));
test('rejects -01.0 (leading zero in negative float)', () => assertRejects('-01.0', '-01.0'));
test('rejects 01.5e10 (leading zero, decimal, and exponent)', () => assertRejects('01.5e10', '01.5e10'));
// --- acceptance cases: valid TOML 1.0 numeric forms ---
test('accepts 0 (single zero)', () => assertAccepts('0', 0, 'single zero'));
test('accepts 0.5 (zero before decimal)', () => assertAccepts('0.5', 0.5, 'zero.decimal'));
test('accepts 0.0 (zero.zero)', () => assertAccepts('0.0', 0.0, 'zero.zero'));
test('accepts 0e1 (zero with exponent)', () => assertAccepts('0e1', 0, '0e1'));
test('accepts +0.5 (positive zero-decimal)', () => assertAccepts('+0.5', 0.5, '+0.5'));
test('accepts -0.5 (negative zero-decimal)', () => assertAccepts('-0.5', -0.5, '-0.5'));
test('accepts 1 (single non-zero digit)', () => assertAccepts('1', 1, '1'));
test('accepts 12 (two digits)', () => assertAccepts('12', 12, '12'));
test('accepts 1.5 (simple float)', () => assertAccepts('1.5', 1.5, '1.5'));
test('accepts 1_000 (underscored integer)', () => assertAccepts('1_000', 1000, '1_000'));
test('accepts 1_000.5 (underscored float)', () => assertAccepts('1_000.5', 1000.5, '1_000.5'));
test('accepts +1.5 (positive float)', () => assertAccepts('+1.5', 1.5, '+1.5'));
test('accepts -2.0 (negative float)', () => assertAccepts('-2.0', -2.0, '-2.0'));
test('accepts 1.5e-3 (float with negative exponent)', () => assertAccepts('1.5e-3', 1.5e-3, '1.5e-3'));
test('accepts 1.05e10 (fractional part may start with zero)', () => assertAccepts('1.05e10', 1.05e10, '1.05e10'));
});
// ---------------------------------------------------------------------------
// Defect 2 — idempotent rollback covers skills, agents, VERSION
// ---------------------------------------------------------------------------
// concurrency: false — patches module.exports.__codexSchemaValidator and drives
// the install pipeline. Serialise to prevent cross-test pollution.
describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', { concurrency: false }, () => {
let tmpDir;
let codexHome;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-rollback-'));
codexHome = path.join(tmpDir, 'codex-home');
});
afterEach(() => {
delete installModule.__codexSchemaValidator;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('validation failure rolls back skills/, agents/, and VERSION to pre-install state', () => {
// Start from a clean codexHome with no pre-existing GSD content — the dirs
// do not exist yet. After a failed install they must be absent (or contain
// only what was there before, i.e. nothing).
fs.mkdirSync(codexHome, { recursive: true });
// Force schema validation to fail so we can observe the rollback without
// needing a genuinely broken config.
installModule.__codexSchemaValidator = () => ({
ok: false,
reason: 'simulated failure for #3245 rollback test',
});
let threw = false;
try {
runCodexInstall(codexHome);
} catch (_) {
threw = true;
}
assert.strictEqual(threw, true, 'install must throw when validation fails');
// skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback.
const skillsDir = path.join(codexHome, 'skills');
if (fs.existsSync(skillsDir)) {
const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.strictEqual(
gsdSkills.length,
0,
'rollback must remove all gsd-* skill directories: ' + gsdSkills.map(e => e.name).join(', ')
);
}
// agents/ — GSD writes gsd-*.md and gsd-*.toml here. All must be absent.
const agentsDir = path.join(codexHome, 'agents');
if (fs.existsSync(agentsDir)) {
const gsdAgents = fs.readdirSync(agentsDir)
.filter(f => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')));
assert.strictEqual(
gsdAgents.length,
0,
'rollback must remove all gsd-* agent files: ' + gsdAgents.join(', ')
);
}
// VERSION — GSD writes get-shit-done/VERSION. Must be absent (wasn't there before).
const versionPath = path.join(codexHome, 'get-shit-done', 'VERSION');
assert.strictEqual(
fs.existsSync(versionPath),
false,
'rollback must remove the VERSION file written during install'
);
});
test('rollback is safe when fired before any snapshots were captured (very early failure)', () => {
// If the validator is injected before ANY install writes happen, the rollback
// must not throw — it should be idempotent when nothing was written yet.
fs.mkdirSync(codexHome, { recursive: true });
installModule.__codexSchemaValidator = () => ({
ok: false,
reason: 'very early simulated failure',
});
// The install must throw (validation failure), but the rollback that runs
// internally must not throw — it must be idempotent when nothing was written.
let threw = false;
try {
runCodexInstall(codexHome);
} catch (_) {
threw = true;
}
assert.strictEqual(threw, true, 'install must throw when validation fails (very early failure)');
// Rollback removes all gsd-* skill dirs it wrote. Even if skills/ was
// created during the install, no gsd-* dirs should survive after rollback.
const skillsDir = path.join(codexHome, 'skills');
const remainingGsdSkills = fs.existsSync(skillsDir)
? fs.readdirSync(skillsDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'))
.map((e) => e.name)
: [];
assert.deepStrictEqual(
remainingGsdSkills,
[],
'rollback must remove all gsd-* skill dirs even when fired after minimal writes'
);
});
test('rollback does not remove pre-existing user skills that GSD did not write', () => {
// If the user has a custom skill dir (not gsd-*) it must survive rollback.
const skillsDir = path.join(codexHome, 'skills');
const userSkill = path.join(skillsDir, 'my-custom-skill');
fs.mkdirSync(userSkill, { recursive: true });
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# Custom\n', 'utf8');
installModule.__codexSchemaValidator = () => ({
ok: false,
reason: 'simulated failure — user skill must survive',
});
let threw = false;
try { runCodexInstall(codexHome); } catch (_) { threw = true; }
assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (user-skill-survives scenario)');
assert.strictEqual(
fs.existsSync(path.join(userSkill, 'SKILL.md')),
true,
'pre-existing non-gsd-* skill must survive rollback'
);
});
test('rollback removes orphaned atomic-write temp files', () => {
// Any <file>.tmp-<pid>-<n> files created during aborted atomic writes
// must be cleaned up by the rollback so targetDir is not left with stray
// temp files consuming disk space.
fs.mkdirSync(codexHome, { recursive: true });
installModule.__codexSchemaValidator = () => ({
ok: false,
reason: 'simulated failure for temp-file cleanup test',
});
let threw = false;
try { runCodexInstall(codexHome); } catch (_) { threw = true; }
assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (temp-file cleanup scenario)');
// Scan for any *.tmp-* files left in codexHome after rollback.
const tmpPattern = /\.tmp-\d+-\d+$/;
function findTmpFiles(dir) {
if (!fs.existsSync(dir)) return [];
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findTmpFiles(full));
} else if (tmpPattern.test(entry.name)) {
results.push(full);
}
}
return results;
}
const stray = findTmpFiles(codexHome);
assert.strictEqual(
stray.length,
0,
'rollback must clean up orphaned atomic-write temp files: ' + stray.join(', ')
);
});
});