Files
factorio-clusterio/test/host/patch.js
T
bbassieandClaude Opus 5.5 ca3dbab49d Unpatch saves started with save patching disabled
Turning factorio.enable_save_patching off did nothing to a save that
was patched before. Its control.lua still loaded every module, so the
clusterio_api interface, player_sync's join handling and the other
module event handlers kept running with no plugin on the host side.

When patching is disabled the instance now restores the original
control.lua that patching kept as scenario.lua and removes the module
files, locale files included, as listed in clusterio.json. The patch
info stays with an empty module list so the patch number keeps
counting. Otherwise patching the save again would reuse the number
stored in the game and on_server_startup would not be raised. Data
the modules put in storage is left alone.

PatchInfo.fromSave dropped the version read from the save, so the
check against newer patch versions never fired. It now keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:16:52 +00:00

361 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import * as lib from "@clusterio/lib";
import * as patch from "@clusterio/host/dist/node/src/patch.js";
import { createZipBuffer } from "../common.js";
describe("host/patch", function() {
describe("class SaveModule", function() {
describe("static moduleFilePath()", function() {
it("Should remap locale files", function() {
const mappings = [
["locale/en/foo.cfg", "locale/en/test-foo.cfg"],
["locale/en/foo.txt", "locale/en/test-foo.txt"],
["locale/en/foo.tmp.txt", "locale/en/test-foo.tmp.txt"],
["locale/en/foo", "locale/en/test-foo"],
["locale/en.cfg", "locale/en/test.cfg"],
["locale/en.txt", "locale/en/test.txt"],
["locale/en.tmp.txt", "locale/en/test.tmp.txt"],
["locale/en", "locale/en/test"],
["locale/en/bar/foo.cfg", "locale/en/test-bar/foo.cfg"],
];
for (let [input, expected] of mappings) {
assert.equal(patch.SaveModule.moduleFilePath(input, "test"), expected);
}
});
});
describe("loadFiles()", function() {
const testModule = new patch.SaveModule(new lib.ModuleInfo("test", "1.0.0"));
const referenceFiles = new Map([
["modules/test/test.lua", "-- test\n"],
["modules/test/module_exports.lua", "-- module_exports\n"],
["locale/en/test.cfg", "module-test=A Test\n"],
["locale/en/test-locale.cfg", "module-test-locale=Test Locale\n"],
["modules/test.lua", "return require(\"modules/test/module_exports\")"],
]);
before(async function() {
await testModule.loadFiles("test/file/modules/test");
});
it("Should read the contents of all files in a module", function() {
assert.equal(testModule.files.size, referenceFiles.size);
for (let [relativePath, contents] of referenceFiles) {
if (!testModule.files.has(relativePath)) {
assert.fail(`Module is missing file: ${relativePath}`);
} else {
assert.equal(testModule.files.get(relativePath).toString("utf-8"), contents);
}
}
});
});
});
describe("generateLoader()", function() {
let reference = [
"-- Auto generated scenario module loader created by Clusterio",
"-- Modifications to this file will be lost when loaded in Clusterio",
"clusterio_patch_number = 1",
"",
'local event_handler = require("event_handler")',
"",
"-- Scenario modules",
'event_handler.add_lib(require("foo"))',
"",
"-- Clusterio modules",
'event_handler.add_lib(require("modules/spam/bar"))',
'require("modules/spam/spam")',
"",
].join("\n");
let patchInfo = new patch.PatchInfo(
1,
new lib.ModuleInfo("foo", "0.0.0", ["foo"]),
[
new patch.SaveModule(
new lib.ModuleInfo(
"spam",
"0.0.0",
["bar.lua"],
["spam.lua"],
),
new Map([
["bar.lua", Buffer.from("")],
["spam.lua", Buffer.from("")],
["excluded.lua", Buffer.from("")],
]),
),
],
);
it("should produce output matching the reference", function() {
assert.equal(patch._generateLoader(patchInfo), reference);
});
});
function module({ name, version, dependencies = {} }) {
const dependencyMap = new Map(Object.entries(dependencies));
return new patch.SaveModule(new lib.ModuleInfo(name, version, [], [], dependencyMap));
}
describe("reorderDependencies()", function() {
it("should reorder to satisfy simple dependency", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {} }),
];
patch._reorderDependencies(modules);
assert(modules[0].info.name === "b", "Dependency was not reorederd");
});
it("should throw on invalid version", function() {
let modules = [
module({ name: "a", version: "foo", dependencies: {} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Invalid version 'foo' for module a")
);
});
it("should throw on invalid version range", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "invalid"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Invalid version range 'invalid' for dependency b on module a")
);
});
it("should throw on missing dependency", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Missing dependency b for module a")
);
modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {"c": "*"} }),
module({ name: "c", version: "1.0.0", dependencies: {"d": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Missing dependency d for module c")
);
});
it("should throw on outdated dependency", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {} }),
module({ name: "b", version: "1.0.0", dependencies: {"a": ">=2"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Module b requires a >=2")
);
});
it("should throw on dependency loops", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {"a": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Module dependency loop detected: a -> b -> a")
);
modules = [
module({ name: "d", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {"c": "*"} }),
module({ name: "c", version: "1.0.0", dependencies: {"a": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Module dependency loop detected: b -> c -> a -> b")
);
});
it("should reorder correctly for multiple dependencies", function() {
function validate(modules) {
let present = new Set();
for (let { info } of modules) {
for (let dependency of info.dependencies.keys()) {
if (!present.has(dependency)) {
assert.fail(`${info.name} depends on ${dependency}, but was ordered before it`);
}
}
present.add(info.name);
}
}
let names = ["a", "b", "c"];
let successCount = 0;
const bitCount = names.length * (names.length - 1);
for (let i=0; i < 2**bitCount; i++) {
let value = i;
let bits = [];
for (let j=0; j < bitCount; j++) {
bits.push(value & 1);
value >>= 1;
}
let modules = [];
for (let j=0; j < names.length; j++) {
let name = names[j];
let dependencies = {};
for (let k=0; k < names.length - 1; k++) {
if (bits[k + j * (names.length - 1)]) {
dependencies[names[k + (k >= j)]] = "*";
}
}
modules.push(module({ name, version: "1.0.0", dependencies }));
}
let success = false;
try {
patch._reorderDependencies(modules);
success = true;
} catch (err) { }
if (success) {
validate(modules);
successCount += 1;
}
}
// Deriving the formula for the number of graphs without
// dependencies loops is left as an excercise for the reader.
assert.equal(successCount, 25, "Incorrect number of successful orderings");
});
});
describe("loadModules()", function() {
it("should load the bundled clusterio module", async function() {
const modules = await patch.loadModules([]);
assert.deepEqual([...modules.keys()], ["clusterio"]);
assert(modules.get("clusterio").files.has("modules/clusterio/api.lua"));
});
it("should load modules from plugins", async function() {
const pluginInfos = await lib.loadPluginInfos(
new Map([["research_sync", path.resolve("plugins/research_sync")]])
);
const modules = await patch.loadModules(pluginInfos);
assert.deepEqual([...modules.keys()], ["research_sync", "clusterio"]);
assert(modules.get("research_sync").files.has("modules/research_sync/sync.lua"));
});
it("should skip plugins without a module", async function() {
const pluginInfos = await lib.loadPluginInfos(
new Map([["test_plugin_no_module", path.resolve("test/file/test_plugin_no_module")]])
);
const modules = await patch.loadModules(pluginInfos);
assert.deepEqual([...modules.keys()], ["clusterio"]);
});
it("should throw on a plugin module named like a bundled module", async function() {
const pluginDir = path.join("temp", "test", "clusterio_plugin");
await fs.mkdir(path.join(pluginDir, "module"), { recursive: true });
await fs.writeFile(path.join(pluginDir, "package.json"), "{}");
await fs.writeFile(path.join(pluginDir, "module", "module.json"), "{}");
await assert.rejects(
patch.loadModules([{
name: "clusterio", version: "1.0.0", packagePath: path.resolve(pluginDir, "package.json"),
}]),
new Error("Module with name clusterio already exists in a plugin")
);
});
});
describe("patch()", function() {
it("should throw on unknown scenario", async function() {
let zipPath = path.join("temp", "test", "patch.zip");
await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", "-- unknown\n"]]));
await assert.rejects(
patch.patch(zipPath, []),
new Error("Unable to patch save, unknown scenario (3acc3be3861144e55604f5ac2f2555071885ebc4)")
);
});
});
describe("unpatch()", function() {
const freeplayControl = "require('__base__/script/freeplay/control.lua')\n";
const zipPath = path.join("temp", "test", "unpatch.zip");
let testModule;
before(async function() {
testModule = new patch.SaveModule(new lib.ModuleInfo("test", "1.0.0"));
await testModule.loadFiles("test/file/modules/test");
});
async function readSave() {
const zip = await lib.ZipArchive.fromFile(zipPath);
try {
const files = new Map();
for (const name of zip.entries.keys()) {
files.set(name, (await zip.readFile(name)).toString("utf8"));
}
return files;
} finally {
zip.close();
}
}
it("should leave saves that are not patched untouched", async function() {
await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", freeplayControl]]));
assert.equal(await patch.unpatch(zipPath), false);
assert.deepEqual([...(await readSave()).keys()], ["world/control.lua"]);
});
it("should restore control.lua and remove module files", async function() {
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", freeplayControl],
["world/level.dat0", "level"],
]));
await patch.patch(zipPath, [testModule]);
assert.equal(await patch.unpatch(zipPath), true);
const files = await readSave();
assert.deepEqual(
[...files.keys()].sort(),
["world/clusterio.json", "world/control.lua", "world/level.dat0", "world/scenario.lua"],
);
assert.equal(files.get("world/control.lua"), freeplayControl);
const patchInfo = JSON.parse(files.get("world/clusterio.json"));
assert.equal(patchInfo.patch_number, 1);
assert.deepEqual(patchInfo.modules, []);
assert.equal(await patch.unpatch(zipPath), false, "unpatched twice");
});
it("should throw on a newer patch version", async function() {
const scenario = new lib.ModuleInfo("freeplay", "2.0.29", [], ["scenario"]);
const patchInfo = { version: 2, patch_number: 1, scenario, modules: [] };
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", freeplayControl],
["world/clusterio.json", JSON.stringify(patchInfo)],
]));
await assert.rejects(patch.unpatch(zipPath), /Save patch version 2 is newer/);
await assert.rejects(patch.patch(zipPath, []), /Save patch version 2 is newer/);
});
it("should keep counting the patch number when patched again", async function() {
await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", freeplayControl]]));
await patch.patch(zipPath, [testModule]);
await patch.unpatch(zipPath);
await patch.patch(zipPath, [testModule]);
const files = await readSave();
assert.equal(JSON.parse(files.get("world/clusterio.json")).patch_number, 2);
assert(files.has("world/modules/test/test.lua"));
});
it("should keep the loader of scenarios made for Clusterio", async function() {
const scenario = new lib.ModuleInfo("custom", "1.0.0", [], ["custom"]);
const patchInfo = new patch.PatchInfo(3, scenario, [testModule]);
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", patch._generateLoader(patchInfo)],
["world/clusterio.json", JSON.stringify(patchInfo)],
["world/custom.lua", "-- custom\n"],
...[...testModule.files].map(([name, content]) => [`world/${name}`, content]),
]));
assert.equal(await patch.unpatch(zipPath), true);
const files = await readSave();
assert.deepEqual(
[...files.keys()].sort(),
["world/clusterio.json", "world/control.lua", "world/custom.lua"],
);
assert.equal(files.get("world/control.lua"), patch._generateLoader(new patch.PatchInfo(3, scenario, [])));
});
});
});