diff --git a/tests/lib/install-executor.test.js b/tests/lib/install-executor.test.js index 940d5f030..f40c1cc03 100644 --- a/tests/lib/install-executor.test.js +++ b/tests/lib/install-executor.test.js @@ -786,6 +786,97 @@ function runTests() { } })) passed++; else failed++; + if (test('OpenCode pre-fix managed sources upgrade on repair and uninstall preserves user changes', () => { + const tempDir = createTempDir('install-executor-opencode-upgrade-'); + try { + const sourceRoot = path.join(tempDir, 'source'); + writeManifestSourceFixture(sourceRoot); + const modulesPath = path.join(sourceRoot, 'manifests', 'install-modules.json'); + const manifest = JSON.parse(fs.readFileSync(modulesPath, 'utf8')); + writeJson(sourceRoot, 'manifests/install-modules.json', { + ...manifest, + modules: manifest.modules.map(module => ({ + ...module, + paths: ['.opencode'], + targets: ['opencode'], + })), + }); + writeFile(sourceRoot, '.opencode/dist/index.js', 'export default () => ({});\n'); + writeFile(sourceRoot, '.opencode/dist/plugins/index.js', 'export {};\n'); + writeFile(sourceRoot, '.opencode/dist/tools/index.js', 'export {};\n'); + writeJson(sourceRoot, '.opencode/opencode.json', { plugin: ['./plugins'] }); + fs.cpSync(path.join(REPO_ROOT, '.opencode', 'tools'), path.join(sourceRoot, '.opencode', 'tools'), { + recursive: true, + }); + const currentContent = fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'tools', 'index.ts'), 'utf8'); + const legacyContent = [ + 'export { default as runTests } from "./run-tests.js"', + 'export { default as checkCoverage } from "./check-coverage.js"', + 'export { default as securityAudit } from "./security-audit.js"', + 'export { default as formatCode } from "./format-code.js"', + 'export { default as lintCheck } from "./lint-check.js"', + 'export { default as gitSummary } from "./git-summary.js"', + 'export { default as changedFiles } from "./changed-files.js"', + 'export { default as dependencyAnalyzer } from "./dependency-analyzer.js"', + '', + ].join('\n'); + const digest = content => crypto.createHash('sha256').update(content).digest('hex'); + + for (const upgrade of [false, true]) { + writeFile(sourceRoot, '.opencode/tools/index.ts', legacyContent); + const homeDir = path.join(tempDir, upgrade ? 'repair-home' : 'uninstall-home'); + const projectRoot = path.join(tempDir, 'project'); + const plan = createManifestInstallPlan({ + sourceRoot, projectRoot, homeDir, target: 'opencode', moduleIds: ['fixture-core'], + }); + applyInstallPlan(plan); + const barrelPath = path.join(plan.targetRoot, 'tools', 'index.ts'); + const toolPath = path.join(plan.targetRoot, 'tools', 'run-tests.ts'); + const userPath = writeFile(plan.targetRoot, 'tools/user-note.txt', 'Keep my settings.\n'); + const installedState = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8')); + const recordedBarrel = installedState.operations.find(operation => operation.destinationPath === barrelPath); + assert.strictEqual(fs.readFileSync(barrelPath, 'utf8'), legacyContent); + assert.strictEqual(recordedBarrel.contentTransform, undefined, 'TypeScript imports are copied, not transformed'); + assert.strictEqual(recordedBarrel.contentSha256, digest(legacyContent)); + assert.ok(fs.existsSync(toolPath)); + assert.ok(!fs.existsSync(path.join(plan.targetRoot, 'tools', 'run-tests.js')), 'legacy import has no adjacent JavaScript file'); + + // Upgrade the repository independently of the existing managed home. + writeFile(sourceRoot, '.opencode/tools/index.ts', currentContent); + const lifecycleOptions = { repoRoot: sourceRoot, projectRoot, homeDir, targets: ['opencode'] }; + if (upgrade) { + const repaired = repairInstalledStates(lifecycleOptions); + assert.strictEqual(repaired.results.length, 1); + assert.strictEqual(repaired.results[0].status, 'repaired', JSON.stringify(repaired)); + assert.ok(repaired.results[0].repairedPaths.includes(barrelPath)); + const repairedContent = fs.readFileSync(barrelPath, 'utf8'); + assert.strictEqual(repairedContent, currentContent); + assert.ok(repairedContent.includes('from "./run-tests.ts"')); + assert.ok(!repairedContent.includes('.js"'), 'repair replaces legacy JavaScript specifiers'); + const repairedState = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8')); + const repairedBarrel = repairedState.operations.find(operation => operation.destinationPath === barrelPath); + assert.strictEqual(repairedBarrel.contentSha256, digest(currentContent)); + assert.notStrictEqual(repairedBarrel.contentSha256, recordedBarrel.contentSha256); + assert.strictEqual(fs.readFileSync(userPath, 'utf8'), 'Keep my settings.\n'); + } + + const modifiedContent = `${fs.readFileSync(toolPath, 'utf8')}\n// User test customisation.\n`; + fs.writeFileSync(toolPath, modifiedContent, 'utf8'); + const uninstalled = uninstallInstalledStates(lifecycleOptions); + assert.strictEqual(uninstalled.results.length, 1); + assert.strictEqual(uninstalled.results[0].status, 'partial', JSON.stringify(uninstalled)); + assert.ok(uninstalled.results[0].removedPaths.includes(barrelPath)); + assert.ok(!fs.existsSync(barrelPath), 'both legacy and repaired managed barrels are recognised'); + assert.deepStrictEqual(uninstalled.results[0].retainedPaths, [toolPath]); + assert.strictEqual(fs.readFileSync(toolPath, 'utf8'), modifiedContent); + assert.strictEqual(fs.readFileSync(userPath, 'utf8'), 'Keep my settings.\n'); + assert.ok(fs.existsSync(plan.installStatePath), 'modified managed content retains its ownership record'); + } + } finally { + cleanup(tempDir); + } + })) passed++; else failed++; + if (test('dedupeCopyFileOperations keeps the last writer per destination (issue #2414)', () => { // Mirrors the OpenCode command scenario: a generic commands/.md source // (preserve-relative-path) and an override .opencode/commands/.md source