const assert = require("assert"); const fs = require("fs"); const os = require("os"); const path = require("path"); const { pruneEntries, replacePluginDirectory, resetDirectoryForFullUpdate } = require("../src/services/update-manager"); const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-update-test-")); try { const source = path.join(root, "source"); const target = path.join(root, "target"); fs.mkdirSync(path.join(source, "data", "config"), { recursive: true }); fs.mkdirSync(path.join(target, "data", "models"), { recursive: true }); fs.writeFileSync(path.join(source, "plugin.json"), '{"id":"test"}'); fs.writeFileSync(path.join(source, "index.js"), "module.exports = 'new';"); fs.writeFileSync(path.join(source, "data", "config", "default.json"), '{"default":true}'); fs.writeFileSync(path.join(target, "index.js"), "module.exports = 'old';"); fs.writeFileSync(path.join(target, "stale.js"), "stale"); const model = path.join(target, "data", "models", "large.gguf"); const descriptor = fs.openSync(model, "w"); fs.ftruncateSync(descriptor, 3 * 1024 * 1024 * 1024); fs.closeSync(descriptor); replacePluginDirectory(source, target, { preserveData: true }); assert.equal(fs.readFileSync(path.join(target, "index.js"), "utf8"), "module.exports = 'new';"); assert.equal(fs.existsSync(path.join(target, "stale.js")), false); assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024); assert.equal(fs.existsSync(path.join(target, "data", "config", "default.json")), false); const failedSource = path.join(root, "failed-source"); fs.mkdirSync(failedSource, { recursive: true }); fs.writeFileSync(path.join(failedSource, "plugin.json"), '{"id":"test"}'); fs.writeFileSync(path.join(failedSource, "index.js"), "module.exports = 'must-not-land';"); const originalRename = fs.renameSync; fs.renameSync = (from, to) => { if (String(from).includes(".target.update-") && to === target) { throw Object.assign(new Error("simulated install lock"), { code: "EPERM" }); } return originalRename(from, to); }; try { assert.throws( () => replacePluginDirectory(failedSource, target, { preserveData: true }), /simulated install lock/ ); } finally { fs.renameSync = originalRename; } assert.equal(fs.readFileSync(path.join(target, "index.js"), "utf8"), "module.exports = 'new';"); assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024); assert.equal(fs.readdirSync(root).some((name) => name.includes(".target.update-") || name.includes(".target.backup-")), false); const install = path.join(root, "install"); for (const relative of [ "data/feedback/report.json", "data/lumi_ai/models/model.gguf", "plugins/example/data/settings.json", "knowledge/community/local.md", "knowledge/corrections/fix.md", "knowledge/core/generated.md", "knowledge/core/local.md", "knowledge/plugins/generated.md", "config/local.json", "src/stale.js" ]) { const file = path.join(install, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, relative); } fs.writeFileSync( path.join(install, "knowledge", "core", "generated.md"), "---\ngenerated: true\neditable: false\n---\ngenerated" ); fs.writeFileSync( path.join(install, "knowledge", "plugins", "generated.md"), "---\ngenerated: true\neditable: false\n---\ngenerated" ); fs.writeFileSync( path.join(install, "knowledge", "core", "local.md"), "---\ngenerated: false\neditable: true\n---\nlocal" ); resetDirectoryForFullUpdate(install); for (const relative of [ "data/feedback/report.json", "data/lumi_ai/models/model.gguf", "plugins/example/data/settings.json", "knowledge/community/local.md", "knowledge/corrections/fix.md", "knowledge/core/local.md", "config/local.json" ]) { assert.equal(fs.existsSync(path.join(install, relative)), true, `${relative} should be preserved`); } assert.equal(fs.existsSync(path.join(install, "knowledge/core/generated.md")), false); assert.equal(fs.existsSync(path.join(install, "knowledge/plugins/generated.md")), false); assert.equal(fs.existsSync(path.join(install, "src/stale.js")), false); const snapshotRoot = path.join(root, "snapshots"); const now = Date.now(); const makeSnapshot = (id, createdAt, type = "bot", pluginId = null) => { const snapshotPath = path.join(snapshotRoot, id); fs.mkdirSync(snapshotPath, { recursive: true }); fs.writeFileSync(path.join(snapshotPath, type === "bot" ? "core.zip" : "plugin.zip"), "compressed-code"); return { id, type, pluginId, target_kind: type, target_id: pluginId, status: "available", createdAt, path: snapshotPath }; }; const newestCore = makeSnapshot("core-new", now); const secondCore = makeSnapshot("core-second", now - 1000); const excessCore = makeSnapshot("core-excess", now - 2000); const pluginSnapshot = makeSnapshot("plugin-new", now - 3000, "plugin", "example"); const stalePlugin = makeSnapshot("plugin-stale", now - 31 * 24 * 60 * 60 * 1000, "plugin", "old"); fs.mkdirSync(path.join(newestCore.path, "full", "data", "lumi_ai", "models"), { recursive: true }); fs.writeFileSync(path.join(newestCore.path, "full", "data", "lumi_ai", "models", "model.gguf"), "must-not-remain"); fs.writeFileSync(path.join(newestCore.path, "app.db"), "database-backup"); const retained = pruneEntries( [newestCore, secondCore, excessCore, pluginSnapshot, stalePlugin], { now, retention: { max_age_days: 30, max_per_target: 2 } } ); assert.deepEqual(retained.map((entry) => entry.id).sort(), ["core-new", "core-second", "plugin-new"]); assert.equal(fs.existsSync(path.join(newestCore.path, "full")), false); assert.equal(fs.existsSync(path.join(newestCore.path, "app.db")), false); assert.equal(fs.existsSync(path.join(newestCore.path, "database.zip")), true); assert.equal(fs.existsSync(excessCore.path), false); assert.equal(fs.existsSync(stalePlugin.path), false); assert.equal(fs.existsSync(pluginSnapshot.path), true); const managerSource = fs.readFileSync(path.join(__dirname, "..", "src", "services", "update-manager.js"), "utf8"); const updateDocs = fs.readFileSync(path.join(__dirname, "..", "docs", "updates.md"), "utf8"); const updateView = fs.readFileSync(path.join(__dirname, "..", "src", "web", "views", "admin-updates.ejs"), "utf8"); const mainSource = fs.readFileSync(path.join(__dirname, "..", "src", "main.js"), "utf8"); assert(managerSource.includes("Automatic rollback also failed")); assert.equal((managerSource.match(/restoreSnapshot\(record\.id/g) || []).length >= 2, true); assert.equal(managerSource.includes("snapshotFullInstall(fullPath)"), false); assert(managerSource.includes("database.zip")); assert(managerSource.includes("DEFAULT_SNAPSHOT_RETENTION_DAYS = 30")); assert(updateView.includes("Automatic snapshot cleanup")); assert(updateView.includes('name="max_age_days"')); assert(updateView.includes('name="max_per_target"')); assert(mainSource.includes("cleanupSnapshots();")); assert(updateDocs.includes("Preserved Local Data")); assert(updateDocs.includes("knowledge/community/")); assert(updateDocs.includes("AI models/runtimes")); assert(updateDocs.includes("LLM model files")); console.log("Update preservation verification passed: protected data, compressed snapshots, retention cleanup, transactional replacement, and failed-update rollback wiring."); } finally { fs.rmSync(root, { recursive: true, force: true }); }