Files
factorio-clusterio/test/lib/ModStore.test.js
BastiaanandClaude Opus 5.5 c28e617b22 Replace jszip with yauzl and yazl (#1011)
* Replace jszip with yauzl and yazl

jszip loads a whole archive into memory to read it and buffers the
output when writing.  Saves and mods can be several hundred megabytes,
so read zips with yauzl, which only reads the central directory and
streams entries on demand, and write them with yazl, which streams the
output.

lib gains a ZipArchive wrapper around yauzl with the lookup helpers the
callers need, findRoot() now takes it instead of a JSZip.  Saves are
patched by streaming every kept entry into a new zip, mod info.json is
read straight from the file, exports are streamed to the controller and
Factorio zip downloads go through a temp file instead of a buffer.

Patching now inflates and deflates each entry again where jszip reused
the compressed data, on a 336 MB synthetic save that is 7 s instead of
0.7 s, with peak memory going from 416 MB to 151 MB.

Closes #1002

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Copy save entries without recompressing them

yazl inflates and deflates every entry it is given, so patching a save
took ten times longer than with jszip, which reused the compressed
data.  Add a ZipWriter to lib that streams entries out as they are
added and copies entries from a ZipArchive byte for byte, with ZIP64
structures when sizes or offsets need them, and use it in patch().

On a 336 MB synthetic save patching now takes 0.4 s at 143 MB peak RSS
against 0.75 s at 417 MB with jszip.  Factorio loads the result.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Handle save write errors before the patch loop awaits them

A write error on the temporary save, such as a full disk, rejected the
pipeline promise while addEntry() or addBuffer() was still pending. Nothing
was listening yet, so the host's unhandledRejection handler exited the
process instead of failing the instance start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the export zip cache local to each export

The cache was module level, so when two instances on one host exported at
once, the first to finish closed archives the other was still reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start writing zips before adding files from disk

yazl reads files passed to addFile() as soon as they are added. An
unreadable file emitted an error while the directory traversal was still
running, before the output pipeline existed, and the process exited on the
uncaught error. build_mod and zipDirectory now start the pipeline first and
reject instead. build_mod also removes the partial zip on failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 21:44:19 +03:00

892 lines
35 KiB
JavaScript

import assert from "node:assert/strict";
import path from "node:path";
import fs from "node:fs/promises";
import stream from "node:stream";
// Capture native fetch before any potential mocks are applied
const nativeFetch = global.fetch;
import { ModStore, ModInfo, ModVersionEquality, zipOutputStream } from "@clusterio/lib";
import { externalTest, createZip, createZipBuffer } from "../common.js";
import "../setup_logging.js";
const MODS_DIR = path.join("temp", "test", "mod_store", "mods");
const CACHE_FILE = path.join(MODS_DIR, "mod-info-cache.json");
function mockModFiles(name, version, factorioVersion = "1.1", dependencies = ["base >= 1.1"]) {
const infoJson = {
name: name,
version: version,
title: `${name} Mod Title`,
author: "Test Author",
factorio_version: factorioVersion,
dependencies: dependencies,
description: `Description for ${name}`,
};
return [
[`${name}/info.json`, JSON.stringify(infoJson, null, 4)],
[`${name}/dummy.lua`, "-- Mock Lua file"],
];
}
// Helper function to create a mock mod zip file
async function createMockModZip(filePath, name, version, factorioVersion = "1.1", dependencies = ["base >= 1.1"]) {
const buffer = await createZipBuffer(mockModFiles(name, version, factorioVersion, dependencies));
await fs.writeFile(filePath, buffer);
}
// Helper to wait briefly for mtime changes
async function waitBriefly() {
return new Promise(resolve => setTimeout(resolve, 50)); // 50ms should be enough for mtime resolution
}
describe("lib/ModStore", function () {
before(async function () {
await fs.mkdir(MODS_DIR, { recursive: true });
});
after(function() {
global.fetch = nativeFetch;
});
beforeEach(function () {
// Reset mocks before each test
global.fetch = async (url, options) => {
throw new Error(`fetch mock not implemented for ${url} in this test`);
};
});
afterEach(async function () {
await fs.rm(MODS_DIR, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(MODS_DIR, { recursive: true });
});
describe("constructor", function () {
it("should create an instance with empty files map", function () {
const modStore = new ModStore(MODS_DIR, new Map());
assert(modStore instanceof ModStore);
assert.equal(modStore.modsDirectory, MODS_DIR);
assert.equal(modStore.files.size, 0);
});
});
describe("fromDirectory", function () {
it("should load from an empty directory", async function () {
const modStore = await ModStore.fromDirectory(MODS_DIR);
assert(modStore instanceof ModStore);
assert.equal(modStore.modsDirectory, MODS_DIR);
assert.equal(modStore.files.size, 0);
});
it("should load a single mod zip file", async function () {
const modName = "test-mod";
const modVersion = "1.0.0";
const filename = `${modName}_${modVersion}.zip`;
await createMockModZip(path.join(MODS_DIR, filename), modName, modVersion);
const modStore = await ModStore.fromDirectory(MODS_DIR);
assert.equal(modStore.files.size, 1);
const modInfo = modStore.files.get(filename);
assert(modInfo instanceof ModInfo);
assert.equal(modInfo.name, modName);
assert.equal(modInfo.version, modVersion);
assert.equal(modInfo.filename, filename);
});
it("should load multiple mod zip files", async function () {
const mod1Name = "test-mod-1";
const mod1Version = "1.0.0";
const mod1Filename = `${mod1Name}_${mod1Version}.zip`;
await createMockModZip(path.join(MODS_DIR, mod1Filename), mod1Name, mod1Version);
const mod2Name = "test-mod-2";
const mod2Version = "0.1.0";
const mod2Filename = `${mod2Name}_${mod2Version}.zip`;
await createMockModZip(path.join(MODS_DIR, mod2Filename), mod2Name, mod2Version);
const modStore = await ModStore.fromDirectory(MODS_DIR);
assert.equal(modStore.files.size, 2);
assert(modStore.files.has(mod1Filename));
assert(modStore.files.has(mod2Filename));
});
it("should ignore non-zip files and directories", async function () {
// Add a valid mod
const modName = "real-mod";
const modVersion = "1.0.0";
const filename = `${modName}_${modVersion}.zip`;
await createMockModZip(path.join(MODS_DIR, filename), modName, modVersion);
// Add other file types and directories
await fs.writeFile(path.join(MODS_DIR, "some-text.txt"), "hello");
await fs.mkdir(path.join(MODS_DIR, "a-directory"), { recursive: true });
await fs.writeFile(path.join(MODS_DIR, "archive.tar.gz"), "not a zip");
await createMockModZip(path.join(MODS_DIR, "temp_file.tmp.zip"), "temp", "1.0.0"); // Should ignore .tmp.zip
const modStore = await ModStore.fromDirectory(MODS_DIR);
assert.equal(modStore.files.size, 1);
assert(modStore.files.has(filename)); // Should only load the real mod
});
it("should invalidate cache if mod file is modified", async function () {
const modName = "updated-mod";
const modVersion = "1.0.0";
const filename = `${modName}_${modVersion}.zip`;
const modPath = path.join(MODS_DIR, filename);
// Create mod and load once to generate cache
await createMockModZip(modPath, modName, modVersion);
let modStore = await ModStore.fromDirectory(MODS_DIR);
assert.equal(modStore.files.size, 1);
await assert.doesNotReject(fs.access(CACHE_FILE), "Cache file should exist");
// Wait and recreate the file to update mtime
await waitBriefly();
await createMockModZip(modPath, modName, modVersion, "1.1", ["base >= 1.1", "? updated-dep"]);
// Load again
modStore = await ModStore.fromDirectory(MODS_DIR);
assert.equal(modStore.files.size, 1, "Mod should be reloaded");
const modInfo = modStore.files.get(filename);
assert(modInfo instanceof ModInfo);
assert.equal(modInfo.name, modName);
assert.equal(modInfo.version, modVersion);
// Check if the dependency was updated (indicating re-read)
assert(modInfo.dependencySpecifications.includes("? updated-dep"));
});
it("should handle corrupted zip files gracefully", async function () {
// Create a valid mod
const validModName = "valid-mod";
const validModVersion = "1.0.0";
const validFilename = `${validModName}_${validModVersion}.zip`;
await createMockModZip(path.join(MODS_DIR, validFilename), validModName, validModVersion);
// Create an invalid zip file (just text)
const invalidFilename = "invalid-mod_0.0.1.zip";
await fs.writeFile(path.join(MODS_DIR, invalidFilename), "This is not a zip file");
const modStore = await ModStore.fromDirectory(MODS_DIR);
assert.equal(modStore.files.size, 1, "Only the valid mod should be loaded");
assert(modStore.files.has(validFilename));
assert(!modStore.files.has(invalidFilename));
});
// TODO: Tests for loading mods from directory
// - Directory with cache file
// - Cache invalidation
// - Error handling for corrupt zips
});
describe("downloadMods", function () {
let modStore;
const username = "testuser";
const token = "testtoken";
const factorioVersion = "1.1";
// Store fetch and restore it after tests
let originalFetch;
before(function () {
originalFetch = global.fetch;
});
after(function () {
global.fetch = originalFetch;
});
beforeEach(async function () {
// Start with an empty store for download tests
// Clear any existing files in the temp dir
await fs.rm(MODS_DIR, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(MODS_DIR, { recursive: true });
modStore = await ModStore.fromDirectory(MODS_DIR); // Initialize using fromDirectory
// Restore fetch before each test in this suite to avoid pollution
global.fetch = originalFetch;
});
// Helper to create the mock fetch function for the Factorio Mod Portal API
function createMockPortalApiFetch(modsToDownload, fetchCallsArray = null) {
return async (url, options) => {
const urlString = url.toString();
if (fetchCallsArray) {
fetchCallsArray.push({ url: urlString, options }); // Track call if array is provided
}
if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") {
const body = options.body;
let requestedModNames = [];
if (body instanceof URLSearchParams) {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => {
const requestedMod = modsToDownload.find(mod => mod.name === name);
if (!requestedMod) {
// Use assert or throw for clearer test failure messages
assert.fail(`[Test Mock ERROR] Mock fetch received unexpected mod name: ${name}`);
}
const requestedVersion = requestedMod.version.version;
return {
name: name,
latest_release: {
download_url: `/download/${name}/${requestedVersion}`,
file_name: `${name}_${requestedVersion}.zip`,
info_json: { factorio_version: factorioVersion },
version: requestedVersion,
sha1: `sha1_${name}_${requestedVersion}`,
},
};
});
return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) };
}
if (urlString.startsWith("https://mods.factorio.com/download/")) {
const [, name, version] = /\/([^\/]+)\/([^\/]+)$/.exec(url.pathname);
const requestedMod = modsToDownload.find(mod => (
mod.name === name
&& mod.version.version === version
));
if (!requestedMod) {
return { ok: false, status: 404, statusText: `Test Mock 404, no such mod: ${urlString}` };
}
if (name === "bad-download-mod") {
return { ok: false, status: 503, statusText: "Test Mock download error" };
}
const zip = createZip(mockModFiles(name, version));
return { ok: true, status: 200, body: stream.Readable.toWeb(zipOutputStream(zip)) };
}
// Fallback for unhandled fetches in this test
return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` };
};
}
it("should download and add a single mod", async function () {
const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }];
// Define fetch mock specific to this test
global.fetch = createMockPortalApiFetch(modsToDownload);
await modStore.downloadMods(modsToDownload, username, token, factorioVersion);
assert.equal(modStore.files.size, 1, "Should have 1 mod after download");
const filename = "download-mod-1_1.1.0.zip";
assert(modStore.files.has(filename), `Mod ${filename} should be in the store`);
await assert.doesNotReject(fs.access(path.join(MODS_DIR, filename)), `File ${filename} should exist`);
const modInfo = modStore.files.get(filename);
assert(modInfo instanceof ModInfo);
assert.equal(modInfo.name, "download-mod-1");
assert.equal(modInfo.version, "1.1.0");
});
it("should download multiple mods", async function () {
const modsToDownload = [
{ name: "multi-mod-a", version: ModVersionEquality.fromString("1.0.0") },
{ name: "multi-mod-b", version: ModVersionEquality.fromString("2.0.0") },
];
// Define fetch mock specific to this test
global.fetch = createMockPortalApiFetch(modsToDownload);
await modStore.downloadMods(modsToDownload, username, token, factorioVersion);
assert.equal(modStore.files.size, 2);
assert(modStore.files.has("multi-mod-a_1.0.0.zip"));
assert(modStore.files.has("multi-mod-b_2.0.0.zip"));
await fs.access(path.join(MODS_DIR, "multi-mod-a_1.0.0.zip"));
await fs.access(path.join(MODS_DIR, "multi-mod-b_2.0.0.zip"));
});
it("should throw when no releases available", async function () {
const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }];
// Define fetch mock specific to this test
global.fetch = async (url, options) => {
const urlString = url.toString();
if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") {
const body = options.body;
let requestedModNames = [];
if (body instanceof URLSearchParams) {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => ({ name: name }));
return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) };
}
// Fallback for unhandled fetches in this test
return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` };
};
await assert.rejects(modStore.downloadMods(modsToDownload, username, token, factorioVersion));
});
it("should skip built-in mods", async function () {
const modsToDownload = [
{ name: "base", version: ModVersionEquality.fromString("1.1.0") }, // Built-in
{ name: "core", version: ModVersionEquality.fromString("1.1.0") }, // Built-in
{ name: "another-new-mod", version: ModVersionEquality.fromString("1.0.0") }, // Not built-in
];
let fetchCalls = [];
// Define fetch mock specific to this test
global.fetch = createMockPortalApiFetch(modsToDownload, fetchCalls);
await modStore.downloadMods(modsToDownload, username, token, factorioVersion);
assert.equal(modStore.files.size, 1, "Only non-builtin mod should be downloaded");
assert(modStore.files.has("another-new-mod_1.0.0.zip"));
const postCall = fetchCalls.find(call => call.options?.method === "POST");
assert(postCall, "POST call to API should have happened");
const requestedMods = (postCall.options.body.get("namelist") || "").split(",");
assert.deepEqual(requestedMods, ["another-new-mod"], "Only non-builtin mod should be in the namelist");
});
it("should handle API error during mod info fetch", async function () {
const modsToDownload = [{ name: "api-error-mod", version: ModVersionEquality.fromString("1.0.0") }];
// Define fetch mock specific to this test (simulates API error)
global.fetch = async (url, options) => {
if (url.toString().startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") {
return {
ok: false,
status: 503,
statusText: "Service Unavailable",
};
}
// This fallback shouldn't be reached in this test
return { ok: false, status: 404, statusText: `Test Mock 404: ${url.toString()}` };
};
await assert.rejects(
modStore.downloadMods(modsToDownload, username, token, factorioVersion),
/Mod portal request to https:\/\/mods\.factorio\.com\/api\/mods.* failed: 503 Service Unavailable/
);
assert.equal(modStore.files.size, 0, "No mods should be added on API error");
});
it("should handle download error for a specific mod", async function () {
const modsToDownload = [
{ name: "good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success
{ name: "bad-download-mod", version: ModVersionEquality.fromString("1.0.0") }, // Fail
{ name: "another-good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success
];
// Define fetch mock specific to this test (for the API call)
global.fetch = createMockPortalApiFetch(modsToDownload);
await modStore.downloadMods(modsToDownload, username, token, factorioVersion);
assert.equal(modStore.files.size, 2, "Only successfully downloaded mods should be added");
assert(modStore.files.has("good-mod_1.0.0.zip"));
assert(modStore.files.has("another-good-mod_1.0.0.zip"));
assert(!modStore.files.has("bad-download-mod_1.0.0.zip"));
});
it("should download mods in chunks", async function () {
const modsToDownload = [
{ name: "chunk-mod-1", version: ModVersionEquality.fromString("1.0.0") },
{ name: "chunk-mod-2", version: ModVersionEquality.fromString("1.0.0") },
{ name: "chunk-mod-3", version: ModVersionEquality.fromString("1.0.0") },
{ name: "chunk-mod-4", version: ModVersionEquality.fromString("1.0.0") },
];
let fetchCalls = [];
// Define fetch mock specific to this test
global.fetch = createMockPortalApiFetch(modsToDownload, fetchCalls);
await modStore.downloadMods(modsToDownload, username, token, factorioVersion);
let postCalls = fetchCalls.reduce((a, v) => a + (v.options?.method === "POST"), 0);
// Assuming chunkSize is 500, 4 mods should be 1 chunk/POST call
assert.equal(postCalls, 1, "Should make 1 POST call for 4 mods");
assert.equal(modStore.files.size, modsToDownload.length, "All chunked mods should be downloaded");
assert(modStore.files.has("chunk-mod-1_1.0.0.zip"));
assert(modStore.files.has("chunk-mod-4_1.0.0.zip"));
});
});
describe("fetchAllModsFromPortal (static)", function () {
const factorioVersion = "1.1";
const apiBaseUrl = "https://mods.factorio.com/api/mods";
const pageSize = 2; // Small page size for mock tests
// Keep track of the original fetch for the live test
let originalFetch;
before(function() {
originalFetch = global.fetch;
});
after(function() {
global.fetch = originalFetch; // Restore original fetch after this suite
});
// Simplified mock mod data for mock tests
const mockPortalMods = [
{ name: "pa", title: "A", owner: "oA", latest_release: {
version: "1.0.0", sha1: "sha1pa", info_json: { factorio_version: factorioVersion },
} },
{ name: "pb", title: "B", owner: "oB", latest_release: {
version: "2.0.0", sha1: "sha1pb", info_json: { factorio_version: factorioVersion },
} },
{ name: "pc", title: "C", owner: "oC", latest_release: {
version: "3.0.0", sha1: "sha1pc", info_json: { factorio_version: factorioVersion },
} },
{ name: "pd", title: "D", owner: "oD", latest_release: {
version: "4.0.0", sha1: "sha1pd", info_json: { factorio_version: factorioVersion },
} },
];
const totalMods = mockPortalMods.length;
const totalPages = Math.ceil(totalMods / pageSize);
beforeEach(function () {
// Set up mock fetch for most tests in this suite
global.fetch = async (url, options) => {
const urlObj = new URL(url);
if (urlObj.origin + urlObj.pathname === apiBaseUrl && !options?.method /* GET */) {
const params = urlObj.searchParams;
const page = parseInt(params.get("page") || "1", 10);
const reqPageSize = parseInt(params.get("page_size") || String(pageSize), 10);
const reqVersion = params.get("version");
const hideDeprecated = params.get("hide_deprecated") === "true";
// Helper to create the mock response
const createMockResponse = (results, pagination) => {
const jsonFunc = async () => ({ results, pagination });
return {
ok: true,
status: 200,
statusText: "OK",
headers: {
entries: () => [["content-type", "application/json"]],
get: (headerName) => (
headerName.toLowerCase() === "content-type" ? "application/json" : null
),
},
json: jsonFunc,
};
};
// Use larger page size for mock fetch logic if not specified
const effectivePageSize = reqPageSize || 50;
if (reqVersion !== factorioVersion) {
return { ok: false, status: 400, statusText: "Bad Request - Incorrect Version" };
}
const mockTotalPages = Math.ceil(totalMods / effectivePageSize);
const pagination = {
page,
page_count: mockTotalPages,
page_size: effectivePageSize,
count: totalMods,
};
if (page > mockTotalPages) {
return createMockResponse([], pagination);
}
const startIndex = (page - 1) * effectivePageSize;
const endIndex = startIndex + effectivePageSize;
const pageResults = mockPortalMods.slice(startIndex, endIndex);
return createMockResponse(pageResults, pagination);
}
// Fallback for unhandled fetch in mock tests
return {
ok: false,
status: 404,
statusText: `Not Found - Mock fetch unhandled for ${url}`,
body: null,
};
};
});
it("should fetch all mods by handling pagination (using mock)", async function () {
// This test uses the mock fetch setup in beforeEach
const allMods = await ModStore.fetchAllModsFromPortal(factorioVersion);
assert.equal(allMods.length, totalMods, "Should fetch all mock mods");
const fetchedNames = allMods.map(m => m.name).sort();
const expectedNames = mockPortalMods.map(m => m.name).sort();
assert.deepEqual(fetchedNames, expectedNames, "Fetched names mismatch");
});
it("should pass hide_deprecated parameter correctly (using mock)", async function () {
// This test uses the mock fetch setup in beforeEach
let capturedUrl = null;
const currentFetch = global.fetch; // Capture the mock fetch
global.fetch = async (url, options) => {
capturedUrl = url.toString();
return currentFetch(url, options);
};
await ModStore.fetchAllModsFromPortal(factorioVersion, 100, true); // hide_deprecated = true
assert(capturedUrl.includes("hide_deprecated=true"), "URL should contain hide_deprecated=true");
await ModStore.fetchAllModsFromPortal(factorioVersion, 100, false); // hide_deprecated = false
assert(capturedUrl.includes("hide_deprecated=false"), "URL should contain hide_deprecated=false");
});
it("should handle API errors during pagination (using mock)", async function () {
// This test uses the mock fetch setup in beforeEach
const errorPage = 2;
const currentFetch = global.fetch; // Capture the mock fetch
global.fetch = async (url, options) => {
const urlObj = new URL(url);
const page = parseInt(urlObj.searchParams.get("page") || "1", 10);
// Modify pagination for this test to ensure page 2 is requested
if (page === errorPage) {
return {
ok: false,
status: 500,
statusText: "Internal Server Error",
text: async () => "API Error",
};
}
// For other pages (like page 1), return the standard mock response,
// but ensure pagination suggests more pages exist.
const response = await currentFetch(url, options);
if (response.ok && page === 1) {
const json = await response.json();
json.pagination.page_count = Math.max(json.pagination.page_count, errorPage);
return { ...response, json: async () => json };
}
return response; // Return other pages/errors as is
};
await assert.rejects(
ModStore.fetchAllModsFromPortal(factorioVersion),
/Mod portal fetch page 2 failed: 500 Internal Server Error - API Error/
);
});
it("should handle empty results from the portal (using mock)", async function () {
// Override fetch to return empty results on the first page
global.fetch = async (url, options) => {
const urlObj = new URL(url);
if (urlObj.origin + urlObj.pathname === apiBaseUrl && !options?.method) {
const pagination = { page: 1, page_count: 0, page_size: 50, count: 0 };
return { ok: true, status: 200, json: async () => ({ results: [], pagination }) };
}
return {
ok: false, status: 404, statusText: `Mock fetch unhandled for ${url}`,
};
};
const allMods = await ModStore.fetchAllModsFromPortal(factorioVersion);
assert.equal(allMods.length, 0, "Should return an empty array for no mods");
});
// Note: This test connects to the actual Factorio Mod Portal API.
// It requires network access and may take a while to run.
// It might also break if the API changes or is unavailable.
it("should fetch all mods from the LIVE portal API and check structure/count", async function () {
externalTest(this);
// Ensure we are using the true native fetch we captured earlier
global.fetch = nativeFetch;
assert.strictEqual(
global.fetch,
nativeFetch, // Check against the truly native fetch
"global.fetch was not restored to the original native fetch"
);
const liveFactorioVersion = "1.1"; // Use a common, stable version
let allMods = [];
try {
allMods = await ModStore.fetchAllModsFromPortal(liveFactorioVersion, 100, true); // hide deprecated
} catch (err) {
// Provide more context if the API call fails
const cause = err.cause ? ` - Cause: ${err.cause}` : "";
const status = err.response ? ` - Status: ${err.response.status}` : "";
let detailedMessage = `Live API call failed: ${err.message} (Type: ${err.constructor.name})`;
if (err.cause) {
detailedMessage += ` - Cause: ${err.cause}`;
}
// Try to get response info safely
const response = err.response || (typeof err.status === "number" ? err : null);
if (response) {
detailedMessage += ` - Status: ${response.status} ${response.statusText || ""}`;
if (typeof response.text === "function") {
try {
// Attempt to get text body for more context
const textBody = await response.text();
detailedMessage += ` - Body Preview: ${textBody.substring(0, 200)}...`;
} catch (textErr) {
detailedMessage += ` - (Failed to read response body as text: ${textErr.message})`;
}
}
} else {
// Log the whole error object if it's not a response-related error
try {
detailedMessage += ` - Error Details: ${JSON.stringify(err, Object.getOwnPropertyNames(err))}`;
} catch (stringifyErr) {
detailedMessage += " - (Could not stringify error object)";
}
}
assert.fail(detailedMessage);
}
assert(Array.isArray(allMods), "Result should be an array");
// Check for a realistically large number of mods. Adjust if needed.
assert(
allMods.length > 3000,
`Expected > 3000 mods for Factorio ${liveFactorioVersion}, but got ${allMods.length}`
);
// Check the structure of the first mod (assuming the list is not empty)
if (allMods.length > 0) {
const firstMod = allMods[0];
assert.equal(typeof firstMod.name, "string", "First mod should have a string name");
assert.equal(typeof firstMod.title, "string", "First mod should have a string title");
assert.equal(typeof firstMod.owner, "string", "First mod should have a string owner");
assert.ok(firstMod.latest_release, "First mod should have latest_release info");
assert.equal(
typeof firstMod.latest_release.version,
"string",
"latest_release version should be string"
);
assert.equal(
typeof firstMod.latest_release.sha1,
"string",
"latest_release should have a sha1 string"
);
assert.ok(firstMod.latest_release.info_json, "latest_release should have info_json");
assert.equal(
typeof firstMod.latest_release.info_json.factorio_version,
"string",
"info_json should have factorio_version string"
);
// Check for an expected field from the user example
assert.equal(
typeof firstMod.downloads_count,
"number",
"First mod should have downloads_count number"
);
} else {
// This should not happen for v1.1 but handles edge cases
// eslint-disable-next-line no-console
console.warn("Warning: Live Mod Portal API returned 0 mods.");
}
});
});
describe("fetchModReleases (static)", function() {
// Keep track of the original fetch for the live test
let originalFetch;
before(function() {
originalFetch = global.fetch;
});
after(function() {
global.fetch = originalFetch; // Restore original fetch after this suite
});
it("should fetch from the correct api url (mock)", async function() {
global.fetch = async function(url) {
assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full");
return { ok: true, status: 200, json: async () => ({ name: "my-mod" }) };
};
const result = await ModStore.fetchModReleases("my-mod");
assert.equal(result.name, "my-mod");
});
it("should throw an error when the fetch fails (mock)", async function() {
global.fetch = async function(url) {
assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full");
return { ok: false, status: 400, json: async () => ({ name: "my-mod" }) };
};
await assert.rejects(ModStore.fetchModReleases("my-mod"));
});
it("should fetch from the correct api url (live)", async function() {
externalTest(this);
global.fetch = nativeFetch;
const result = await ModStore.fetchModReleases("clusterio_lib");
assert(result.releases && result.releases.length > 1, "Failed to fetch releases");
});
});
// === Instance Methods ===
describe("Instance Methods", function () {
let modStore;
let testMod1Name = "test-mod-1";
let testMod1Version = "1.0.0";
let testMod1Filename = `${testMod1Name}_${testMod1Version}.zip`;
let testMod1Path = path.join(MODS_DIR, testMod1Filename);
beforeEach(async function () {
// Create a fresh store and a default mod for instance tests
await createMockModZip(testMod1Path, testMod1Name, testMod1Version);
modStore = await ModStore.fromDirectory(MODS_DIR);
assert.equal(modStore.files.size, 1, "Test setup failed: ModStore should have 1 mod initially");
});
describe("getMod", function () {
it("should return ModInfo for an existing mod", function () {
const modInfo = modStore.getMod(testMod1Name, testMod1Version);
assert(modInfo instanceof ModInfo);
assert.equal(modInfo.name, testMod1Name);
assert.equal(modInfo.version, testMod1Version);
});
it("should return undefined for a non-existent mod name", function () {
const modInfo = modStore.getMod("non-existent-mod", testMod1Version);
assert.equal(modInfo, undefined);
});
it("should return undefined for a non-existent mod version", function () {
const modInfo = modStore.getMod(testMod1Name, "99.9.9");
assert.equal(modInfo, undefined);
});
it("should return undefined if sha1 does not match", function () {
const modInfo = modStore.getMod(testMod1Name, testMod1Version, "mismatched-sha1");
assert.equal(modInfo, undefined);
});
it("should return ModInfo if sha1 matches", function () {
const existingMod = modStore.files.get(testMod1Filename);
assert(existingMod, "Test setup error: existing mod not found"); // Ensure mod exists
const modInfo = modStore.getMod(testMod1Name, testMod1Version, existingMod.sha1);
assert(modInfo instanceof ModInfo);
assert.equal(modInfo.name, testMod1Name);
assert.equal(modInfo.sha1, existingMod.sha1);
});
});
describe("loadFile (instance)", function () {
let testMod2Name = "test-mod-2";
let testMod2Version = "0.1.0";
let testMod2Filename = `${testMod2Name}_${testMod2Version}.zip`;
let testMod2Path = path.join(MODS_DIR, testMod2Filename);
it("should load an existing file and add it to the store", async function () {
await createMockModZip(testMod2Path, testMod2Name, testMod2Version);
assert(!modStore.files.has(testMod2Filename), "Mod should not be present before loadFile");
const modInfo = await modStore.loadFile(testMod2Filename);
assert(modInfo instanceof ModInfo);
assert.equal(modInfo.name, testMod2Name);
assert.equal(modInfo.version, testMod2Version);
assert(modStore.files.has(testMod2Filename), "Mod should be present after loadFile");
assert.equal(modStore.files.get(testMod2Filename), modInfo);
});
it("should emit 'change' event when loading a file", async function () {
await createMockModZip(testMod2Path, testMod2Name, testMod2Version);
let changeEventFired = false;
let eventModInfo = null;
modStore.on("change", (mod) => {
changeEventFired = true;
eventModInfo = mod;
});
const loadedModInfo = await modStore.loadFile(testMod2Filename);
assert(changeEventFired, "Change event should have fired");
assert.equal(eventModInfo, loadedModInfo, "Event should pass the loaded ModInfo");
});
it("should throw when loading a non-existent file", async function () {
await assert.rejects(
modStore.loadFile("non-existent.zip"),
/ENOENT: no such file or directory/ // Check for fs error
);
});
it("should throw if filename does not match info.json", async function () {
const wrongFilename = `wrong-name_${testMod2Version}.zip`;
await createMockModZip(path.join(MODS_DIR, wrongFilename), testMod2Name, testMod2Version);
await assert.rejects(
modStore.loadFile(wrongFilename),
/filename does not match the expected name/ // Check for ModStore.loadFile's specific error
);
});
});
describe("deleteFile", function () {
it("should delete an existing file from store and filesystem", async function () {
assert(modStore.files.has(testMod1Filename), "Mod should exist before deletion");
await assert.doesNotReject(fs.access(testMod1Path), "Mod file should exist before deletion");
await modStore.deleteFile(testMod1Filename);
assert(!modStore.files.has(testMod1Filename), "Mod should not exist in store after deletion");
await assert.rejects(fs.access(testMod1Path), "Mod file should not exist after deletion");
});
it("should emit 'change' event with isDeleted flag when deleting", async function () {
const originalModInfo = modStore.files.get(testMod1Filename);
let changeEventFired = false;
let eventModInfo = null;
modStore.on("change", (mod) => {
changeEventFired = true;
eventModInfo = mod;
});
await modStore.deleteFile(testMod1Filename);
assert(changeEventFired, "Change event should have fired");
assert.equal(eventModInfo, originalModInfo, "Event should pass the original ModInfo object");
assert(eventModInfo.isDeleted, "Event ModInfo should have isDeleted flag set");
});
it("should throw when deleting a non-existent file", async function () {
await assert.rejects(
modStore.deleteFile("non-existent.zip"),
/Mod non-existent.zip does not exist/
);
});
});
describe("addMod", function () {
it("should add a ModInfo object to the files map", function () {
const modInfo = new ModInfo({
name: "added-mod", version: "0.5.0", factorio_version: "1.1", title: "T", author: "A",
dependencies: [], description: "D", sha1: "dummy", size: 100, mtimeMs: Date.now(),
});
assert(!modStore.files.has(modInfo.filename), "Mod should not be present before addMod");
modStore.addMod(modInfo);
assert(modStore.files.has(modInfo.filename), "Mod should be present after addMod");
assert.equal(modStore.files.get(modInfo.filename), modInfo);
});
it("should emit 'change' event when adding a mod", function () {
const modInfo = new ModInfo({
name: "added-mod-event", version: "0.5.1", factorio_version: "1.1", title: "T", author: "A",
dependencies: [], description: "D", sha1: "dummy", size: 100, mtimeMs: Date.now(),
});
let changeEventFired = false;
let eventModInfo = null;
modStore.on("change", (mod) => {
changeEventFired = true;
eventModInfo = mod;
});
modStore.addMod(modInfo);
assert(changeEventFired, "Change event should have fired");
assert.equal(eventModInfo, modInfo, "Event should pass the added ModInfo");
});
});
describe("filterInstalled", function() {
it("should filter out installed mods", function() {
const mods = [
{ name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) },
{ name: "not-installed", version: ModVersionEquality.fromString("1.0.0") },
];
assert.deepEqual(modStore.filterInstalled(mods, false), [
{ name: "not-installed", version: ModVersionEquality.fromString("1.0.0") },
]);
});
it("should filter out uninstalled mods", function() {
const mods = [
{ name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) },
{ name: "not-installed", version: ModVersionEquality.fromString("1.0.0") },
];
assert.deepEqual(modStore.filterInstalled(mods, true), [
{ name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) },
]);
});
});
});
});