mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
Reject directory replacement after temporary file creation or staging, preserve unrelated files during cleanup, and retry settings edits observed before the final rename. Add three regression tests for the review findings.
767 lines
29 KiB
JavaScript
767 lines
29 KiB
JavaScript
/**
|
|
* Focused coverage for safely managing ECC hook entries in Claude settings.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const {
|
|
runWithSettingsLock,
|
|
materializeManagedHooks,
|
|
inspectManagedHooks,
|
|
mergeManagedHooks,
|
|
parseSettings,
|
|
readSettings,
|
|
repairManagedHooks,
|
|
replacePluginRootPlaceholders,
|
|
uninstallManagedHooks,
|
|
updateSettingsAtomic,
|
|
validateManagedHooks,
|
|
} = require('../../scripts/lib/install/claude-settings');
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` PASS ${name}`);
|
|
return true;
|
|
} catch (error) {
|
|
console.log(` FAIL ${name}`);
|
|
console.log(` Error: ${error.stack || error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function entry(id, command, extra = {}) {
|
|
return {
|
|
matcher: '.*',
|
|
hooks: [{ type: 'command', command }],
|
|
id,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function clone(value) {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
function assertAtomicParentReplacementRejected(stage) {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-parent-race-'));
|
|
const targetRoot = path.join(tempDir, 'target');
|
|
const parkedRoot = path.join(tempDir, 'parked');
|
|
const victimRoot = path.join(tempDir, 'victim');
|
|
const settingsPath = path.join(targetRoot, 'settings.json');
|
|
const victimPath = path.join(victimRoot, 'settings.json');
|
|
const originalOpen = fs.openSync;
|
|
const originalFsync = fs.fsyncSync;
|
|
const targetContent = '{"target":true}\n';
|
|
const victimContent = '{"victim":"preserve"}\n';
|
|
let tempDescriptor;
|
|
let tempBasename;
|
|
let replaced = false;
|
|
const replaceParent = () => {
|
|
replaced = true;
|
|
fs.renameSync(targetRoot, parkedRoot);
|
|
fs.symlinkSync(victimRoot, targetRoot, process.platform === 'win32' ? 'junction' : 'dir');
|
|
// A colliding path in the replacement directory must survive error cleanup.
|
|
fs.writeFileSync(path.join(victimRoot, tempBasename), 'unrelated replacement file');
|
|
};
|
|
try {
|
|
fs.mkdirSync(targetRoot);
|
|
fs.mkdirSync(victimRoot);
|
|
fs.writeFileSync(settingsPath, targetContent);
|
|
fs.writeFileSync(victimPath, victimContent);
|
|
fs.openSync = function(file, flags, ...args) {
|
|
const isTemp = typeof file === 'string'
|
|
&& path.basename(file).startsWith('.settings.json.') && file.endsWith('.tmp');
|
|
if (isTemp) tempBasename = path.basename(file);
|
|
if (isTemp && !replaced && stage === 'open') {
|
|
// Replace immediately after the temporary descriptor has been created.
|
|
const descriptor = originalOpen.call(fs, file, flags, ...args);
|
|
tempDescriptor = descriptor;
|
|
replaceParent();
|
|
return descriptor;
|
|
}
|
|
const descriptor = originalOpen.call(fs, file, flags, ...args);
|
|
if (isTemp) tempDescriptor = descriptor;
|
|
return descriptor;
|
|
};
|
|
fs.fsyncSync = function(descriptor) {
|
|
const result = originalFsync.call(fs, descriptor);
|
|
if (!replaced && stage === 'rename' && descriptor === tempDescriptor) replaceParent();
|
|
return result;
|
|
};
|
|
assert.throws(
|
|
() => updateSettingsAtomic(settingsPath, settings => ({ settings: { ...settings, managed: true } })),
|
|
/parent.*changed|changed.*parent/i
|
|
);
|
|
assert.ok(replaced, 'must exercise a replacement inside the atomic writer');
|
|
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), victimContent);
|
|
assert.strictEqual(fs.readFileSync(path.join(parkedRoot, 'settings.json'), 'utf8'), targetContent);
|
|
assert.strictEqual(fs.readFileSync(path.join(victimRoot, tempBasename), 'utf8'), 'unrelated replacement file');
|
|
} finally {
|
|
fs.openSync = originalOpen;
|
|
fs.fsyncSync = originalFsync;
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function runTests() {
|
|
console.log('\n=== Testing install/claude-settings.js ===\n');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
if (test('validates and clones a managed hook map without mutating it', () => {
|
|
const managed = {
|
|
SessionStart: [entry('session:start', 'node start.js')],
|
|
Stop: [entry('session:stop', 'node stop.js')],
|
|
};
|
|
const validated = validateManagedHooks(managed);
|
|
|
|
assert.deepStrictEqual(validated, managed);
|
|
assert.notStrictEqual(validated, managed);
|
|
assert.notStrictEqual(validated.SessionStart[0], managed.SessionStart[0]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('strictly rejects invalid managed hook maps and globally duplicate ids', () => {
|
|
const invalidValues = [
|
|
null,
|
|
[],
|
|
{},
|
|
{ SessionStart: [] },
|
|
{ SessionStart: {} },
|
|
{ SessionStart: [null] },
|
|
{ SessionStart: [[]] },
|
|
{ SessionStart: [{}] },
|
|
{ SessionStart: [{ id: ' ' }] },
|
|
{ BogusEvent: [entry('bad:event', 'bad')] },
|
|
{ SessionStart: [{ id: 'missing:hooks', matcher: '.*' }] },
|
|
{ SessionStart: [{ id: 'bad:command', matcher: '.*', hooks: [{ type: 'command' }] }] },
|
|
];
|
|
|
|
for (const invalid of invalidValues) {
|
|
assert.throws(() => validateManagedHooks(invalid), /managed hooks|hook entry|unique id/i);
|
|
}
|
|
assert.throws(
|
|
() => validateManagedHooks({
|
|
Stop: [entry('shared', 'a')],
|
|
SubagentStop: [entry('shared', 'b')],
|
|
}),
|
|
/expected globally unique id "shared"/
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('materializes hook roots and rejects unresolved environment references', () => {
|
|
const source = {
|
|
hooks: {
|
|
Stop: [entry(
|
|
'ecc:stop',
|
|
'var e=process.env.CLAUDE_PLUGIN_ROOT; '
|
|
+ 'process.env.CLAUDE_PLUGIN_ROOT=r; ${CLAUDE_PLUGIN_ROOT}'
|
|
)],
|
|
},
|
|
};
|
|
const before = clone(source);
|
|
const materialized = materializeManagedHooks(source, '/opt/ecc');
|
|
const command = materialized.Stop[0].hooks[0].command;
|
|
const encodedRoot = command.match(/Buffer\.from\('([^']+)','base64'\)/)[1];
|
|
|
|
assert.deepStrictEqual(source, before);
|
|
assert.ok(!command.includes('var e=process.env.CLAUDE_PLUGIN_ROOT;'));
|
|
assert.ok(!command.includes('${CLAUDE_PLUGIN_ROOT}'));
|
|
assert.strictEqual(Buffer.from(encodedRoot, 'base64').toString('utf8'), '/opt/ecc');
|
|
assert.throws(() => materializeManagedHooks({}, '/opt/ecc'), /hooks object/);
|
|
assert.throws(() => materializeManagedHooks(source, ''), /target root/);
|
|
assert.throws(
|
|
() => materializeManagedHooks({
|
|
hooks: {
|
|
Stop: [entry('ecc:stop', 'node -e "const e=process.env.CLAUDE_PLUGIN_ROOT"')],
|
|
},
|
|
}, '/opt/ecc'),
|
|
/Unable to resolve CLAUDE_PLUGIN_ROOT/
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('replaces every plugin-root placeholder recursively and immutably', () => {
|
|
const source = {
|
|
SessionStart: [{
|
|
id: 'session:start',
|
|
command: '${CLAUDE_PLUGIN_ROOT}/start.js:${CLAUDE_PLUGIN_ROOT}',
|
|
nested: ['${CLAUDE_PLUGIN_ROOT}/nested.js', 3, null],
|
|
}],
|
|
};
|
|
const before = clone(source);
|
|
|
|
const resolved = replacePluginRootPlaceholders(source, '/opt/ecc');
|
|
|
|
assert.deepStrictEqual(source, before);
|
|
assert.deepStrictEqual(resolved, {
|
|
SessionStart: [{
|
|
id: 'session:start',
|
|
command: '/opt/ecc/start.js:/opt/ecc',
|
|
nested: ['/opt/ecc/nested.js', 3, null],
|
|
}],
|
|
});
|
|
})) passed++; else failed++;
|
|
|
|
if (test('parseSettings accepts an object and validates every hooks event array', () => {
|
|
assert.deepStrictEqual(
|
|
parseSettings('{"theme":"dark","hooks":{"Stop":[]}}', 'memory settings'),
|
|
{ theme: 'dark', hooks: { Stop: [] } }
|
|
);
|
|
assert.throws(() => parseSettings('{', 'memory settings'), /Failed to parse memory settings/);
|
|
assert.throws(() => parseSettings('null', 'memory settings'), /expected a JSON object/);
|
|
assert.throws(() => parseSettings('[]', 'memory settings'), /expected a JSON object/);
|
|
assert.throws(
|
|
() => parseSettings('{"hooks":{"Stop":{}}}', 'memory settings'),
|
|
/hooks\.Stop.*array/
|
|
);
|
|
assert.throws(
|
|
() => parseSettings('{"hooks":[]}', 'memory settings'),
|
|
/"hooks".*object/
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readSettings returns an empty object for ENOENT and rejects bad files', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-'));
|
|
try {
|
|
assert.deepStrictEqual(readSettings(path.join(tempDir, 'missing.json')), {});
|
|
|
|
const malformedPath = path.join(tempDir, 'malformed.json');
|
|
fs.writeFileSync(malformedPath, '{', 'utf8');
|
|
assert.throws(() => readSettings(malformedPath), /Failed to parse Claude settings/);
|
|
|
|
const invalidPath = path.join(tempDir, 'invalid.json');
|
|
fs.writeFileSync(invalidPath, '{"hooks":{"Stop":false}}', 'utf8');
|
|
assert.throws(() => readSettings(invalidPath), /hooks\.Stop.*array/);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readSettings propagates non-ENOENT read errors without converting them to empty settings', () => {
|
|
const denied = new Error('denied');
|
|
denied.code = 'EACCES';
|
|
assert.throws(
|
|
() => readSettings('/private/settings.json', {
|
|
readFileSync() {
|
|
throw denied;
|
|
},
|
|
}),
|
|
error => error === denied
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('atomic settings updates retry after a concurrent change and preserve secure mode', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-atomic-'));
|
|
const settingsPath = path.join(tempDir, 'settings.json');
|
|
let commitAttempts = 0;
|
|
try {
|
|
const result = updateSettingsAtomic(
|
|
settingsPath,
|
|
settings => ({ settings: { ...settings, managed: true } }),
|
|
{
|
|
beforeCommit() {
|
|
commitAttempts += 1;
|
|
if (commitAttempts === 1) {
|
|
fs.writeFileSync(settingsPath, '{"theme":"concurrent"}\n', { mode: 0o600 });
|
|
}
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.deepStrictEqual(result.settings, { theme: 'concurrent', managed: true });
|
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), result.settings);
|
|
assert.strictEqual(commitAttempts, 2);
|
|
if (process.platform !== 'win32') {
|
|
assert.strictEqual(fs.statSync(settingsPath).mode & 0o777, 0o600);
|
|
}
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
for (const stage of ['open', 'rename']) {
|
|
if (test(`atomic settings updates reject parent replacement at ${stage} without touching its files`, () => {
|
|
assertAtomicParentReplacementRejected(stage);
|
|
})) passed++; else failed++;
|
|
}
|
|
|
|
if (test('atomic settings updates preserve edits made while the replacement file is staged', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-late-edit-'));
|
|
const settingsPath = path.join(tempDir, 'settings.json');
|
|
const originalFsync = fs.fsyncSync;
|
|
let changed = false;
|
|
let fsyncCalls = 0;
|
|
try {
|
|
fs.writeFileSync(settingsPath, '{"theme":"initial"}\n');
|
|
fs.fsyncSync = function(descriptor) {
|
|
const result = originalFsync.call(fs, descriptor);
|
|
// Lock creation is the first fsync; only change settings after the
|
|
// atomic writer has staged its first replacement payload.
|
|
fsyncCalls += 1;
|
|
if (!changed && fsyncCalls === 2) {
|
|
changed = true;
|
|
fs.writeFileSync(settingsPath, '{"theme":"late-edit"}\n');
|
|
}
|
|
return result;
|
|
};
|
|
updateSettingsAtomic(settingsPath, settings => ({ settings: { ...settings, managed: true } }));
|
|
assert.ok(changed);
|
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { theme: 'late-edit', managed: true });
|
|
} finally {
|
|
fs.fsyncSync = originalFsync;
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('atomic settings updates recover a stale invalid lock after its lease', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-stale-lock-'));
|
|
const settingsPath = path.join(tempDir, 'settings.json');
|
|
const lockPath = `${settingsPath}.ecc.lock`;
|
|
try {
|
|
fs.writeFileSync(lockPath, '', { mode: 0o600 });
|
|
const stale = new Date(Date.now() - (10 * 60 * 1000));
|
|
fs.utimesSync(lockPath, stale, stale);
|
|
updateSettingsAtomic(
|
|
settingsPath,
|
|
settings => ({ settings: { ...settings, recovered: true } })
|
|
);
|
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), {
|
|
recovered: true,
|
|
});
|
|
assert.ok(!fs.existsSync(lockPath));
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('atomic settings updates serialize nested ECC writers and release the lock', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-lock-'));
|
|
const settingsPath = path.join(tempDir, 'settings.json');
|
|
const lockPath = `${settingsPath}.ecc.lock`;
|
|
try {
|
|
updateSettingsAtomic(settingsPath, settings => {
|
|
assert.throws(
|
|
() => updateSettingsAtomic(
|
|
settingsPath,
|
|
nested => ({ settings: { ...nested, nested: true } })
|
|
),
|
|
/Another ECC process is updating Claude settings/
|
|
);
|
|
return { settings: { ...settings, outer: true } };
|
|
});
|
|
|
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), {
|
|
outer: true,
|
|
});
|
|
assert.ok(!fs.existsSync(lockPath));
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('atomic settings updates honor an already-held lock', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-lock-held-'));
|
|
const settingsPath = path.join(tempDir, 'settings.json');
|
|
const lockPath = `${settingsPath}.ecc.lock`;
|
|
try {
|
|
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid }), { mode: 0o600 });
|
|
updateSettingsAtomic(
|
|
settingsPath,
|
|
settings => ({ settings: { ...settings, held: true } }),
|
|
{ lockHeld: true }
|
|
);
|
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { held: true });
|
|
assert.ok(fs.existsSync(lockPath));
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('settings lock release failures do not replace the primary update error', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-release-error-'));
|
|
const settingsPath = path.join(tempDir, 'settings.json');
|
|
const lockPath = `${settingsPath}.ecc.lock`;
|
|
try {
|
|
let caught;
|
|
try {
|
|
runWithSettingsLock(settingsPath, () => {
|
|
fs.rmSync(lockPath, { force: true });
|
|
throw new Error('primary settings failure');
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
assert.ok(caught);
|
|
assert.strictEqual(caught.message, 'primary settings failure');
|
|
assert.ok(caught.releaseError);
|
|
assert.strictEqual(caught.releaseError.code, 'ENOENT');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('atomic settings updates refuse a symlinked destination', () => {
|
|
if (process.platform === 'win32') {
|
|
console.log(' (file symlink support is environment-dependent on Windows; skipping)');
|
|
return;
|
|
}
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-symlink-'));
|
|
const realPath = path.join(tempDir, 'real.json');
|
|
const settingsPath = path.join(tempDir, 'settings.json');
|
|
try {
|
|
fs.writeFileSync(realPath, '{"theme":"dark"}\n', { mode: 0o600 });
|
|
fs.symlinkSync(realPath, settingsPath);
|
|
assert.throws(
|
|
() => updateSettingsAtomic(
|
|
settingsPath,
|
|
settings => ({ settings: { ...settings, managed: true } })
|
|
),
|
|
error => error.code === 'ELOOP' || error.code === 'ECC_SETTINGS_CHANGED'
|
|
);
|
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(realPath, 'utf8')), { theme: 'dark' });
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('fresh merge appends managed entries while preserving unrelated settings and hooks', () => {
|
|
const userEntry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'user-hook' }] };
|
|
const settings = {
|
|
theme: 'dark',
|
|
hooks: {
|
|
SessionStart: [userEntry],
|
|
Notification: [{ id: 'user:notification', command: 'notify' }],
|
|
},
|
|
};
|
|
const managed = {
|
|
SessionStart: [entry('ecc:start', 'node /opt/ecc/start.js')],
|
|
Stop: [entry('ecc:stop', 'node /opt/ecc/stop.js')],
|
|
};
|
|
const settingsBefore = clone(settings);
|
|
const managedBefore = clone(managed);
|
|
|
|
const result = mergeManagedHooks(settings, managed);
|
|
|
|
assert.deepStrictEqual(settings, settingsBefore);
|
|
assert.deepStrictEqual(managed, managedBefore);
|
|
assert.deepStrictEqual(result.settings, {
|
|
theme: 'dark',
|
|
hooks: {
|
|
SessionStart: [userEntry, managed.SessionStart[0]],
|
|
Notification: settings.hooks.Notification,
|
|
Stop: managed.Stop,
|
|
},
|
|
});
|
|
assert.deepStrictEqual(result.added, [
|
|
{ event: 'SessionStart', id: 'ecc:start' },
|
|
{ event: 'Stop', id: 'ecc:stop' },
|
|
]);
|
|
assert.deepStrictEqual(result.updated, []);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('fresh merge treats a different entry with the same event and id as a conflict', () => {
|
|
const settings = {
|
|
hooks: {
|
|
Stop: [entry('ecc:stop', 'user-modified')],
|
|
},
|
|
};
|
|
const managed = {
|
|
Stop: [entry('ecc:stop', 'managed')],
|
|
};
|
|
|
|
assert.throws(
|
|
() => mergeManagedHooks(settings, managed),
|
|
/Refusing to overwrite.*Stop.*ecc:stop/
|
|
);
|
|
assert.deepStrictEqual(settings.hooks.Stop[0], entry('ecc:stop', 'user-modified'));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('fresh merge adopts an identical existing event and id without duplicating it', () => {
|
|
const managed = { Stop: [entry('ecc:stop', 'managed')] };
|
|
const result = mergeManagedHooks({ hooks: clone(managed) }, managed);
|
|
|
|
assert.deepStrictEqual(result.settings.hooks.Stop, managed.Stop);
|
|
assert.deepStrictEqual(result.unchanged, [{ event: 'Stop', id: 'ecc:stop' }]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('upgrade replaces an entry only while it still equals previous managed content', () => {
|
|
const previousManagedHooks = { Stop: [entry('ecc:stop', 'version-1')] };
|
|
const managedHooks = { Stop: [entry('ecc:stop', 'version-2')] };
|
|
const result = mergeManagedHooks(
|
|
{ hooks: { Stop: [entry('ecc:stop', 'version-1')] } },
|
|
managedHooks,
|
|
{ previousManagedHooks }
|
|
);
|
|
|
|
assert.deepStrictEqual(result.settings.hooks.Stop, managedHooks.Stop);
|
|
assert.deepStrictEqual(result.updated, [{ event: 'Stop', id: 'ecc:stop' }]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('upgrade fails closed when previous managed content has drifted', () => {
|
|
const settings = { hooks: { Stop: [entry('ecc:stop', 'customer-edit')] } };
|
|
const before = clone(settings);
|
|
|
|
assert.throws(
|
|
() => mergeManagedHooks(
|
|
settings,
|
|
{ Stop: [entry('ecc:stop', 'version-2')] },
|
|
{ previousManagedHooks: { Stop: [entry('ecc:stop', 'version-1')] } }
|
|
),
|
|
/drifted|Refusing to overwrite/
|
|
);
|
|
assert.deepStrictEqual(settings, before);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('upgrade is idempotent when the desired entry is already installed', () => {
|
|
const desired = { Stop: [entry('ecc:stop', 'version-2')] };
|
|
const result = mergeManagedHooks(
|
|
{ hooks: clone(desired) },
|
|
desired,
|
|
{ previousManagedHooks: { Stop: [entry('ecc:stop', 'version-1')] } }
|
|
);
|
|
|
|
assert.deepStrictEqual(result.settings.hooks, desired);
|
|
assert.deepStrictEqual(result.unchanged, [{ event: 'Stop', id: 'ecc:stop' }]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('upgrade removes unchanged entries that are no longer managed', () => {
|
|
const previousManagedHooks = {
|
|
Stop: [
|
|
entry('ecc:keep', 'version-1'),
|
|
entry('ecc:removed', 'old-command'),
|
|
],
|
|
};
|
|
const desired = { Stop: [entry('ecc:keep', 'version-2')] };
|
|
const userEntry = entry('user:stop', 'keep-user');
|
|
const result = mergeManagedHooks({
|
|
hooks: { Stop: [userEntry, ...previousManagedHooks.Stop] },
|
|
}, desired, { previousManagedHooks });
|
|
|
|
assert.deepStrictEqual(result.settings.hooks.Stop, [userEntry, desired.Stop[0]]);
|
|
assert.deepStrictEqual(result.removed, [{ event: 'Stop', id: 'ecc:removed' }]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('upgrade removes multiple retired hooks without deleting their neighbor', () => {
|
|
const previousManagedHooks = {
|
|
Stop: [entry('ecc:a', 'a'), entry('ecc:b', 'b')],
|
|
};
|
|
const userEntry = entry('user:c', 'keep-user');
|
|
const result = mergeManagedHooks({
|
|
hooks: { Stop: [...previousManagedHooks.Stop, userEntry] },
|
|
}, { SessionStart: [entry('ecc:start', 'start')] }, { previousManagedHooks });
|
|
|
|
assert.deepStrictEqual(result.settings.hooks.Stop, [userEntry]);
|
|
assert.deepStrictEqual(result.removed, [
|
|
{ event: 'Stop', id: 'ecc:a' },
|
|
{ event: 'Stop', id: 'ecc:b' },
|
|
]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('upgrade refuses to remove a retired entry after user drift', () => {
|
|
const previousManagedHooks = { Stop: [entry('ecc:removed', 'old-command')] };
|
|
const settings = { hooks: { Stop: [entry('ecc:removed', 'user-edited')] } };
|
|
|
|
assert.throws(
|
|
() => mergeManagedHooks(settings, { SessionStart: [entry('ecc:start', 'start')] }, {
|
|
previousManagedHooks,
|
|
}),
|
|
/Refusing to remove.*ecc:removed.*drifted/
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('merge fails closed when settings contains ambiguous duplicate event ids', () => {
|
|
assert.throws(
|
|
() => mergeManagedHooks(
|
|
{
|
|
hooks: {
|
|
Stop: [
|
|
entry('ecc:stop', 'version-1'),
|
|
entry('ecc:stop', 'another-copy'),
|
|
],
|
|
},
|
|
},
|
|
{ Stop: [entry('ecc:stop', 'version-2')] },
|
|
{ previousManagedHooks: { Stop: [entry('ecc:stop', 'version-1')] } }
|
|
),
|
|
/multiple.*ecc:stop/i
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('merge treats the same id under another event as a separate user entry', () => {
|
|
const result = mergeManagedHooks(
|
|
{ hooks: { SessionStart: [entry('shared:id', 'existing')] } },
|
|
{ Stop: [entry('shared:id', 'desired')] }
|
|
);
|
|
assert.deepStrictEqual(result.settings.hooks, {
|
|
SessionStart: [entry('shared:id', 'existing')],
|
|
Stop: [entry('shared:id', 'desired')],
|
|
});
|
|
})) passed++; else failed++;
|
|
|
|
if (test('repair mode overwrites a drifted same-event managed id and preserves neighbors', () => {
|
|
const userEntry = { id: 'user:hook', command: 'keep-me' };
|
|
const result = repairManagedHooks(
|
|
{ hooks: { Stop: [userEntry, entry('ecc:stop', 'drifted')] } },
|
|
{ Stop: [entry('ecc:stop', 'repaired')] }
|
|
);
|
|
|
|
assert.deepStrictEqual(result.settings.hooks.Stop, [
|
|
userEntry,
|
|
entry('ecc:stop', 'repaired'),
|
|
]);
|
|
assert.deepStrictEqual(result.updated, [{ event: 'Stop', id: 'ecc:stop' }]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('inspect reports exact, missing, and drifted managed entries plus the actual subset', () => {
|
|
const expected = {
|
|
SessionStart: [entry('ecc:start', 'start')],
|
|
Stop: [
|
|
entry('ecc:stop', 'expected'),
|
|
entry('ecc:missing', 'missing'),
|
|
],
|
|
};
|
|
const actualStart = entry('ecc:start', 'start');
|
|
const actualDrift = entry('ecc:stop', 'changed');
|
|
const result = inspectManagedHooks({
|
|
hooks: {
|
|
SessionStart: [actualStart, { id: 'user:start', command: 'user' }],
|
|
Stop: [actualDrift],
|
|
},
|
|
}, expected);
|
|
|
|
assert.strictEqual(result.status, 'missing');
|
|
assert.deepStrictEqual(result.matched, [{ event: 'SessionStart', id: 'ecc:start' }]);
|
|
assert.deepStrictEqual(result.missing, [{ event: 'Stop', id: 'ecc:missing' }]);
|
|
assert.deepStrictEqual(result.drifted, [{
|
|
event: 'Stop',
|
|
id: 'ecc:stop',
|
|
expected: expected.Stop[0],
|
|
actual: actualDrift,
|
|
}]);
|
|
assert.deepStrictEqual(result.managedHooks, {
|
|
SessionStart: [actualStart],
|
|
Stop: [actualDrift],
|
|
});
|
|
})) passed++; else failed++;
|
|
|
|
if (test('inspect fails closed on duplicate matching ids in one settings event', () => {
|
|
assert.throws(
|
|
() => inspectManagedHooks(
|
|
{ hooks: { Stop: [entry('ecc:stop', 'a'), entry('ecc:stop', 'b')] } },
|
|
{ Stop: [entry('ecc:stop', 'expected')] }
|
|
),
|
|
/multiple.*ecc:stop/i
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('inspect and uninstall key ownership by event plus id', () => {
|
|
const recorded = { Stop: [entry('ecc:stop', 'managed')] };
|
|
const moved = { hooks: { SessionStart: [entry('ecc:stop', 'managed')] } };
|
|
|
|
const inspection = inspectManagedHooks(moved, recorded);
|
|
assert.strictEqual(inspection.status, 'missing');
|
|
assert.deepStrictEqual(inspection.missing, [{ event: 'Stop', id: 'ecc:stop' }]);
|
|
|
|
const uninstall = uninstallManagedHooks(moved, recorded);
|
|
assert.deepStrictEqual(uninstall.settings, moved);
|
|
assert.deepStrictEqual(uninstall.missing, [{ event: 'Stop', id: 'ecc:stop' }]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('uninstall removes exact recorded entries, retains drift, and cleans empty events', () => {
|
|
const recorded = {
|
|
SessionStart: [entry('ecc:start', 'start')],
|
|
Stop: [entry('ecc:stop', 'recorded')],
|
|
Notification: [entry('ecc:notify', 'notify')],
|
|
};
|
|
const userEntry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'user' }] };
|
|
const driftedStop = entry('ecc:stop', 'customer-edit');
|
|
const settings = {
|
|
theme: 'dark',
|
|
hooks: {
|
|
SessionStart: [recorded.SessionStart[0]],
|
|
Stop: [userEntry, driftedStop],
|
|
Notification: [recorded.Notification[0]],
|
|
},
|
|
};
|
|
const before = clone(settings);
|
|
|
|
const result = uninstallManagedHooks(settings, recorded);
|
|
|
|
assert.deepStrictEqual(settings, before);
|
|
assert.deepStrictEqual(result.settings, {
|
|
theme: 'dark',
|
|
hooks: {
|
|
Stop: [userEntry, driftedStop],
|
|
},
|
|
});
|
|
assert.deepStrictEqual(result.removed, [
|
|
{ event: 'SessionStart', id: 'ecc:start' },
|
|
{ event: 'Notification', id: 'ecc:notify' },
|
|
]);
|
|
assert.deepStrictEqual(result.retained, [{
|
|
event: 'Stop',
|
|
id: 'ecc:stop',
|
|
expected: recorded.Stop[0],
|
|
actual: driftedStop,
|
|
reason: 'modified',
|
|
}]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('uninstall removes consecutive managed hooks without deleting a user neighbor', () => {
|
|
const recorded = { Stop: [entry('ecc:a', 'a'), entry('ecc:b', 'b')] };
|
|
const userEntry = entry('user:c', 'keep-user');
|
|
const result = uninstallManagedHooks({
|
|
hooks: { Stop: [...recorded.Stop, userEntry] },
|
|
}, recorded);
|
|
|
|
assert.deepStrictEqual(result.settings.hooks.Stop, [userEntry]);
|
|
assert.deepStrictEqual(result.removed, [
|
|
{ event: 'Stop', id: 'ecc:a' },
|
|
{ event: 'Stop', id: 'ecc:b' },
|
|
]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('uninstall removes hooks entirely after the final managed event is emptied', () => {
|
|
const recorded = { Stop: [entry('ecc:stop', 'recorded')] };
|
|
const result = uninstallManagedHooks({ theme: 'dark', hooks: clone(recorded) }, recorded);
|
|
|
|
assert.deepStrictEqual(result.settings, { theme: 'dark' });
|
|
assert.deepStrictEqual(result.removed, [{ event: 'Stop', id: 'ecc:stop' }]);
|
|
assert.deepStrictEqual(result.retained, []);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('uninstall accepts structurally valid hooks from an older runtime contract', () => {
|
|
const recorded = {
|
|
LegacyEvent: [{
|
|
id: 'ecc:legacy',
|
|
hooks: [{ type: 'legacy-handler', payload: { version: 1 } }],
|
|
}],
|
|
};
|
|
const result = uninstallManagedHooks({ hooks: clone(recorded) }, recorded);
|
|
|
|
assert.deepStrictEqual(result.settings, {});
|
|
assert.deepStrictEqual(result.removed, [{ event: 'LegacyEvent', id: 'ecc:legacy' }]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('all settings transforms reject non-array hook events before changing data', () => {
|
|
const settings = { hooks: { Stop: 'invalid' } };
|
|
const managed = { Stop: [entry('ecc:stop', 'expected')] };
|
|
|
|
assert.throws(() => mergeManagedHooks(settings, managed), /hooks\.Stop.*array/);
|
|
assert.throws(() => repairManagedHooks(settings, managed), /hooks\.Stop.*array/);
|
|
assert.throws(() => inspectManagedHooks(settings, managed), /hooks\.Stop.*array/);
|
|
assert.throws(() => uninstallManagedHooks(settings, managed), /hooks\.Stop.*array/);
|
|
})) passed++; else failed++;
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
runTests();
|