Lumi/scripts/verify-plugin-update-preserves-data.js
2026-07-18 02:56:03 +02:00

160 lines
7.4 KiB
JavaScript

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 });
}