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"], ); 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")); assert.equal(files.get("world/scenario.lua"), freeplayControl); assert.match(files.get("world/control.lua"), /^-- Auto generated scenario module loader/); }); 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, []))); }); }); });