diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index e1c0368e..3bd25948 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -10,6 +10,7 @@ const path = require('path'); const { buildDoctorReport, discoverInstalledStates, + normalizeTargets, repairInstalledStates, uninstallInstalledStates, } = require('../../scripts/lib/install-lifecycle'); @@ -52,12 +53,79 @@ function writeState(filePath, options) { 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() { console.log('\n=== Testing install-lifecycle.js ===\n'); let passed = 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', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); @@ -127,6 +195,42 @@ function runTests() { } })) 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', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); @@ -184,6 +288,189 @@ function runTests() { } })) 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', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); @@ -244,6 +531,201 @@ function runTests() { } })) 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', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); @@ -731,6 +1213,394 @@ function runTests() { } })) 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}`); process.exit(failed > 0 ? 1 : 0); }