273 lines
16 KiB
JavaScript
273 lines
16 KiB
JavaScript
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
|
|
const root = path.join(__dirname, "..");
|
|
const { dependencyIssues, ensureRuntimeDependencies, npmInvocation } = require("../src/services/dependency-manager");
|
|
const { targetForRequestedVersion } = require("../src/services/repo-update");
|
|
const { buildStatus } = require("../src/services/update-index");
|
|
const {
|
|
normalizeDeploymentBranch,
|
|
normalizeRepositoryRef,
|
|
parseDeploymentBranches
|
|
} = require("../src/services/update-repository");
|
|
const { buildCorePreservePaths } = require("../src/services/update-manager");
|
|
const {
|
|
pendingBundledPluginSync,
|
|
pendingDeploymentBranch
|
|
} = require("../src/services/updater");
|
|
|
|
function readJson(relativePath) {
|
|
return JSON.parse(fs.readFileSync(path.join(root, relativePath), "utf8"));
|
|
}
|
|
|
|
const releaseIndex = readJson("release-index.json");
|
|
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
|
assert.deepEqual(releaseVersions, ["0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
|
for (const release of releaseIndex.releases) {
|
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
|
assert.match(release.ref, /^refs\/tags\/v\d+\.\d+\.\d+$/);
|
|
assert.equal(release.data_policy, "preserve");
|
|
}
|
|
|
|
const currentRelease = releaseIndex.releases[0];
|
|
const packageVersion = readJson("package.json").version;
|
|
const coreManifest = readJson("update-manifest.json");
|
|
assert.equal(packageVersion, coreManifest.version);
|
|
assert.equal(coreManifest.channel, "stable");
|
|
assert.equal(packageVersion, "0.3.3");
|
|
assert.equal(currentRelease.version, "0.3.3");
|
|
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
|
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
|
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
|
}
|
|
for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
|
assert.equal(readJson(`plugins/${toolId}/tool_info.json`).version, version, `${toolId} release catalog version`);
|
|
}
|
|
|
|
const baseTarget = {
|
|
current_version: "0.2.4",
|
|
available_versions: [
|
|
{ version: "0.3.3", ref: "refs/tags/v0.3.3", rollback_safe: true },
|
|
{ version: "0.3.2", ref: "refs/tags/v0.3.2", rollback_safe: true },
|
|
{ version: "0.3.1", ref: "refs/tags/v0.3.1", rollback_safe: true },
|
|
{ version: "0.3.0", ref: "refs/tags/v0.3.0", rollback_safe: true },
|
|
{ version: "0.2.27", ref: "refs/tags/v0.2.27", rollback_safe: true },
|
|
{ version: "0.2.26", ref: "refs/tags/v0.2.26", rollback_safe: true },
|
|
{ version: "0.2.25", ref: "refs/tags/v0.2.25", rollback_safe: true },
|
|
{ version: "0.2.24", ref: "refs/tags/v0.2.24", rollback_safe: true },
|
|
{ version: "0.2.23", ref: "refs/tags/v0.2.23", rollback_safe: true },
|
|
{ version: "0.2.22", ref: "refs/tags/v0.2.22", rollback_safe: true },
|
|
{ version: "0.2.21", ref: "refs/tags/v0.2.21", rollback_safe: true },
|
|
{ version: "0.2.20", ref: "refs/tags/v0.2.20", rollback_safe: true },
|
|
{ version: "0.2.19", ref: "refs/tags/v0.2.19", rollback_safe: true },
|
|
{ version: "0.2.18", ref: "refs/tags/v0.2.18", rollback_safe: true },
|
|
{ version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true },
|
|
{ version: "0.2.16", ref: "refs/tags/v0.2.16", rollback_safe: true },
|
|
{ version: "0.2.15", ref: "refs/tags/v0.2.15", rollback_safe: true },
|
|
{ version: "0.2.14", ref: "refs/tags/v0.2.14", rollback_safe: true },
|
|
{ version: "0.2.13", ref: "refs/tags/v0.2.13", rollback_safe: true },
|
|
{ version: "0.2.12", ref: "refs/tags/v0.2.12", rollback_safe: true },
|
|
{ version: "0.2.11", ref: "refs/tags/v0.2.11", rollback_safe: true },
|
|
{ version: "0.2.10", ref: "refs/tags/v0.2.10", rollback_safe: true },
|
|
{ version: "0.2.9", ref: "refs/tags/v0.2.9", rollback_safe: true },
|
|
{ version: "0.2.8", ref: "refs/tags/v0.2.8", rollback_safe: true },
|
|
{ version: "0.2.7", ref: "refs/tags/v0.2.7", rollback_safe: true },
|
|
{ version: "0.2.6", ref: "refs/tags/v0.2.6", rollback_safe: true },
|
|
{ version: "0.2.5", ref: "refs/tags/v0.2.5", rollback_safe: true },
|
|
{ version: "0.2.4", ref: "refs/tags/v0.2.4", rollback_safe: true },
|
|
{ version: "0.2.3", ref: "refs/tags/v0.2.3", rollback_safe: true },
|
|
{ version: "0.2.2", ref: "refs/tags/v0.2.2", rollback_safe: true },
|
|
{ version: "0.2.1", ref: "refs/tags/v0.2.1", rollback_safe: true },
|
|
{ version: "0.2.0", ref: "refs/tags/v0.2.0", rollback_safe: true },
|
|
{ version: "0.1.9", ref: "refs/tags/v0.1.9", rollback_safe: true }
|
|
],
|
|
warnings: []
|
|
};
|
|
const selected = targetForRequestedVersion(baseTarget, "0.1.9", "Core version");
|
|
assert.equal(selected.safe_target_version, "0.1.9");
|
|
assert.equal(selected.source_branch, "refs/tags/v0.1.9");
|
|
assert.equal(selected.update_available, true);
|
|
assert.equal(selected.requires_manual_confirmation, true);
|
|
assert.throws(() => targetForRequestedVersion(baseTarget, "9.9.9", "Core version"), /not listed/);
|
|
assert.throws(() => normalizeRepositoryRef("--upload-pack=bad"), /Invalid/);
|
|
assert.throws(() => normalizeRepositoryRef("refs/tags/../main"), /Invalid/);
|
|
assert.equal(normalizeDeploymentBranch("main"), "main");
|
|
assert.equal(normalizeDeploymentBranch("refs/heads/experimental-ux"), "experimental-ux");
|
|
assert.throws(() => normalizeDeploymentBranch("feature/unsafe"), /main or an experimental-\*/);
|
|
assert.deepEqual(parseDeploymentBranches([
|
|
"1111111111111111111111111111111111111111\trefs/heads/main",
|
|
"2222222222222222222222222222222222222222\trefs/heads/experimental-ux",
|
|
"3333333333333333333333333333333333333333\trefs/heads/feature/not-deployable"
|
|
].join("\n")), [
|
|
{ name: "main", commit: "1111111111111111111111111111111111111111", channel: "stable" },
|
|
{ name: "experimental-ux", commit: "2222222222222222222222222222222222222222", channel: "experimental" }
|
|
]);
|
|
const completedExperimentalUpdate = {
|
|
branch: "experimental-ux",
|
|
last_update_status: "complete",
|
|
last_target_kind: "core",
|
|
last_target_version: "0.2.25",
|
|
last_snapshot_id: "snapshot-1"
|
|
};
|
|
assert.equal(pendingDeploymentBranch(completedExperimentalUpdate, "experimental", "0.2.25"), "experimental-ux");
|
|
assert.equal(pendingDeploymentBranch({ branch: "experimental-ux" }, "experimental", "0.2.25"), null, "metadata checks must not trigger deployment");
|
|
assert.equal(pendingDeploymentBranch({ ...completedExperimentalUpdate, deployed_branch: "experimental-ux" }, "experimental", "0.2.25"), null, "completed deployments must not loop");
|
|
assert.equal(pendingDeploymentBranch({ ...completedExperimentalUpdate, deployed_branch: "main" }, "experimental", "0.2.25"), "experimental-ux", "stable re-entry must complete the experimental deployment");
|
|
assert.equal(pendingDeploymentBranch(completedExperimentalUpdate, "main", "0.2.25"), null, "stable configuration must not bootstrap experimental code");
|
|
const completedLegacyCoreUpdate = {
|
|
branch: "refs/tags/v0.2.27",
|
|
last_update_status: "complete",
|
|
last_target_kind: "core",
|
|
last_target_version: "0.2.27",
|
|
last_snapshot_id: "snapshot-legacy-core"
|
|
};
|
|
assert.equal(
|
|
pendingBundledPluginSync(completedLegacyCoreUpdate, "0.2.27"),
|
|
"refs/tags/v0.2.27",
|
|
"a core update completed by the legacy updater must synchronize its bundled plugins on startup"
|
|
);
|
|
assert.equal(
|
|
pendingBundledPluginSync({
|
|
...completedLegacyCoreUpdate,
|
|
bundled_plugins_synced_for_version: "0.2.27"
|
|
}, "0.2.27"),
|
|
null,
|
|
"completed bundled-plugin synchronization must be idempotent"
|
|
);
|
|
assert.equal(
|
|
pendingBundledPluginSync({ branch: "refs/tags/v0.2.27" }, "0.2.27"),
|
|
null,
|
|
"repository metadata alone must never trigger startup writes"
|
|
);
|
|
|
|
const corrected = buildStatus({
|
|
kind: "core",
|
|
currentVersion: "1.2.0",
|
|
manifest: readJson("update-manifest.json"),
|
|
changelog: [],
|
|
sourceBranch: "main",
|
|
channel: "stable"
|
|
});
|
|
assert.equal(corrected.version_correction, true);
|
|
assert.equal(corrected.safe_target_version, "0.3.3");
|
|
assert.equal(corrected.update_available, true);
|
|
assert.equal(corrected.blocked, false);
|
|
|
|
const preserve = buildCorePreservePaths(root);
|
|
for (const required of ["data", "plugins", "node_modules", "knowledge/community", "knowledge/corrections", ".env"]) {
|
|
assert.equal(preserve.has(required), true, `${required} must survive core updates`);
|
|
}
|
|
|
|
const serverSource = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
|
assert.doesNotMatch(serverSource, /require\([^\n]*plugins\/okf\/backend/);
|
|
assert.match(serverSource, /global\.lumiFrameworks\?\.okf\?\.saveCorrection/);
|
|
const repoUpdateSource = fs.readFileSync(path.join(root, "src", "services", "repo-update.js"), "utf8");
|
|
assert.match(repoUpdateSource, /Plugin update failed while \$\{stage\}/);
|
|
assert.match(repoUpdateSource, /last_update_stage: stage/);
|
|
assert.match(repoUpdateSource, /stage = "preparing the selected repository version"[\s\S]*verifyPluginFiles\(pluginId, managed\.path, target\.safe_target_version\)[\s\S]*stage = "creating the rollback snapshot"/);
|
|
assert.match(repoUpdateSource, /stage = "replacing plugin files"[\s\S]*applyPluginFiles\(path\.join\(managed\.path, "plugins", pluginId\)/);
|
|
assert.match(repoUpdateSource, /last_update_stage: "complete",\s*last_error: null/);
|
|
assert.match(repoUpdateSource, /async function deployCoreBranch/);
|
|
assert.match(repoUpdateSource, /update_method: "branch_deploy"/);
|
|
assert.match(repoUpdateSource, /setSetting\("git_branch", managed\.branch\)/);
|
|
assert.match(repoUpdateSource, /applyCoreFilesFromDirectory\(managed\.path\)[\s\S]*verifyCoreFiles\(repoRoot, target\.version\)/);
|
|
assert.match(
|
|
repoUpdateSource,
|
|
/async function applyCoreUpdate[\s\S]*include_plugin_code: true[\s\S]*target_plugin_ids: targetPluginIds[\s\S]*applyCoreFilesFromDirectory\(managed\.path\)[\s\S]*applyPluginFiles\(path\.join\(managed\.path, "plugins", pluginId\), pluginId, \{ preserveData: true \}\)[\s\S]*syncPluginRegistry\(\)/,
|
|
"stable core updates must transactionally carry bundled plugins and newly introduced Companion plugins"
|
|
);
|
|
assert.match(
|
|
repoUpdateSource,
|
|
/async function synchronizeBundledPluginsForCompletedCoreUpdate[\s\S]*ensureManagedRepo\(configuredRemote, sourceRef\)[\s\S]*include_plugin_code: true[\s\S]*applyPluginFiles\(path\.join\(managed\.path, "plugins", pluginId\), pluginId, \{ preserveData: true \}\)[\s\S]*bundled_plugins_synced_for_version: currentVersion[\s\S]*restoreSnapshot\(snapshotRecord\.id/,
|
|
"legacy core updates must complete bundled-plugin synchronization transactionally on startup"
|
|
);
|
|
const updateManagerSource = fs.readFileSync(path.join(root, "src", "services", "update-manager.js"), "utf8");
|
|
assert.match(updateManagerSource, /from_source_branch: snapshot\.metadata\?\.from_source_branch/);
|
|
assert.match(updateManagerSource, /target_commit: snapshot\.metadata\?\.target_commit/);
|
|
assert.match(updateManagerSource, /includes_plugin_code: Boolean\(snapshot\.bundledPluginsZip\)/);
|
|
assert.match(updateManagerSource, /plugins\.zip/);
|
|
assert.match(repoUpdateSource, /target_plugin_ids: targetPluginIds/);
|
|
assert.match(repoUpdateSource, /applyPluginFiles\(path\.join\(managed\.path, "plugins", pluginId\), pluginId, \{ preserveData: true \}\)/);
|
|
const updaterSource = fs.readFileSync(path.join(root, "src", "services", "updater.js"), "utf8");
|
|
const mainSource = fs.readFileSync(path.join(root, "src", "main.js"), "utf8");
|
|
assert.match(updaterSource, /function registerRestartHandler/);
|
|
assert.match(updaterSource, /async function completePendingBranchDeployment/);
|
|
assert.match(updaterSource, /async function completePendingBundledPluginSync/);
|
|
assert.match(updaterSource, /state\.last_update_status === "complete"[\s\S]*state\.last_target_kind === "core"[\s\S]*state\.last_target_version === localVersion/);
|
|
assert.match(mainSource, /registerRestartHandler\(\(\) => shutdown\(10\)\)/);
|
|
assert.match(mainSource, /await completePendingBranchDeployment\(configuredRemote, configuredBranch\)/);
|
|
assert.match(mainSource, /await completePendingBundledPluginSync\(configuredRemote\)/);
|
|
assert.match(mainSource, /overlayConnectorManager\.stop\(\)[\s\S]*stopPlugins\(\)[\s\S]*process\.exit\(Number\(exitCode\) === 10 \? 10 : 0\)/);
|
|
const updatesView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-updates.ejs"), "utf8");
|
|
assert.match(serverSource, /app\.post\("\/admin\/updates\/core\/deploy-branch"/);
|
|
assert.match(updatesView, /Running code branch/);
|
|
assert.match(updatesView, /Return safely to main/);
|
|
assert.match(updatesView, /Emergency restore previous backup/);
|
|
const connectorSource = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8");
|
|
assert.match(connectorSource, /try\s*{[\s\S]*require\("obs-websocket-js"\)/);
|
|
|
|
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-dependencies-"));
|
|
try {
|
|
fs.writeFileSync(path.join(fixture, "package.json"), JSON.stringify({ dependencies: { example: "^1.0.0" } }));
|
|
fs.writeFileSync(path.join(fixture, "package-lock.json"), JSON.stringify({
|
|
lockfileVersion: 3,
|
|
packages: { "": {}, "node_modules/example": { version: "1.2.3" } }
|
|
}));
|
|
assert.deepEqual(dependencyIssues(fixture), [{ dependency: "example", reason: "missing", expected: "1.2.3", optional: false }]);
|
|
let invocation = null;
|
|
const result = ensureRuntimeDependencies({
|
|
rootPath: fixture,
|
|
platform: "linux",
|
|
runner(command, args) {
|
|
invocation = { command, args };
|
|
const packageDir = path.join(fixture, "node_modules", "example");
|
|
fs.mkdirSync(packageDir, { recursive: true });
|
|
fs.writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ version: "1.2.3" }));
|
|
return { status: 0, stdout: "", stderr: "" };
|
|
}
|
|
});
|
|
assert.equal(result.ready, true);
|
|
assert.equal(result.installed, true);
|
|
assert.equal(invocation.args.includes("ci"), true);
|
|
assert.deepEqual(dependencyIssues(fixture), []);
|
|
} finally {
|
|
fs.rmSync(fixture, { recursive: true, force: true });
|
|
}
|
|
|
|
const windowsNpmFixture = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-windows-npm-"));
|
|
try {
|
|
const fakeNode = path.join(windowsNpmFixture, "node.exe");
|
|
const fakeNpmCli = path.join(windowsNpmFixture, "node_modules", "npm", "bin", "npm-cli.js");
|
|
fs.mkdirSync(path.dirname(fakeNpmCli), { recursive: true });
|
|
fs.writeFileSync(fakeNpmCli, "");
|
|
const invocation = npmInvocation({ platform: "win32", execPath: fakeNode, env: {} });
|
|
assert.equal(invocation.command, fakeNode);
|
|
assert.deepEqual(invocation.args, [fakeNpmCli]);
|
|
assert.equal(invocation.args.includes("npm.cmd"), false);
|
|
} finally {
|
|
fs.rmSync(windowsNpmFixture, { recursive: true, force: true });
|
|
}
|
|
|
|
const optionalFixture = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-optional-dependency-"));
|
|
try {
|
|
fs.writeFileSync(path.join(optionalFixture, "package.json"), JSON.stringify({ optionalDependencies: { optional: "^1.0.0" } }));
|
|
fs.writeFileSync(path.join(optionalFixture, "package-lock.json"), JSON.stringify({
|
|
lockfileVersion: 3,
|
|
packages: { "": {}, "node_modules/optional": { version: "1.0.0", optional: true } }
|
|
}));
|
|
const degraded = ensureRuntimeDependencies({
|
|
rootPath: optionalFixture,
|
|
platform: "linux",
|
|
runner: () => ({ status: 1, stderr: "offline" })
|
|
});
|
|
assert.equal(degraded.ready, true);
|
|
assert.equal(degraded.optional_failed, true);
|
|
} finally {
|
|
fs.rmSync(optionalFixture, { recursive: true, force: true });
|
|
}
|
|
|
|
console.log("Update system verification passed: immutable refs, exact versions, preserved data, dependency repair, and optional boundaries.");
|