mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 20:55:28 +02:00
619 lines
27 KiB
JavaScript
619 lines
27 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression tests for stale install-state operations (issue #3232).
|
|
*
|
|
* A reinstall must drop managed copy-file records that a selected module no
|
|
* longer ships, delete only unchanged regular files reached without symlinks,
|
|
* and keep ownership whenever the install fails or the record is out of scope.
|
|
*/
|
|
|
|
const assert = require('assert');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const { applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install/apply');
|
|
const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
|
|
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
|
|
const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime');
|
|
const { normalizeInstallRequest, parseInstallArgs } = require('../../scripts/lib/install/request');
|
|
const { buildDoctorReport } = require('../../scripts/lib/install-lifecycle');
|
|
const { completeStaleOperationsReconciliation } = require('../../scripts/lib/install/stale-operations-reconciliation');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..', '..');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
let skipped = 0;
|
|
|
|
function test(name, fn) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stale-ops-'));
|
|
try {
|
|
if (fn(root) === 'skip') {
|
|
console.log(` - ${name} (skipped: symlinks unavailable)`);
|
|
skipped += 1;
|
|
return;
|
|
}
|
|
console.log(` ✓ ${name}`);
|
|
passed += 1;
|
|
} catch (error) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${error.stack || error.message}`);
|
|
failed += 1;
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function sha256(content) {
|
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
function writeFile(filePath, content) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content);
|
|
}
|
|
|
|
function trySymlink(targetPath, linkPath, type) {
|
|
try {
|
|
fs.symlinkSync(targetPath, linkPath, type);
|
|
return true;
|
|
} catch (error) {
|
|
if (process.platform === 'win32' && (error.code === 'EPERM' || error.code === 'EACCES')) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function cursorTarget(root) {
|
|
const targetRoot = path.join(root, '.cursor');
|
|
return {
|
|
root,
|
|
targetRoot,
|
|
installStatePath: path.join(targetRoot, 'ecc-install-state.json'),
|
|
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
|
};
|
|
}
|
|
|
|
function claudeTarget(root) {
|
|
const targetRoot = path.join(root, '.claude');
|
|
return {
|
|
root,
|
|
targetRoot,
|
|
installStatePath: path.join(targetRoot, 'ecc', 'install-state.json'),
|
|
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
|
};
|
|
}
|
|
|
|
// spec: { moduleId, source, dest?, kind?, content?, payload?, skipSource? }
|
|
function buildOperation(target, spec) {
|
|
const destinationPath = path.join(target.targetRoot, spec.dest || spec.source);
|
|
if (spec.kind === 'merge-json') {
|
|
return {
|
|
kind: 'merge-json',
|
|
moduleId: spec.moduleId,
|
|
sourceRelativePath: spec.source,
|
|
destinationPath,
|
|
strategy: 'merge-json',
|
|
ownership: 'managed',
|
|
scaffoldOnly: false,
|
|
mergePayload: spec.payload || { managed: true },
|
|
};
|
|
}
|
|
const sourcePath = path.join(target.root, 'source', spec.source);
|
|
if (!spec.skipSource) {
|
|
writeFile(sourcePath, spec.content === undefined ? `${spec.source}\n` : spec.content);
|
|
}
|
|
return {
|
|
kind: 'copy-file',
|
|
moduleId: spec.moduleId,
|
|
sourcePath,
|
|
sourceRelativePath: spec.source,
|
|
destinationPath,
|
|
strategy: 'preserve-relative-path',
|
|
ownership: 'managed',
|
|
scaffoldOnly: false,
|
|
};
|
|
}
|
|
|
|
function makePlan(target, specs, options = {}) {
|
|
const operations = specs.map(spec => buildOperation(target, spec));
|
|
const modules = options.modules || [...new Set(operations.map(operation => operation.moduleId))];
|
|
return {
|
|
mode: 'manifest',
|
|
target: target.adapter.target,
|
|
adapter: target.adapter,
|
|
targetRoot: target.targetRoot,
|
|
installRoot: target.targetRoot,
|
|
installStatePath: target.installStatePath,
|
|
operations,
|
|
statePreview: {
|
|
schemaVersion: 'ecc.install.v1',
|
|
installedAt: new Date().toISOString(),
|
|
target: { ...target.adapter, root: target.targetRoot, installStatePath: target.installStatePath },
|
|
request: {
|
|
profile: null,
|
|
modules,
|
|
includeComponents: [],
|
|
excludeComponents: [],
|
|
legacyLanguages: [],
|
|
legacyMode: false,
|
|
hookConsent: null,
|
|
},
|
|
resolution: { selectedModules: modules, skippedModules: [] },
|
|
source: { manifestVersion: 1 },
|
|
operations,
|
|
},
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
function relativeDestinations(target, operations) {
|
|
const ops = operations || readInstallState(target.installStatePath).operations;
|
|
return ops
|
|
.map(operation => path.relative(target.targetRoot, operation.destinationPath).split(path.sep).join('/'))
|
|
.sort();
|
|
}
|
|
|
|
function findRecord(target, relativePath) {
|
|
const destinationPath = path.join(target.targetRoot, relativePath);
|
|
return readInstallState(target.installStatePath).operations
|
|
.find(operation => operation.destinationPath === destinationPath);
|
|
}
|
|
|
|
function editState(target, transform) {
|
|
const state = readInstallState(target.installStatePath);
|
|
writeInstallState(target.installStatePath, { ...state, operations: transform(state.operations) });
|
|
}
|
|
|
|
function orphanWarnings(result) {
|
|
return result.warnings.filter(warning => warning.startsWith('Preserved orphaned file '));
|
|
}
|
|
|
|
const KEEP = { moduleId: 'core', source: 'lib/keep.js' };
|
|
|
|
console.log('\n=== Testing stale install-state operations (#3232) ===\n');
|
|
|
|
for (const phase of ['during hashing', 'after hashing']) {
|
|
test(`preserves an equal-length user edit ${phase}`, root => {
|
|
const target = cursorTarget(root);
|
|
const file = path.join(target.targetRoot, 'obsolete.txt');
|
|
const original = 'installed bytes\n';
|
|
const edited = 'user edit bytes\n';
|
|
assert.strictEqual(original.length, edited.length);
|
|
writeFile(file, original);
|
|
// Bind the fixture identity to its opened private file. Validate the
|
|
// retained pathname through a fresh descriptor after the injected race.
|
|
const identityFd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
let identity;
|
|
try { identity = fs.fstatSync(identityFd, { bigint: true }); }
|
|
finally { fs.closeSync(identityFd); }
|
|
const originalRead = fs.readFileSync;
|
|
const originalFstat = fs.fstatSync;
|
|
let mutationApplied = false;
|
|
let matchingStats = 0;
|
|
const matches = stat => stat.dev === identity.dev && stat.ino === identity.ino;
|
|
const mutate = () => {
|
|
fs.writeFileSync(file, edited);
|
|
// Avoid depending on filesystem timestamp granularity in the race fixture.
|
|
const later = new Date(Date.now() + 2000);
|
|
fs.utimesSync(file, later, later);
|
|
mutationApplied = true;
|
|
};
|
|
fs.readFileSync = function (value, ...args) {
|
|
const bytes = originalRead.call(this, value, ...args);
|
|
if (phase === 'during hashing' && typeof value === 'number'
|
|
&& matches(originalFstat(value, { bigint: true }))) mutate();
|
|
return bytes;
|
|
};
|
|
fs.fstatSync = function (descriptor, ...args) {
|
|
const stat = originalFstat.call(this, descriptor, ...args);
|
|
if (phase === 'after hashing' && matches(stat) && ++matchingStats === 2) mutate();
|
|
return stat;
|
|
};
|
|
let result;
|
|
try {
|
|
result = completeStaleOperationsReconciliation({ staleOperationCandidates: [{
|
|
destinationPath: file, contentSha256: sha256(original),
|
|
}] }, { targetRoot: target.targetRoot });
|
|
} finally {
|
|
fs.readFileSync = originalRead;
|
|
fs.fstatSync = originalFstat;
|
|
}
|
|
assert.ok(mutationApplied, 'The equal-length mutation must actually execute');
|
|
const retainedFd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
try {
|
|
const retainedIdentity = fs.fstatSync(retainedFd, { bigint: true });
|
|
assert.ok(retainedIdentity.isFile());
|
|
assert.ok(matches(retainedIdentity), 'The mutated fixture inode must remain at the retained path');
|
|
assert.strictEqual(fs.readFileSync(retainedFd, 'utf8'), edited);
|
|
} finally {
|
|
fs.closeSync(retainedFd);
|
|
}
|
|
assert.deepStrictEqual(result.removedPaths, []);
|
|
assert.strictEqual(result.warnings.length, 1);
|
|
assert.match(result.warnings[0], /changed while being verified/);
|
|
});
|
|
}
|
|
|
|
test('removes an unchanged stale copy and its record after a successful reinstall', root => {
|
|
const target = cursorTarget(root);
|
|
const stale = { moduleId: 'core', source: 'lib/obsolete/cost-estimate.js' };
|
|
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
|
const stalePath = path.join(target.targetRoot, stale.source);
|
|
assert.ok(fs.existsSync(stalePath));
|
|
|
|
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
|
assert.ok(!fs.existsSync(stalePath), 'unchanged stale copy should be deleted');
|
|
assert.ok(!fs.existsSync(path.dirname(stalePath)), 'emptied stale directory should be pruned');
|
|
assert.ok(fs.existsSync(path.join(target.targetRoot, KEEP.source)));
|
|
assert.deepStrictEqual(result.reconciledStalePaths, [path.resolve(stalePath)]);
|
|
assert.deepStrictEqual(result.warnings, []);
|
|
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
|
assert.deepStrictEqual(relativeDestinations(target, result.statePreview.operations), ['lib/keep.js']);
|
|
});
|
|
|
|
test('preserves a modified stale copy, drops its record, and warns only once', root => {
|
|
const target = cursorTarget(root);
|
|
const stale = { moduleId: 'core', source: 'lib/edited.js' };
|
|
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
|
const stalePath = path.join(target.targetRoot, stale.source);
|
|
fs.writeFileSync(stalePath, 'user edit\n');
|
|
|
|
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
|
assert.strictEqual(fs.readFileSync(stalePath, 'utf8'), 'user edit\n');
|
|
assert.deepStrictEqual(result.reconciledStalePaths, []);
|
|
const warnings = orphanWarnings(result);
|
|
assert.strictEqual(warnings.length, 1, warnings.join('\n'));
|
|
assert.ok(warnings[0].includes(stalePath) && /content changed after install/.test(warnings[0]));
|
|
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
|
|
|
const rerun = applyInstallPlan(makePlan(target, [KEEP]));
|
|
assert.deepStrictEqual(orphanWarnings(rerun), [], 'an untracked orphan must not warn again');
|
|
assert.strictEqual(fs.readFileSync(stalePath, 'utf8'), 'user edit\n');
|
|
});
|
|
|
|
test('preserves a stale copy whose record has no content digest', root => {
|
|
const target = cursorTarget(root);
|
|
const stale = { moduleId: 'core', source: 'lib/old-ledger.js' };
|
|
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
|
editState(target, operations => operations.map(operation => {
|
|
if (!operation.destinationPath.endsWith('old-ledger.js')) {
|
|
return operation;
|
|
}
|
|
const { contentSha256: _digest, ...digestless } = operation;
|
|
return digestless;
|
|
}));
|
|
|
|
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
|
assert.ok(fs.existsSync(path.join(target.targetRoot, stale.source)));
|
|
const warnings = orphanWarnings(result);
|
|
assert.strictEqual(warnings.length, 1);
|
|
assert.ok(/no content digest/.test(warnings[0]));
|
|
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
|
});
|
|
|
|
test('preserves a stale destination that is no longer a regular file', root => {
|
|
const target = cursorTarget(root);
|
|
const stale = { moduleId: 'core', source: 'lib/now-a-dir.js' };
|
|
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
|
const stalePath = path.join(target.targetRoot, stale.source);
|
|
fs.rmSync(stalePath);
|
|
writeFile(path.join(stalePath, 'user.txt'), 'user data\n');
|
|
|
|
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
|
assert.strictEqual(fs.readFileSync(path.join(stalePath, 'user.txt'), 'utf8'), 'user data\n');
|
|
const warnings = orphanWarnings(result);
|
|
assert.strictEqual(warnings.length, 1);
|
|
assert.ok(/not a regular file/.test(warnings[0]));
|
|
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
|
});
|
|
|
|
test('never deletes a stale record destination outside the target root', root => {
|
|
const target = cursorTarget(root);
|
|
applyInstallPlan(makePlan(target, [KEEP]));
|
|
const outsidePath = path.join(root, 'outside', 'victim.js');
|
|
writeFile(outsidePath, 'victim\n');
|
|
editState(target, operations => [...operations, {
|
|
kind: 'copy-file',
|
|
moduleId: 'core',
|
|
sourceRelativePath: 'lib/victim.js',
|
|
destinationPath: outsidePath,
|
|
strategy: 'preserve-relative-path',
|
|
ownership: 'managed',
|
|
scaffoldOnly: false,
|
|
contentSha256: sha256('victim\n'),
|
|
}]);
|
|
|
|
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
|
assert.strictEqual(fs.readFileSync(outsidePath, 'utf8'), 'victim\n');
|
|
const warnings = orphanWarnings(result);
|
|
assert.strictEqual(warnings.length, 1);
|
|
assert.ok(/outside the install root/.test(warnings[0]));
|
|
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
|
});
|
|
|
|
test('never deletes the in-root target of a stale symlinked destination', root => {
|
|
const target = cursorTarget(root);
|
|
const stale = { moduleId: 'core', source: 'lib/linked.js' };
|
|
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
|
const linkPath = path.join(target.targetRoot, stale.source);
|
|
// The link target holds exactly the recorded bytes, so following the link
|
|
// would pass the digest check and delete the user's file.
|
|
const linkTarget = path.join(target.targetRoot, 'user', 'linked-target.js');
|
|
writeFile(linkTarget, `${stale.source}\n`);
|
|
fs.rmSync(linkPath);
|
|
if (!trySymlink(linkTarget, linkPath, 'file')) {
|
|
return 'skip';
|
|
}
|
|
|
|
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
|
assert.ok(fs.lstatSync(linkPath).isSymbolicLink(), 'the symlink itself must be preserved');
|
|
assert.strictEqual(fs.readFileSync(linkTarget, 'utf8'), `${stale.source}\n`);
|
|
const warnings = orphanWarnings(result);
|
|
assert.strictEqual(warnings.length, 1);
|
|
assert.ok(/is a symlink/.test(warnings[0]));
|
|
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
|
});
|
|
|
|
test('never deletes through an in-root symlinked parent directory', root => {
|
|
const target = cursorTarget(root);
|
|
const stale = { moduleId: 'core', source: 'nested/stale.js' };
|
|
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
|
const linkedDir = path.join(target.targetRoot, 'nested');
|
|
const realDir = path.join(target.targetRoot, 'real-nested');
|
|
fs.renameSync(linkedDir, realDir);
|
|
if (!trySymlink(realDir, linkedDir, 'dir')) {
|
|
return 'skip';
|
|
}
|
|
|
|
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
|
assert.strictEqual(fs.readFileSync(path.join(realDir, 'stale.js'), 'utf8'), `${stale.source}\n`);
|
|
assert.ok(fs.lstatSync(linkedDir).isSymbolicLink());
|
|
const warnings = orphanWarnings(result);
|
|
assert.strictEqual(warnings.length, 1);
|
|
assert.ok(/passes through the symlink/.test(warnings[0]));
|
|
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
|
});
|
|
|
|
test('dry-run previews the reconciled state without mutating files or install-state', root => {
|
|
const target = cursorTarget(root);
|
|
const stale = { moduleId: 'core', source: 'lib/obsolete.js' };
|
|
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
|
const stateBefore = fs.readFileSync(target.installStatePath, 'utf8');
|
|
|
|
const preview = previewInstallPlan(makePlan(target, [KEEP]));
|
|
assert.strictEqual(preview.applied, false);
|
|
assert.ok(fs.existsSync(path.join(target.targetRoot, stale.source)), 'dry-run must not delete');
|
|
assert.strictEqual(fs.readFileSync(target.installStatePath, 'utf8'), stateBefore);
|
|
assert.deepStrictEqual(relativeDestinations(target, preview.statePreview.operations), ['lib/keep.js']);
|
|
assert.ok(preview.warnings.some(warning => /^1 previously managed file\(s\) are no longer part/.test(warning)));
|
|
assert.deepStrictEqual(orphanWarnings(preview), []);
|
|
});
|
|
|
|
test('a failed reinstall keeps stale records and files for a later retry', root => {
|
|
const target = cursorTarget(root);
|
|
const stale = { moduleId: 'core', source: 'lib/obsolete.js' };
|
|
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
|
const stalePath = path.join(target.targetRoot, stale.source);
|
|
const recorded = findRecord(target, stale.source);
|
|
|
|
// Failure while copying: the checkpoint is the bridge state.
|
|
const broken = { moduleId: 'core', source: 'lib/missing-source.js', skipSource: true };
|
|
assert.throws(() => applyInstallPlan(makePlan(target, [KEEP, broken])), /ENOENT/);
|
|
assert.ok(fs.existsSync(stalePath));
|
|
assert.deepStrictEqual(findRecord(target, stale.source), recorded);
|
|
|
|
// Failure while persisting the final state, after every copy succeeded.
|
|
let stateWrites = 0;
|
|
assert.throws(() => applyInstallPlan(makePlan(target, [KEEP]), {
|
|
beforeInstallStateWrite() {
|
|
stateWrites += 1;
|
|
if (stateWrites === 2) {
|
|
throw new Error('simulated final install-state write failure');
|
|
}
|
|
},
|
|
}), /simulated final install-state write failure/);
|
|
assert.strictEqual(stateWrites, 2, 'bridge and final state writes should both be attempted');
|
|
assert.ok(fs.existsSync(stalePath), 'stale file must survive a failed install');
|
|
assert.deepStrictEqual(findRecord(target, stale.source), recorded);
|
|
|
|
const retry = applyInstallPlan(makePlan(target, [KEEP]));
|
|
assert.ok(!fs.existsSync(stalePath));
|
|
assert.deepStrictEqual(retry.reconciledStalePaths, [path.resolve(stalePath)]);
|
|
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
|
});
|
|
|
|
test('selective --modules installs only reconcile modules they select', root => {
|
|
const target = cursorTarget(root);
|
|
const alphaOne = { moduleId: 'alpha', source: 'alpha/one.md' };
|
|
const alphaTwo = { moduleId: 'alpha', source: 'alpha/two.md' };
|
|
const beta = { moduleId: 'beta', source: 'beta/one.md' };
|
|
applyInstallPlan(makePlan(target, [alphaOne, alphaTwo]));
|
|
applyInstallPlan(makePlan(target, [beta]));
|
|
assert.deepStrictEqual(relativeDestinations(target), ['alpha/one.md', 'alpha/two.md', 'beta/one.md']);
|
|
|
|
const alphaResult = applyInstallPlan(makePlan(target, [alphaOne]));
|
|
assert.ok(!fs.existsSync(path.join(target.targetRoot, alphaTwo.source)));
|
|
assert.deepStrictEqual(alphaResult.reconciledStalePaths, [
|
|
path.resolve(path.join(target.targetRoot, alphaTwo.source)),
|
|
]);
|
|
assert.ok(fs.existsSync(path.join(target.targetRoot, beta.source)), 'unselected module files stay');
|
|
assert.deepStrictEqual(relativeDestinations(target), ['alpha/one.md', 'beta/one.md']);
|
|
|
|
const betaResult = applyInstallPlan(makePlan(target, [beta]));
|
|
assert.deepStrictEqual(betaResult.reconciledStalePaths, []);
|
|
assert.deepStrictEqual(relativeDestinations(target), ['alpha/one.md', 'beta/one.md']);
|
|
});
|
|
|
|
test('reconciles a destination change for the same module and source', root => {
|
|
const target = cursorTarget(root);
|
|
applyInstallPlan(makePlan(target, [{ moduleId: 'core', source: 'cfg/tool.json', dest: 'old/tool.json' }]));
|
|
const result = applyInstallPlan(makePlan(target, [{ moduleId: 'core', source: 'cfg/tool.json', dest: 'new/tool.json' }]));
|
|
assert.ok(!fs.existsSync(path.join(target.targetRoot, 'old', 'tool.json')));
|
|
assert.ok(fs.existsSync(path.join(target.targetRoot, 'new', 'tool.json')));
|
|
assert.strictEqual(result.reconciledStalePaths.length, 1);
|
|
const state = readInstallState(target.installStatePath);
|
|
assert.deepStrictEqual(relativeDestinations(target), ['new/tool.json']);
|
|
assert.strictEqual(state.operations[0].sourceRelativePath, 'cfg/tool.json');
|
|
});
|
|
|
|
test('keeps a destination whose kind changes and drops a kind-plus-destination move', root => {
|
|
const target = cursorTarget(root);
|
|
applyInstallPlan(makePlan(target, [
|
|
{ moduleId: 'core', source: 'cfg/config.json', dest: 'config.json', content: '{"base":1}\n' },
|
|
{ moduleId: 'core', source: 'cfg/events.json', dest: 'events/events.json', content: '{"events":[]}\n' },
|
|
]));
|
|
|
|
const result = applyInstallPlan(makePlan(target, [
|
|
{ moduleId: 'core', kind: 'merge-json', source: 'cfg/config.json', dest: 'config.json', payload: { merged: true } },
|
|
{ moduleId: 'core', kind: 'merge-json', source: 'cfg/events.json', dest: 'settings.json', payload: { events: [] } },
|
|
]));
|
|
|
|
assert.deepStrictEqual(
|
|
JSON.parse(fs.readFileSync(path.join(target.targetRoot, 'config.json'), 'utf8')),
|
|
{ base: 1, merged: true },
|
|
'a same-destination kind change must keep the file'
|
|
);
|
|
assert.ok(!fs.existsSync(path.join(target.targetRoot, 'events', 'events.json')));
|
|
assert.deepStrictEqual(result.reconciledStalePaths, [
|
|
path.resolve(path.join(target.targetRoot, 'events', 'events.json')),
|
|
]);
|
|
const state = readInstallState(target.installStatePath);
|
|
assert.deepStrictEqual(relativeDestinations(target), ['config.json', 'settings.json']);
|
|
assert.ok(state.operations.every(operation => operation.kind === 'merge-json'));
|
|
});
|
|
|
|
test('preserves Claude legacy-skill migration conflict ownership', root => {
|
|
const target = claudeTarget(root);
|
|
const legacyPath = path.join(target.targetRoot, 'skills', 'ecc', 'demo', 'SKILL.md');
|
|
const flatUserPath = path.join(target.targetRoot, 'skills', 'demo', 'SKILL.md');
|
|
writeFile(legacyPath, '# demo (nested ECC copy)\n');
|
|
writeFile(flatUserPath, '# demo (user-owned)\n');
|
|
const seedPlan = makePlan(target, []);
|
|
writeInstallState(target.installStatePath, {
|
|
...seedPlan.statePreview,
|
|
request: { ...seedPlan.statePreview.request, modules: ['skills-core'] },
|
|
resolution: { selectedModules: ['skills-core'], skippedModules: [] },
|
|
operations: [{
|
|
kind: 'copy-file',
|
|
moduleId: 'skills-core',
|
|
sourceRelativePath: 'skills/demo/SKILL.md',
|
|
destinationPath: legacyPath,
|
|
strategy: 'preserve-relative-path',
|
|
ownership: 'managed',
|
|
scaffoldOnly: false,
|
|
contentSha256: sha256('# demo (nested ECC copy)\n'),
|
|
}],
|
|
});
|
|
|
|
const result = applyInstallPlan(makePlan(target, [
|
|
{ moduleId: 'skills-core', source: 'skills/demo/SKILL.md', dest: 'skills/demo/SKILL.md' },
|
|
{ moduleId: 'skills-core', source: 'rules/keep.md', dest: 'rules/ecc/keep.md' },
|
|
]));
|
|
|
|
assert.ok(result.warnings.some(warning => warning.startsWith("Skipped Claude skill 'demo'")));
|
|
assert.deepStrictEqual(orphanWarnings(result), []);
|
|
assert.deepStrictEqual(result.reconciledStalePaths, []);
|
|
assert.strictEqual(fs.readFileSync(legacyPath, 'utf8'), '# demo (nested ECC copy)\n');
|
|
assert.strictEqual(fs.readFileSync(flatUserPath, 'utf8'), '# demo (user-owned)\n');
|
|
assert.deepStrictEqual(relativeDestinations(target), ['rules/ecc/keep.md', 'skills/ecc/demo/SKILL.md']);
|
|
});
|
|
|
|
test('declining hooks keeps hook files from modules that remain selected', root => {
|
|
const target = cursorTarget(root);
|
|
const hook = { moduleId: 'platform-configs', source: '.cursor/hooks/after-edit.js', dest: 'hooks/after-edit.js' };
|
|
const rule = { moduleId: 'platform-configs', source: 'rules/keep.md' };
|
|
applyInstallPlan(withHookConsent(makePlan(target, [hook, rule]), 'enabled'));
|
|
|
|
const declined = withHookConsent(makePlan(target, [hook, rule]), 'declined');
|
|
assert.ok(!declined.operations.some(operation => operation.sourceRelativePath === hook.source));
|
|
const result = applyInstallPlan(declined);
|
|
assert.deepStrictEqual(result.reconciledStalePaths, []);
|
|
assert.ok(fs.existsSync(path.join(target.targetRoot, hook.dest)));
|
|
assert.deepStrictEqual(relativeDestinations(target), ['hooks/after-edit.js', 'rules/keep.md']);
|
|
});
|
|
|
|
test('legacy language installs stay cumulative inside a shared module', root => {
|
|
const target = cursorTarget(root);
|
|
const legacyPlan = specs => ({ ...makePlan(target, specs), mode: 'legacy' });
|
|
applyInstallPlan(legacyPlan([{ moduleId: 'legacy-cursor-install', source: 'rules/python-style.md' }]));
|
|
const result = applyInstallPlan(legacyPlan([{ moduleId: 'legacy-cursor-install', source: 'rules/typescript-style.md' }]));
|
|
assert.deepStrictEqual(result.reconciledStalePaths, []);
|
|
assert.ok(fs.existsSync(path.join(target.targetRoot, 'rules', 'python-style.md')));
|
|
assert.deepStrictEqual(relativeDestinations(target), ['rules/python-style.md', 'rules/typescript-style.md']);
|
|
});
|
|
|
|
test('Claude home reinstall drops obsolete hooks.json and cost-estimate.js records (#3232)', root => {
|
|
const homeDir = path.join(root, 'home');
|
|
const projectRoot = path.join(root, 'project');
|
|
fs.mkdirSync(homeDir, { recursive: true });
|
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
const env = { HOME: homeDir, USERPROFILE: homeDir };
|
|
const request = normalizeInstallRequest({
|
|
...parseInstallArgs(['node', 'install-apply.js', '--profile', 'core', '--enable-hooks']),
|
|
config: null,
|
|
});
|
|
const createPlan = () => createInstallPlanFromRequest(request, { projectRoot, homeDir, env });
|
|
const doctorCodes = () => buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['claude'], env })
|
|
.results.flatMap(result => result.issues.map(issue => issue.code));
|
|
|
|
const initialPlan = createPlan();
|
|
applyInstallPlan(initialPlan);
|
|
const claudeRoot = initialPlan.targetRoot;
|
|
const hooksJsonPath = path.join(claudeRoot, 'hooks', 'hooks.json');
|
|
const costEstimatePath = path.join(claudeRoot, 'scripts', 'lib', 'cost-estimate.js');
|
|
const legacyHooksJson = '{"hooks":{}}\n';
|
|
const legacyCostEstimate = 'module.exports = {};\n';
|
|
// Seed the records an older pin left behind: hooks.json was copied before it
|
|
// moved into settings.json, and cost-estimate.js was later deleted upstream.
|
|
writeFile(hooksJsonPath, legacyHooksJson);
|
|
writeFile(costEstimatePath, '// user tweak\n');
|
|
const staleRecord = (sourceRelativePath, destinationPath, installedContent) => ({
|
|
kind: 'copy-file',
|
|
moduleId: 'hooks-runtime',
|
|
sourcePath: path.join(REPO_ROOT, sourceRelativePath),
|
|
sourceRelativePath,
|
|
destinationPath,
|
|
strategy: 'preserve-relative-path',
|
|
ownership: 'managed',
|
|
scaffoldOnly: false,
|
|
contentSha256: sha256(installedContent),
|
|
});
|
|
editState({ installStatePath: initialPlan.installStatePath }, operations => [
|
|
...operations,
|
|
staleRecord('hooks/hooks.json', hooksJsonPath, legacyHooksJson),
|
|
staleRecord('scripts/lib/cost-estimate.js', costEstimatePath, legacyCostEstimate),
|
|
]);
|
|
assert.ok(doctorCodes().includes('missing-source-files'), 'seeded state should reproduce the doctor error');
|
|
|
|
const plannedDestinations = new Set(createPlan().operations.map(operation => operation.destinationPath));
|
|
const preview = previewInstallPlan(createPlan());
|
|
assert.ok(
|
|
preview.statePreview.operations.every(operation => plannedDestinations.has(operation.destinationPath)),
|
|
'dry-run state preview must not carry operations the plan no longer contains'
|
|
);
|
|
assert.ok(fs.existsSync(hooksJsonPath) && fs.existsSync(costEstimatePath));
|
|
|
|
const result = applyInstallPlan(createPlan());
|
|
assert.ok(!fs.existsSync(hooksJsonPath), 'unchanged obsolete hooks.json should be removed');
|
|
assert.strictEqual(fs.readFileSync(costEstimatePath, 'utf8'), '// user tweak\n');
|
|
const warnings = orphanWarnings(result);
|
|
assert.strictEqual(warnings.length, 1, warnings.join('\n'));
|
|
assert.ok(warnings[0].includes(costEstimatePath));
|
|
|
|
const state = readInstallState(initialPlan.installStatePath);
|
|
assert.ok(!state.operations.some(operation => (
|
|
operation.destinationPath === hooksJsonPath || operation.destinationPath === costEstimatePath
|
|
)));
|
|
const codes = doctorCodes();
|
|
assert.ok(!codes.includes('missing-source-files'), `doctor still reports: ${codes.join(', ')}`);
|
|
assert.ok(!codes.includes('drifted-managed-files'), `doctor still reports: ${codes.join(', ')}`);
|
|
});
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}, Skipped: ${skipped}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|