mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-30 22:13:28 +08:00
test: cover install lifecycle edge paths
This commit is contained in:
@@ -10,6 +10,7 @@ const path = require('path');
|
|||||||
const {
|
const {
|
||||||
buildDoctorReport,
|
buildDoctorReport,
|
||||||
discoverInstalledStates,
|
discoverInstalledStates,
|
||||||
|
normalizeTargets,
|
||||||
repairInstalledStates,
|
repairInstalledStates,
|
||||||
uninstallInstalledStates,
|
uninstallInstalledStates,
|
||||||
} = require('../../scripts/lib/install-lifecycle');
|
} = require('../../scripts/lib/install-lifecycle');
|
||||||
@@ -52,12 +53,79 @@ function writeState(filePath, options) {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCursorStateOptions(projectRoot, overrides = {}) {
|
||||||
|
const targetRoot = overrides.targetRoot || path.join(projectRoot, '.cursor');
|
||||||
|
const installStatePath = overrides.installStatePath || path.join(targetRoot, 'ecc-install-state.json');
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||||
|
targetRoot,
|
||||||
|
installStatePath,
|
||||||
|
request: {
|
||||||
|
profile: null,
|
||||||
|
modules: [],
|
||||||
|
includeComponents: [],
|
||||||
|
excludeComponents: [],
|
||||||
|
legacyLanguages: ['typescript'],
|
||||||
|
legacyMode: true,
|
||||||
|
...(overrides.request || {}),
|
||||||
|
},
|
||||||
|
resolution: {
|
||||||
|
selectedModules: ['legacy-cursor-install'],
|
||||||
|
skippedModules: [],
|
||||||
|
...(overrides.resolution || {}),
|
||||||
|
},
|
||||||
|
operations: overrides.operations || [],
|
||||||
|
source: {
|
||||||
|
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||||
|
repoCommit: 'abc123',
|
||||||
|
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||||
|
...(overrides.source || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCursorState(projectRoot, overrides = {}) {
|
||||||
|
const options = createCursorStateOptions(projectRoot, overrides);
|
||||||
|
writeState(options.installStatePath, options);
|
||||||
|
return {
|
||||||
|
targetRoot: options.targetRoot,
|
||||||
|
installStatePath: options.installStatePath,
|
||||||
|
state: options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedOperation(kind, destinationPath, overrides = {}) {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
moduleId: 'test-module',
|
||||||
|
sourceRelativePath: 'rules/common/coding-style.md',
|
||||||
|
destinationPath,
|
||||||
|
strategy: kind,
|
||||||
|
ownership: 'managed',
|
||||||
|
scaffoldOnly: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function runTests() {
|
function runTests() {
|
||||||
console.log('\n=== Testing install-lifecycle.js ===\n');
|
console.log('\n=== Testing install-lifecycle.js ===\n');
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
|
if (test('normalizes default targets and dedupes adapter aliases', () => {
|
||||||
|
const defaultTargets = normalizeTargets();
|
||||||
|
|
||||||
|
assert.ok(defaultTargets.includes('claude'));
|
||||||
|
assert.ok(defaultTargets.includes('cursor'));
|
||||||
|
assert.ok(defaultTargets.includes('codex'));
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
normalizeTargets(['cursor-project', 'cursor', 'claude-home', 'claude']),
|
||||||
|
['cursor', 'claude']
|
||||||
|
);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('discovers installed states for multiple targets in the current context', () => {
|
if (test('discovers installed states for multiple targets in the current context', () => {
|
||||||
const homeDir = createTempDir('install-lifecycle-home-');
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
@@ -127,6 +195,42 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('discovers missing and invalid install-state records', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let records = discoverInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(records.length, 1);
|
||||||
|
assert.strictEqual(records[0].exists, false);
|
||||||
|
assert.strictEqual(records[0].state, null);
|
||||||
|
assert.strictEqual(records[0].error, null);
|
||||||
|
|
||||||
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||||
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(statePath, '{not-json', 'utf8');
|
||||||
|
|
||||||
|
records = discoverInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(records[0].exists, true);
|
||||||
|
assert.strictEqual(records[0].state, null);
|
||||||
|
assert.ok(records[0].error.includes('Failed to read install-state'));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('doctor reports missing managed files as an error', () => {
|
if (test('doctor reports missing managed files as an error', () => {
|
||||||
const homeDir = createTempDir('install-lifecycle-home-');
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
@@ -184,6 +288,189 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('doctor reports target mismatches, missing sources, unverified operations, and version drift', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const actualTargetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const actualStatePath = path.join(actualTargetRoot, 'ecc-install-state.json');
|
||||||
|
const recordedTargetRoot = path.join(projectRoot, '.old-cursor');
|
||||||
|
const recordedStatePath = path.join(recordedTargetRoot, 'state.json');
|
||||||
|
const copyDestination = path.join(actualTargetRoot, 'rules', 'missing-source.md');
|
||||||
|
const customDestination = path.join(actualTargetRoot, 'custom.txt');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(copyDestination), { recursive: true });
|
||||||
|
fs.writeFileSync(copyDestination, 'managed copy\n');
|
||||||
|
fs.writeFileSync(customDestination, 'custom\n');
|
||||||
|
|
||||||
|
writeState(actualStatePath, createCursorStateOptions(projectRoot, {
|
||||||
|
targetRoot: recordedTargetRoot,
|
||||||
|
installStatePath: recordedStatePath,
|
||||||
|
request: {
|
||||||
|
profile: 'missing-profile',
|
||||||
|
legacyLanguages: [],
|
||||||
|
legacyMode: false,
|
||||||
|
},
|
||||||
|
resolution: {
|
||||||
|
selectedModules: [],
|
||||||
|
skippedModules: [],
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
repoVersion: '0.0.1',
|
||||||
|
manifestVersion: CURRENT_MANIFEST_VERSION + 100,
|
||||||
|
},
|
||||||
|
operations: [
|
||||||
|
managedOperation('copy-file', copyDestination, {
|
||||||
|
sourceRelativePath: 'missing/source.md',
|
||||||
|
strategy: 'copy-file',
|
||||||
|
}),
|
||||||
|
managedOperation('custom-kind', customDestination),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const report = buildDoctorReport({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
const codes = report.results[0].issues.map(issue => issue.code);
|
||||||
|
|
||||||
|
assert.strictEqual(report.results[0].status, 'error');
|
||||||
|
assert.ok(codes.includes('missing-target-root'));
|
||||||
|
assert.ok(codes.includes('target-root-mismatch'));
|
||||||
|
assert.ok(codes.includes('install-state-path-mismatch'));
|
||||||
|
assert.ok(codes.includes('missing-source-files'));
|
||||||
|
assert.ok(codes.includes('unverified-managed-operations'));
|
||||||
|
assert.ok(codes.includes('manifest-version-mismatch'));
|
||||||
|
assert.ok(codes.includes('repo-version-mismatch'));
|
||||||
|
assert.ok(codes.includes('resolution-unavailable'));
|
||||||
|
assert.strictEqual(report.summary.checkedCount, 1);
|
||||||
|
assert.ok(report.summary.errorCount >= 3);
|
||||||
|
assert.ok(report.summary.warningCount >= 4);
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('doctor verifies render-template and merge-json operations by content', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const templatePath = path.join(targetRoot, 'generated.txt');
|
||||||
|
const jsonPath = path.join(targetRoot, 'settings.json');
|
||||||
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(templatePath, 'generated\n');
|
||||||
|
fs.writeFileSync(jsonPath, JSON.stringify({
|
||||||
|
keep: true,
|
||||||
|
nested: {
|
||||||
|
managed: true,
|
||||||
|
extra: true,
|
||||||
|
},
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
writeCursorState(projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('render-template', templatePath, {
|
||||||
|
renderedContent: 'generated\n',
|
||||||
|
}),
|
||||||
|
managedOperation('merge-json', jsonPath, {
|
||||||
|
mergePayload: {
|
||||||
|
nested: {
|
||||||
|
managed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = buildDoctorReport({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(report.results[0].status, 'ok');
|
||||||
|
assert.strictEqual(report.results[0].issues.length, 0);
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('doctor classifies remove, unverified template/json, and invalid JSON operation health', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const templatePath = path.join(targetRoot, 'template.txt');
|
||||||
|
const missingPayloadJsonPath = path.join(targetRoot, 'missing-payload.json');
|
||||||
|
const invalidJsonPath = path.join(targetRoot, 'invalid.json');
|
||||||
|
const removedPath = path.join(targetRoot, 'already-removed.txt');
|
||||||
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(templatePath, 'generated\n');
|
||||||
|
fs.writeFileSync(missingPayloadJsonPath, '{"managed":true}\n');
|
||||||
|
fs.writeFileSync(invalidJsonPath, '{not-json', 'utf8');
|
||||||
|
|
||||||
|
writeCursorState(projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('remove', removedPath),
|
||||||
|
managedOperation('render-template', templatePath),
|
||||||
|
managedOperation('merge-json', missingPayloadJsonPath),
|
||||||
|
managedOperation('merge-json', invalidJsonPath, {
|
||||||
|
mergePayload: { managed: true },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = buildDoctorReport({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
const codes = report.results[0].issues.map(issue => issue.code);
|
||||||
|
|
||||||
|
assert.strictEqual(report.results[0].status, 'warning');
|
||||||
|
assert.ok(codes.includes('unverified-managed-operations'));
|
||||||
|
assert.ok(codes.includes('drifted-managed-files'));
|
||||||
|
assert.ok(!report.results[0].issues.some(issue => issue.code === 'missing-managed-files'));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('doctor reports invalid install-state files as errors', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statePath = path.join(projectRoot, '.cursor', 'ecc-install-state.json');
|
||||||
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
||||||
|
fs.writeFileSync(statePath, '{"schemaVersion":"wrong"}\n');
|
||||||
|
|
||||||
|
const report = buildDoctorReport({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(report.results[0].status, 'error');
|
||||||
|
assert.ok(report.results[0].issues.some(issue => issue.code === 'invalid-install-state'));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('doctor reports a healthy legacy install when managed files are present', () => {
|
if (test('doctor reports a healthy legacy install when managed files are present', () => {
|
||||||
const homeDir = createTempDir('install-lifecycle-home-');
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
@@ -244,6 +531,201 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('repair dry-run reports planned copy repairs without writing files', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const destinationPath = path.join(targetRoot, 'rules', 'coding-style.md');
|
||||||
|
writeCursorState(projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('copy-file', destinationPath, {
|
||||||
|
sourceRelativePath: 'rules/common/coding-style.md',
|
||||||
|
strategy: 'copy-file',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = repairInstalledStates({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.dryRun, true);
|
||||||
|
assert.strictEqual(result.results[0].status, 'planned');
|
||||||
|
assert.deepStrictEqual(result.results[0].plannedRepairs, [destinationPath]);
|
||||||
|
assert.ok(!fs.existsSync(destinationPath));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('repair copies missing managed files from recorded source paths', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const destinationPath = path.join(targetRoot, 'rules', 'coding-style.md');
|
||||||
|
const sourcePath = path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md');
|
||||||
|
writeCursorState(projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('copy-file', destinationPath, {
|
||||||
|
sourceRelativePath: 'rules/common/coding-style.md',
|
||||||
|
strategy: 'copy-file',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = repairInstalledStates({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.results[0].status, 'repaired');
|
||||||
|
assert.ok(fs.readFileSync(destinationPath).equals(fs.readFileSync(sourcePath)));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('repair reports invalid states, missing sources, unsupported operations, and no-op refreshes', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const invalidProjectRoot = createTempDir('install-lifecycle-invalid-');
|
||||||
|
const missingSourceProjectRoot = createTempDir('install-lifecycle-missing-source-');
|
||||||
|
const unsupportedProjectRoot = createTempDir('install-lifecycle-unsupported-');
|
||||||
|
const okProjectRoot = createTempDir('install-lifecycle-ok-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invalidStatePath = path.join(invalidProjectRoot, '.cursor', 'ecc-install-state.json');
|
||||||
|
fs.mkdirSync(path.dirname(invalidStatePath), { recursive: true });
|
||||||
|
fs.writeFileSync(invalidStatePath, '{"schemaVersion":"wrong"}\n');
|
||||||
|
|
||||||
|
let result = repairInstalledStates({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot: invalidProjectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.results[0].status, 'error');
|
||||||
|
assert.ok(result.results[0].error.includes('Invalid install-state'));
|
||||||
|
|
||||||
|
const missingDestination = path.join(missingSourceProjectRoot, '.cursor', 'rules', 'missing.md');
|
||||||
|
fs.mkdirSync(path.dirname(missingDestination), { recursive: true });
|
||||||
|
fs.writeFileSync(missingDestination, 'managed\n');
|
||||||
|
writeCursorState(missingSourceProjectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('copy-file', missingDestination, {
|
||||||
|
sourceRelativePath: 'missing/source.md',
|
||||||
|
strategy: 'copy-file',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
result = repairInstalledStates({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot: missingSourceProjectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.results[0].status, 'error');
|
||||||
|
assert.ok(result.results[0].error.includes('Missing source file(s)'));
|
||||||
|
|
||||||
|
const unsupportedDestination = path.join(unsupportedProjectRoot, '.cursor', 'custom.txt');
|
||||||
|
writeCursorState(unsupportedProjectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('custom-kind', unsupportedDestination),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
result = repairInstalledStates({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot: unsupportedProjectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.results[0].status, 'error');
|
||||||
|
assert.ok(result.results[0].error.includes('Unsupported repair operation kind'));
|
||||||
|
|
||||||
|
writeCursorState(okProjectRoot, { operations: [] });
|
||||||
|
result = repairInstalledStates({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot: okProjectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.results[0].status, 'ok');
|
||||||
|
assert.strictEqual(result.results[0].stateRefreshed, true);
|
||||||
|
assert.strictEqual(result.summary.errorCount, 0);
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(invalidProjectRoot);
|
||||||
|
cleanup(missingSourceProjectRoot);
|
||||||
|
cleanup(unsupportedProjectRoot);
|
||||||
|
cleanup(okProjectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('repair dry-run reports ok when no managed operations need changes', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeCursorState(projectRoot, { operations: [] });
|
||||||
|
|
||||||
|
const result = repairInstalledStates({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.results[0].status, 'ok');
|
||||||
|
assert.strictEqual(result.results[0].stateRefreshed, true);
|
||||||
|
assert.deepStrictEqual(result.results[0].plannedRepairs, []);
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('repair surfaces missing source errors from execution when destination is absent', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destinationPath = path.join(projectRoot, '.cursor', 'rules', 'missing.md');
|
||||||
|
writeCursorState(projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('copy-file', destinationPath, {
|
||||||
|
sourceRelativePath: 'missing/source.md',
|
||||||
|
strategy: 'copy-file',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = repairInstalledStates({
|
||||||
|
repoRoot: REPO_ROOT,
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.results[0].status, 'error');
|
||||||
|
assert.ok(result.results[0].error.includes('Missing source file for repair'));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('doctor reports drifted managed files as a warning', () => {
|
if (test('doctor reports drifted managed files as a warning', () => {
|
||||||
const homeDir = createTempDir('install-lifecycle-home-');
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
@@ -731,6 +1213,394 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uninstall dry-run reports deduped managed removals without deleting files', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const destinationPath = path.join(targetRoot, 'rules', 'coding-style.md');
|
||||||
|
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||||
|
fs.writeFileSync(destinationPath, 'managed\n');
|
||||||
|
const { installStatePath } = writeCursorState(projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||||
|
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.dryRun, true);
|
||||||
|
assert.strictEqual(result.results[0].status, 'planned');
|
||||||
|
assert.deepStrictEqual(result.results[0].plannedRemovals, [
|
||||||
|
destinationPath,
|
||||||
|
installStatePath,
|
||||||
|
]);
|
||||||
|
assert.ok(fs.existsSync(destinationPath));
|
||||||
|
assert.ok(fs.existsSync(installStatePath));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uninstall reports invalid install states as errors', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statePath = path.join(projectRoot, '.cursor', 'ecc-install-state.json');
|
||||||
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
||||||
|
fs.writeFileSync(statePath, '{not-json', 'utf8');
|
||||||
|
|
||||||
|
const result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.results[0].status, 'error');
|
||||||
|
assert.ok(result.results[0].error.includes('Failed to read install-state'));
|
||||||
|
assert.strictEqual(result.summary.errorCount, 1);
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uninstall removes copied files and cleans empty parent directories', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const destinationPath = path.join(targetRoot, 'rules', 'nested', 'managed.md');
|
||||||
|
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||||
|
fs.writeFileSync(destinationPath, 'managed\n');
|
||||||
|
writeCursorState(projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||||
|
assert.ok(result.results[0].removedPaths.includes(destinationPath));
|
||||||
|
assert.ok(!fs.existsSync(destinationPath));
|
||||||
|
assert.ok(!fs.existsSync(path.dirname(destinationPath)));
|
||||||
|
assert.ok(fs.existsSync(targetRoot));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uninstall handles merge-json subset removal and full-file deletion', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const partialProjectRoot = createTempDir('install-lifecycle-partial-');
|
||||||
|
const fullProjectRoot = createTempDir('install-lifecycle-full-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let targetRoot = path.join(partialProjectRoot, '.cursor');
|
||||||
|
let destinationPath = path.join(targetRoot, 'settings.json');
|
||||||
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(destinationPath, JSON.stringify({
|
||||||
|
keep: true,
|
||||||
|
managed: true,
|
||||||
|
nested: {
|
||||||
|
keep: true,
|
||||||
|
remove: true,
|
||||||
|
},
|
||||||
|
list: ['a', 'b'],
|
||||||
|
}, null, 2));
|
||||||
|
writeCursorState(partialProjectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('merge-json', destinationPath, {
|
||||||
|
mergePayload: {
|
||||||
|
managed: true,
|
||||||
|
nested: { remove: true },
|
||||||
|
list: ['a', 'b'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot: partialProjectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||||
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), {
|
||||||
|
keep: true,
|
||||||
|
nested: {
|
||||||
|
keep: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
targetRoot = path.join(fullProjectRoot, '.cursor');
|
||||||
|
destinationPath = path.join(targetRoot, 'settings.json');
|
||||||
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(destinationPath, JSON.stringify({ managed: true }, null, 2));
|
||||||
|
writeCursorState(fullProjectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('merge-json', destinationPath, {
|
||||||
|
mergePayload: { managed: true },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot: fullProjectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||||
|
assert.ok(!fs.existsSync(destinationPath));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(partialProjectRoot);
|
||||||
|
cleanup(fullProjectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uninstall handles merge-json edge shapes and absent destinations', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projects = [
|
||||||
|
createTempDir('install-lifecycle-current-primitive-'),
|
||||||
|
createTempDir('install-lifecycle-missing-key-'),
|
||||||
|
createTempDir('install-lifecycle-nested-delete-'),
|
||||||
|
createTempDir('install-lifecycle-array-root-'),
|
||||||
|
createTempDir('install-lifecycle-primitive-root-'),
|
||||||
|
createTempDir('install-lifecycle-absent-dest-'),
|
||||||
|
createTempDir('install-lifecycle-previous-json-'),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
projectRoot: projects[0],
|
||||||
|
initial: '"plain"',
|
||||||
|
payload: { managed: true },
|
||||||
|
expected: 'plain',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectRoot: projects[1],
|
||||||
|
initial: { keep: true },
|
||||||
|
payload: { missing: true },
|
||||||
|
expected: { keep: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectRoot: projects[2],
|
||||||
|
initial: { keep: true, nested: { remove: true } },
|
||||||
|
payload: { nested: { remove: true } },
|
||||||
|
expected: { keep: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectRoot: projects[3],
|
||||||
|
initial: ['a', 'b'],
|
||||||
|
payload: ['a', 'b'],
|
||||||
|
removed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectRoot: projects[4],
|
||||||
|
initial: true,
|
||||||
|
payload: true,
|
||||||
|
removed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectRoot: projects[5],
|
||||||
|
payload: { managed: true },
|
||||||
|
absent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectRoot: projects[6],
|
||||||
|
initial: { generated: true },
|
||||||
|
payload: { generated: true },
|
||||||
|
previousJson: { restored: true },
|
||||||
|
expected: { restored: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const targetRoot = path.join(testCase.projectRoot, '.cursor');
|
||||||
|
const destinationPath = path.join(targetRoot, 'settings.json');
|
||||||
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
|
if (!testCase.absent) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
destinationPath,
|
||||||
|
typeof testCase.initial === 'string'
|
||||||
|
? `${testCase.initial}\n`
|
||||||
|
: JSON.stringify(testCase.initial, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeCursorState(testCase.projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('merge-json', destinationPath, {
|
||||||
|
mergePayload: testCase.payload,
|
||||||
|
previousJson: testCase.previousJson,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot: testCase.projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||||
|
if (testCase.removed || testCase.absent) {
|
||||||
|
assert.ok(!fs.existsSync(destinationPath));
|
||||||
|
} else {
|
||||||
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), testCase.expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
for (const projectRoot of projects) {
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uninstall removes generated render-template files and no-backup remove operations are no-ops', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const templatePath = path.join(targetRoot, 'generated', 'plugin.json');
|
||||||
|
const removedPath = path.join(targetRoot, 'already-removed.txt');
|
||||||
|
fs.mkdirSync(path.dirname(templatePath), { recursive: true });
|
||||||
|
fs.writeFileSync(templatePath, '{"generated":true}\n');
|
||||||
|
|
||||||
|
writeCursorState(projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('render-template', templatePath, {
|
||||||
|
renderedContent: '{"generated":true}\n',
|
||||||
|
}),
|
||||||
|
managedOperation('remove', removedPath),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||||
|
assert.ok(result.results[0].removedPaths.includes(templatePath));
|
||||||
|
assert.ok(!fs.existsSync(templatePath));
|
||||||
|
assert.ok(!fs.existsSync(path.dirname(templatePath)));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uninstall restores previous JSON snapshots for template and remove operations', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
|
const templatePath = path.join(targetRoot, 'plugin.json');
|
||||||
|
const removedPath = path.join(targetRoot, 'legacy.json');
|
||||||
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(templatePath, '{"generated":true}\n');
|
||||||
|
|
||||||
|
writeCursorState(projectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('render-template', templatePath, {
|
||||||
|
previousJson: { existing: true },
|
||||||
|
renderedContent: '{"generated":true}\n',
|
||||||
|
}),
|
||||||
|
managedOperation('remove', removedPath, {
|
||||||
|
previousJson: { restored: true },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||||
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(templatePath, 'utf8')), {
|
||||||
|
existing: true,
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(removedPath, 'utf8')), {
|
||||||
|
restored: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uninstall reports unsupported operations and missing merge payloads as errors', () => {
|
||||||
|
const homeDir = createTempDir('install-lifecycle-home-');
|
||||||
|
const unsupportedProjectRoot = createTempDir('install-lifecycle-unsupported-');
|
||||||
|
const missingPayloadProjectRoot = createTempDir('install-lifecycle-missing-payload-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let targetRoot = path.join(unsupportedProjectRoot, '.cursor');
|
||||||
|
let destinationPath = path.join(targetRoot, 'custom.txt');
|
||||||
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(destinationPath, 'custom\n');
|
||||||
|
writeCursorState(unsupportedProjectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('custom-kind', destinationPath),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot: unsupportedProjectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.results[0].status, 'error');
|
||||||
|
assert.ok(result.results[0].error.includes('Unsupported uninstall operation kind'));
|
||||||
|
|
||||||
|
targetRoot = path.join(missingPayloadProjectRoot, '.cursor');
|
||||||
|
destinationPath = path.join(targetRoot, 'settings.json');
|
||||||
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(destinationPath, '{"managed":true}\n');
|
||||||
|
writeCursorState(missingPayloadProjectRoot, {
|
||||||
|
operations: [
|
||||||
|
managedOperation('merge-json', destinationPath),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
result = uninstallInstalledStates({
|
||||||
|
homeDir,
|
||||||
|
projectRoot: missingPayloadProjectRoot,
|
||||||
|
targets: ['cursor'],
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.results[0].status, 'error');
|
||||||
|
assert.ok(result.results[0].error.includes('Missing merge payload for uninstall'));
|
||||||
|
} finally {
|
||||||
|
cleanup(homeDir);
|
||||||
|
cleanup(unsupportedProjectRoot);
|
||||||
|
cleanup(missingPayloadProjectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user