160 lines
7.4 KiB
JavaScript
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 });
|
|
}
|