Files
factorio-clusterio/test/lib/rce_ops.js
2026-10-27 21:32:51 +00:00

388 lines
15 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import fsSync from "node:fs";
import * as lib from "@clusterio/lib";
import "../setup_logging.js";
// Installation with dependencies installed from a path, an alias, git and the registry. Pinned
// installs and updates must not replace the ones not from the registry.
function useLocalDependencies() {
const cwd = process.cwd();
const dir = path.resolve("temp", "test", "rce_ops_local_dependency");
const _fetch = global.fetch;
before(async function() {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ dependencies: {
"linked": "file:../linked",
"aliased": "npm:other@^1.0.0",
"from-git": "github:user/from-git",
"published": "^1.0.0",
"optional": "^1.0.0",
}, optionalDependencies: {
"optional": "github:user/optional",
} }));
process.chdir(dir);
global.fetch = function() { return { ok: true }; };
});
after(function() {
process.chdir(cwd);
global.fetch = _fetch;
});
return dir;
}
describe("lib/rce_ops", function() {
describe("updatePackage()", function() {
it("runs", async function() {
// In dev env no side effects occur, so best we can do is check it doesn't error
await lib.updatePackage("foo");
});
});
describe("installPackage()", function() {
it("runs", async function() {
// In dev env no side effects occur, so best we can do is check it doesn't error
await lib.installPackage("foo");
});
describe("npm-operation.lock", function() {
const lockPath = "npm-operation.lock";
const inProgress = /Another package operation is already in progress/;
const audit = lib.logger.audit;
const recoveryPath = `${lockPath}.recovery`;
afterEach(async function() {
lib.logger.audit = audit;
await fs.rm(lockPath, { force: true });
await fs.rm(recoveryPath, { force: true });
});
it("rejects while another operation is in progress", async function() {
let nested;
lib.logger.audit = function() {
lib.logger.audit = audit;
nested = lib.installPackage("bar");
};
await lib.installPackage("foo");
await assert.rejects(nested, inProgress);
// The lock is released once the operation finishes
await lib.installPackage("foo");
await assert.rejects(fs.access(lockPath));
});
it("runs only one of several operations started together", async function() {
const results = await Promise.allSettled([
lib.installPackage("foo"), lib.updatePackage("bar"), lib.installPackage("baz"),
]);
assert.deepEqual(results.map(result => result.status), ["fulfilled", "rejected", "rejected"]);
for (const result of results.slice(1)) {
assert.match(result.reason.message, inProgress);
}
});
it("holds the lock file while the operation runs", async function() {
let lockContent;
lib.logger.audit = function() {
// eslint-disable-next-line node/no-sync
lockContent = fsSync.readFileSync(lockPath, "utf8");
};
await lib.installPackage("foo");
assert.equal(lockContent, `${process.pid} ${performance.timeOrigin}\n`);
await assert.rejects(fs.access(lockPath));
});
it("releases the lock when the operation fails", async function() {
lib.logger.audit = function() {
throw new Error("operation failed");
};
await assert.rejects(lib.installPackage("foo"), /operation failed/);
lib.logger.audit = audit;
await assert.rejects(fs.access(lockPath));
await lib.installPackage("foo");
});
it("leaves a lock it does not own in place", async function() {
lib.logger.audit = function() {
// eslint-disable-next-line node/no-sync
fsSync.writeFileSync(lockPath, `${process.ppid}\n`);
};
await lib.installPackage("foo");
assert.equal(await fs.readFile(lockPath, "utf8"), `${process.ppid}\n`);
});
it("rejects while another process holds the lock", async function() {
// A standalone controller and host share the installation directory
await fs.writeFile(lockPath, `${process.ppid}\n`);
await assert.rejects(lib.installPackage("foo"), inProgress);
assert.equal(await fs.readFile(lockPath, "utf8"), `${process.ppid}\n`);
});
it("rejects while another copy of lib in this process holds the lock", async function() {
const rceOpsUrl = new URL("../../packages/lib/dist/node/src/rce_ops.js", import.meta.url);
const otherCopy = await import(`${rceOpsUrl.href}?copy`);
await lib.installPackage("foo", async () => {
await assert.rejects(otherCopy.installPackage("bar"), inProgress);
});
});
it("names the process holding the lock", async function() {
await fs.writeFile(lockPath, `${process.ppid} 1234.5\n`);
await assert.rejects(lib.installPackage("foo"), new RegExp(`held by process ${process.ppid}\\)`));
});
it("reports an interrupted recovery", async function() {
await fs.writeFile(lockPath, "999999999\n");
await fs.writeFile(recoveryPath, "999999999\n");
await assert.rejects(lib.installPackage("foo"), /remove .*npm-operation\.lock\.recovery/);
});
});
});
describe("packageOperationRunning()", function() {
const lockPath = "npm-operation.lock";
const audit = lib.logger.audit;
afterEach(async function() {
lib.logger.audit = audit;
await fs.rm(lockPath, { force: true });
});
it("reports whether an operation is running", async function() {
assert.equal(await lib.packageOperationRunning(), false);
let running;
lib.logger.audit = function() {
lib.logger.audit = audit;
running = lib.packageOperationRunning();
};
await lib.installPackage("foo");
assert.equal(await running, true);
assert.equal(await lib.packageOperationRunning(), false);
await fs.writeFile(lockPath, `${process.ppid}\n`);
assert.equal(await lib.packageOperationRunning(), true);
await fs.writeFile(lockPath, "999999999\n");
assert.equal(await lib.packageOperationRunning(), false);
});
});
describe("handleUpdateAll()", function() {
it("updates the core package and all npm plugins in one command", async function() {
const _audit = lib.logger.audit;
const messages = [];
lib.logger.audit = (msg) => { messages.push(msg); };
try {
await lib.handleUpdateAll("core", [{ npmPackage: "foo" }, { name: "local" }, { npmPackage: "bar" }]);
} finally {
lib.logger.audit = _audit;
}
assert.deepEqual(messages, ["RCE | npm update --save core foo bar"]);
});
});
describe("handlePluginUpdate()", function() {
it("runs", async function() {
await lib.handlePluginUpdate("foo", [{ npmPackage: "foo" }]);
});
it("rejects when plugin not installed", async function() {
await assert.rejects(
lib.handlePluginUpdate("foo", []),
/Plugin foo is not installed on this machine/
);
await assert.rejects(
lib.handlePluginUpdate("foo@1.2.3", []),
/Plugin foo is not installed on this machine/
);
});
it("updates to a pinned version", async function() {
const _fetch = global.fetch;
let calledWith = "";
global.fetch = function(url) {
calledWith = url;
return { ok: true };
};
try {
await lib.handlePluginUpdate("@scope/foo@1.2.3", [{ npmPackage: "@scope/foo" }]);
assert.equal(calledWith, "https://registry.npmjs.com/@scope/foo/1.2.3");
global.fetch = function() { return { ok: false }; };
await assert.rejects(
lib.handlePluginUpdate("foo@9.9.9", [{ npmPackage: "foo" }]),
/Unknown plugin: foo@9.9.9/,
);
} finally {
global.fetch = _fetch;
}
});
describe("over a dependency not from the registry", function() {
const dir = useLocalDependencies();
it("rejects pinned updates of dependencies not from the registry", async function() {
for (const name of ["linked", "aliased", "from-git", "optional"]) {
const error = new RegExp(`${name} is not installed from npm on this machine`);
await assert.rejects(lib.handlePluginUpdate(`${name}@2.0.0`, [{ npmPackage: name }]), error);
}
await lib.handlePluginUpdate("published@2.0.0", [{ npmPackage: "published" }]);
});
it("rejects pinned updates of a plugin another plugin installs from git", async function() {
const modules = path.join(dir, "node_modules");
const plugin = (name, dependencies) => fs.mkdir(path.join(modules, name), { recursive: true })
.then(() => fs.writeFile(path.join(modules, name, "package.json"), JSON.stringify({
name, version: "1.0.0", keywords: ["clusterio-plugin"], dependencies,
})));
await plugin("published", { child: "github:user/child" });
await plugin("child", {});
try {
await assert.rejects(
lib.handlePluginUpdate("child@2.0.0", [{ npmPackage: "child" }]),
/child is not installed from npm on this machine/,
);
} finally {
await fs.rm(modules, { recursive: true, force: true });
}
});
it("rejects pinned updates of a plugin an optional dependency installs from git", async function() {
const modules = path.join(dir, "node_modules");
const plugin = (name, packageJson) => fs.mkdir(path.join(modules, name), { recursive: true })
.then(() => fs.writeFile(path.join(modules, name, "package.json"), JSON.stringify({
name, version: "1.0.0", keywords: ["clusterio-plugin"], ...packageJson,
})));
await plugin("published", {
dependencies: { child: "^1.0.0" },
optionalDependencies: { child: "github:user/child" },
});
await plugin("child", {});
try {
await assert.rejects(
lib.handlePluginUpdate("child@2.0.0", [{ npmPackage: "child" }]),
/child is not installed from npm on this machine/,
);
} finally {
await fs.rm(modules, { recursive: true, force: true });
}
});
it("checks the source again once it holds the lock", async function() {
const packageJsonPath = path.join(dir, "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
// Another operation switches it to git while the registry is being checked
global.fetch = async function() {
const packageJson = JSON.parse(original);
packageJson.dependencies.published = "github:user/published";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
return { ok: true };
};
try {
const error = /published is not installed from npm on this machine/;
const update = lib.handlePluginUpdate("published@2.0.0", [{ npmPackage: "published" }]);
await assert.rejects(update, error);
} finally {
global.fetch = function() { return { ok: true }; };
await fs.writeFile(packageJsonPath, original);
}
});
});
});
describe("handlePluginInstall()", function() {
const _fetch = global.fetch;
before(function() {
global.fetch = function() { return { ok: false }; };
});
after(function() {
global.fetch = _fetch;
});
it("runs", async function() {
let calledWith = "";
global.fetch = function(url) {
calledWith = url;
return { ok: true };
};
await lib.handlePluginInstall("foo");
assert.equal(calledWith, "https://registry.npmjs.com/foo");
});
it("installs a pinned version", async function() {
let calledWith = "";
global.fetch = function(url) {
calledWith = url;
return { ok: true };
};
assert.equal(await lib.handlePluginInstall("foo@1.2.3"), "foo");
assert.equal(calledWith, "https://registry.npmjs.com/foo/1.2.3");
assert.equal(await lib.handlePluginInstall("@scope/foo@1.0.0-alpha.1"), "@scope/foo");
assert.equal(calledWith, "https://registry.npmjs.com/@scope/foo/1.0.0-alpha.1");
await lib.handlePluginInstall("@scope/foo");
assert.equal(calledWith, "https://registry.npmjs.com/@scope/foo");
});
it("rejects unregistered versions", async function() {
global.fetch = function() { return { ok: false }; };
await assert.rejects(lib.handlePluginInstall("foo@9.9.9"), /Unknown plugin: foo@9.9.9/);
});
it("rejects invalid versions", async function() {
await assert.rejects(lib.handlePluginInstall("foo@"), /Invalid plugin version: /);
await assert.rejects(lib.handlePluginInstall("foo@-1"), /Invalid plugin version: -1/);
});
it("rejects when name too long", async function() {
const pluginName = "a".repeat(215);
await assert.rejects(
lib.handlePluginInstall(pluginName),
{ message: `Invalid plugin name: ${pluginName}` }
);
});
it("rejects specs npm would read as a path, repository or option", async function() {
let fetched = false;
global.fetch = function() {
fetched = true;
return { ok: true };
};
for (const spec of [
".", "..", "./foo", "../foo", "foo/latest", "@clusterio/lib/latest", "-/ping", "-foo", ".foo",
"_foo", "@scope", "@scope/", "@/foo", "@scope/foo/bar", "foo/bar@1.0.0", "github:foo/bar",
]) {
await assert.rejects(lib.handlePluginInstall(spec), /Invalid plugin name/, spec);
}
assert.equal(fetched, false);
});
it("rejects when invalid symbol present", async function() {
const pluginName = "?";
await assert.rejects(
lib.handlePluginInstall(pluginName),
{ message: `Invalid plugin name: ${pluginName}` },
);
});
it("rejects unregistered packages", async function() {
let calledWith = "";
global.fetch = function(url) {
calledWith = url;
return { ok: false };
};
await assert.rejects(lib.handlePluginInstall("foo"), /Unknown plugin: foo/);
assert.equal(calledWith, "https://registry.npmjs.com/foo");
});
describe("over a dependency not from the registry", function() {
const dir = useLocalDependencies();
it("rejects pinned installs of dependencies not from the registry", async function() {
for (const name of ["linked", "aliased", "from-git", "optional"]) {
const error = new RegExp(`${name} is not installed from npm on this machine`);
await assert.rejects(lib.handlePluginInstall(`${name}@2.0.0`), error);
}
await lib.handlePluginInstall("published@2.0.0");
});
it("checks the source again once it holds the lock", async function() {
const packageJsonPath = path.join(dir, "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
// Another operation switches it to git while the registry is being checked
global.fetch = async function() {
const packageJson = JSON.parse(original);
packageJson.dependencies.published = "github:user/published";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
return { ok: true };
};
try {
const error = /published is not installed from npm on this machine/;
await assert.rejects(lib.handlePluginInstall("published@2.0.0"), error);
} finally {
global.fetch = function() { return { ok: true }; };
await fs.writeFile(packageJsonPath, original);
}
});
});
});
});