Files
factorio-clusterio/test/create.js
T
Danielv123 e5701a8dae Approve standalone hosts like any other host
A standalone install no longer adds its host to the controller database
by signing a token and writing hosts.json itself. The host asks to join
when it first starts and is approved once with the link it prints.
2026-10-30 23:15:22 +01:00

230 lines
9.5 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { escapeArg } from "../packages/create/escape_arg.js";
const execFileAsync = promisify(execFile);
const installer = path.resolve(import.meta.dirname, "../packages/create/create.js");
const discoveryUrl = new URL("../packages/lib/dist/node/src/load_plugin_list.js", import.meta.url).href;
describe("create installer CLI", function() {
this.timeout(10000);
let directory;
beforeEach(async function() {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio install #"));
await fs.writeFile(path.join(directory, "package.json"), JSON.stringify({
name: "clusterio-install", private: true,
}));
});
afterEach(async function() {
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
});
async function addPlugin(name, type, source) {
const pluginDir = path.join(directory, "node_modules", name);
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(path.join(pluginDir, "package.json"), JSON.stringify({
name, version: "1.0.0", type, main: "index.js", keywords: ["clusterio-plugin"],
}));
await fs.writeFile(path.join(pluginDir, "index.js"), source);
const packagePath = path.join(directory, "package.json");
const pkg = JSON.parse(await fs.readFile(packagePath));
pkg.dependencies = { ...pkg.dependencies, [name]: "1.0.0" };
await fs.writeFile(packagePath, JSON.stringify(pkg));
}
function runInstaller(plugins, installFails = false) {
// Only replace npm's subprocess: fixtures stand in for downloaded packages.
// Run the actual CLI from outside the installation, like npm init does.
const mockNpm = `
import assert from "node:assert/strict";
import childProcess from "node:child_process";
import { PassThrough } from "node:stream";
childProcess.execFile = (command, options, callback) => {
assert.match(command, /^npm(?:\\.cmd)? install /);
const stdout = new PassThrough();
const stderr = new PassThrough();
queueMicrotask(() => {
stdout.end();
stderr.end();
callback(${installFails} ? new Error("npm install failed") : null, "", "");
});
return { stdout, stderr };
};
`;
return execFileAsync(process.execPath, [
"--import", `data:text/javascript,${encodeURIComponent(mockNpm)}`,
installer, "--mode", "plugins",
...(process.platform === "linux" ? ["--allow-install-as-root"] : []),
"--plugins", ...plugins,
], { cwd: directory });
}
it("installs plugins without importing them or writing a plugin list", async function() {
await addPlugin("@installer-test/deferred", "module", 'throw new Error("installer must not import plugins");');
const { stdout } = await runInstaller(["@installer-test/deferred"]);
assert.match(stdout, /Successfully installed plugins/);
await assert.rejects(fs.access(path.join(directory, "plugin-list.json")), { code: "ENOENT" });
});
[false, true].forEach(existingList => {
it(`defers registration to discovery ${existingList ? "preserving existing entries" : "with no list"}`,
async function() {
await addPlugin("@installer-test/esm", "module", 'export const plugin = { name: "esm_plugin" };');
await addPlugin("@installer-test/commonjs", "commonjs", 'exports.plugin = { name: "cjs_plugin" };');
const listPath = path.join(directory, "plugin-list.json");
const previousEntries = existingList ? [["manual", "./plugins/manual"]] : [];
const previousContents = JSON.stringify(previousEntries);
if (existingList) {
await fs.writeFile(listPath, previousContents);
}
const { stdout } = await runInstaller(["@installer-test/esm", "@installer-test/commonjs"]);
assert.match(stdout, /Successfully installed plugins/);
if (existingList) {
assert.equal(await fs.readFile(listPath, "utf8"), previousContents);
} else {
await assert.rejects(fs.access(listPath), { code: "ENOENT" });
}
// Use the real discovery function invoked by controller/host/ctl commands.
await execFileAsync(process.execPath, [
"--input-type=module", "--eval",
`import { loadPluginList } from ${JSON.stringify(discoveryUrl)};
await loadPluginList("plugin-list.json");`,
], { cwd: directory });
assert.deepEqual(JSON.parse(await fs.readFile(listPath)), [
...previousEntries,
["esm_plugin", "@installer-test/esm"],
["cjs_plugin", "@installer-test/commonjs"],
]);
}
);
});
it("exits with failure when npm installation fails", async function() {
await assert.rejects(runInstaller(["@installer-test/missing"], true), err => {
assert.equal(err.code, 1);
assert.match(err.stdout + err.stderr, /Failed to install: npm install failed/);
assert.doesNotMatch(err.stdout, /Successfully installed/);
return true;
});
});
it("exits with failure on an unexpected installer error", async function() {
await fs.writeFile(path.join(directory, "package.json"), "null");
await assert.rejects(runInstaller([]), err => {
assert.equal(err.code, 1);
assert.match(err.stdout + err.stderr, /TypeError/);
assert.doesNotMatch(err.stdout, /Successfully installed/);
return true;
});
});
describe("host setup", function() {
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const hostBin = path.join("node_modules", ".bin", `clusteriohost${scriptExt}`);
const controllerBin = path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`);
const hostSet = (field, value) => `${hostBin} config set ${field} ${escapeArg(String(value))}`;
const controllerUrl = "http://controller.example.com:8080/";
function run(args, env = {}) {
// Record the commands for npm and the clusterio CLIs instead of running them.
const mockExec = `
import childProcess from "node:child_process";
import fs from "node:fs";
import { PassThrough } from "node:stream";
childProcess.execFile = (command, options, callback) => {
fs.appendFileSync(process.env.CREATE_TEST_COMMANDS, JSON.stringify(command) + "\\n");
const stdout = new PassThrough();
const stderr = new PassThrough();
queueMicrotask(() => {
stdout.end();
stderr.end();
callback(null, "", "");
});
return { stdout, stderr };
};
`;
return execFileAsync(process.execPath, [
"--import", `data:text/javascript,${encodeURIComponent(mockExec)}`,
installer,
...(process.platform === "linux" ? ["--allow-install-as-root"] : []),
...args,
], {
cwd: directory,
env: { ...process.env, CREATE_TEST_COMMANDS: path.join(directory, "commands.jsonl"), ...env },
});
}
// Commands run by the installer, without the getent lookups made when writing systemd units.
async function commands() {
const content = await fs.readFile(path.join(directory, "commands.jsonl"), "utf8");
return content.split("\n").filter(Boolean).map(line => JSON.parse(line))
.filter(command => !command.startsWith("getent "));
}
function hostArgs(url = controllerUrl) {
return [
"--mode", "host", "--host-name", "Test Host", "--controller-url", url,
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm", "--plugins",
];
}
it("writes the host config and leaves joining the cluster to the host", async function() {
const { stdout } = await run(hostArgs());
assert.match(stdout, /Successfully installed host/);
assert.match(stdout, /Start the host with run-host/);
const recorded = await commands();
assert.match(recorded[0], /^npm(?:\.cmd)? install @clusterio\/host$/);
assert.deepEqual(recorded.slice(1), [
`${hostBin} config create`,
hostSet("host.name", "Test Host"),
hostSet("host.controller_url", controllerUrl),
hostSet("host.public_address", "host.example.com"),
hostSet("host.factorio_directory", "factorio"),
hostSet("host.allow_remote_updates", false),
hostSet("host.allow_plugin_updates", false),
hostSet("host.allow_plugin_install", false),
]);
await fs.access(path.join(directory, `run-host${process.platform === "win32" ? ".cmd" : ".sh"}`));
});
it("adds the trailing slash the host requires to the controller URL", async function() {
await run(hostArgs(controllerUrl.slice(0, -1)));
assert((await commands()).includes(hostSet("host.controller_url", controllerUrl)));
});
it("leaves joining the cluster to the host of a standalone install", async function() {
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
const { stdout } = await run([
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
"--factorio-dir", "factorio", "--remote-npm", "--plugins",
]);
assert.match(stdout, /Successfully installed standalone/);
assert.match(stdout, /Start the host with run-host/);
const recorded = await commands();
for (const expected of [
hostSet("host.name", "local"),
hostSet("host.controller_url", "http://localhost:8123/"),
`${controllerBin} config set controller.allow_remote_updates true`,
`${controllerBin} config set controller.allow_plugin_updates true`,
`${controllerBin} config set controller.allow_plugin_install true`,
hostSet("host.allow_remote_updates", true),
hostSet("host.allow_plugin_updates", true),
hostSet("host.allow_plugin_install", true),
]) {
assert(recorded.includes(expected), `missing ${expected}`);
}
assert(!recorded.some(command => command.includes("host.controller_token")));
});
});
});