Files
factorio-clusterio/test/lib/file_ops.js
T
Hornwitser 2e48b728dd Remove fs-extra and klaw (#882)
These packages are obsoleted by the promises API to the built-in Node fs
module.  The fs-extra package is also causing problems with broken APIs
that don't behave the same as Node's fs module, and throwing errors
without code properties on them.
2026-04-09 22:53:13 +02:00

406 lines
14 KiB
JavaScript

"use strict";
const assert = require("assert").strict;
const fs = require("node:fs/promises");
const http = require("node:http");
const path = require("path");
const util = require("node:util");
const lib = require("@clusterio/lib");
describe("lib/file_ops", function() {
let baseDir = path.join("temp", "test", "file_ops");
async function setupTestingEnv() {
await fs.mkdir(path.join(baseDir, "test", "folder"), { recursive: true });
await fs.mkdir(path.join(baseDir, "test", "another folder"), { recursive: true });
await fs.rm(path.join(baseDir, "safe"), { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(path.join(baseDir, "safe"), { recursive: true });
await fs.writeFile(path.join(baseDir, "test", "file.txt"), "contents");
await fs.writeFile(path.join(baseDir, "test", "another file.txt"), "more contents");
await fs.mkdir(path.join(baseDir, "find"), { recursive: true });
await fs.writeFile(path.join(baseDir, "find", "file"), "contents");
await fs.writeFile(path.join(baseDir, "find", "file.txt"), "contents");
await fs.writeFile(path.join(baseDir, "find", "foo-1"), "contents");
await fs.writeFile(path.join(baseDir, "find", "foo-2"), "contents");
await fs.writeFile(path.join(baseDir, "find", "bar-1.txt"), "contents");
await fs.writeFile(path.join(baseDir, "find", "bar-2.txt"), "contents");
}
before(setupTestingEnv);
describe("getNewestFile()", function() {
it("returns a string in a directory with files", async function() {
let newest = await lib.getNewestFile(path.join(baseDir, "test"));
assert.equal(typeof newest, "string");
});
it("returns undefined if all entries were filtered out", async function() {
let newest = await lib.getNewestFile(path.join(baseDir, "test"), (name) => !name.endsWith(".txt"));
assert.equal(newest, undefined);
});
it("returns undefined if directory is empty", async function() {
let newest = await lib.getNewestFile(path.join(baseDir, "test", "folder"));
assert.equal(newest, undefined);
});
});
describe("getNewestFile()", function() {
it("returns 0 if directory does not exist", async function() {
let size = await lib.directorySize(path.join(baseDir, "invalid"));
assert.equal(size, 0);
});
it("returns 0 if directory is empty", async function() {
let size = await lib.directorySize(path.join(baseDir, "test", "folder"));
assert.equal(size, 0);
});
it("returns size of files in directory", async function() {
let size = await lib.directorySize(path.join(baseDir, "test"));
assert.equal(size, 21);
});
});
describe("findUnusedName()", function() {
it("should return named unchanged if it does not exist", async function() {
let cases = [
[["file"], "file"],
[["file", ".txt"], "file.txt"],
[["file.txt", ".txt"], "file.txt"],
];
for (let [args, expected] of cases) {
let actual = await lib.findUnusedName(path.join(baseDir, "test", "folder"), ...args);
assert.equal(actual, expected);
}
});
it("should return changed name if it does exist", async function() {
let cases = [
[["file"], "file-2"],
[["file", ".txt"], "file-2.txt"],
[["file.txt", ".txt"], "file-2.txt"],
[["foo-1"], "foo-3"],
[["bar-1", ".txt"], "bar-3.txt"],
[["bar-1.txt", ".txt"], "bar-3.txt"],
];
for (let [args, expected] of cases) {
let actual = await lib.findUnusedName(path.join(baseDir, "find"), ...args);
assert.equal(actual, expected);
}
});
});
describe("safeOutputFile()", function() {
it("should write new target file", async function() {
let target = path.join(baseDir, "safe", "simple.txt");
await lib.safeOutputFile(target, "a text file", "utf8");
await assert.rejects(fs.access(target.replace(".txt", ".tmp.txt")), "temporary was left behind");
assert.equal(await fs.readFile(target, "utf8"), "a text file");
});
it("should overwrite existing target file", async function() {
let target = path.join(baseDir, "safe", "exists.txt");
await fs.writeFile(target, "previous", "utf8");
await lib.safeOutputFile(target, "current", "utf8");
await assert.rejects(fs.access(target.replace(".txt", ".tmp.txt")), "temporary was left behind");
assert.equal(await fs.readFile(target, "utf8"), "current");
});
it("should handle creating file in current working directory", async function() {
let target = "temporary-file-made-to-test-cwd.txt";
try {
await lib.safeOutputFile(target, "a text file", "utf8");
} finally {
try {
await fs.unlink(target);
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
}
});
});
describe("checkFilename()", function() {
it("should allow a basic name", function() {
lib.checkFilename("file");
});
function check(item, msg) {
assert.throws(() => lib.checkFilename(item), new Error(msg));
}
it("should throw on non-string", function() {
check(undefined, "must be a string");
check(null, "must be a string");
check({}, "must be a string");
check([], "must be a string");
check(0, "must be a string");
check(false, "must be a string");
});
it("should throw on empty name", function() {
check("", "cannot be empty");
});
it("should throw on <>:\"\\/|?* \\x00\\r\\n\\t", function() {
for (let char of '<>:"\\/|?*\x00\r\n\t') {
check(char, 'cannot contain <>:"\\/|=* or control characters');
}
});
it("should throw on CON, PRN, AUX, NUL, COM1, LPT1", function() {
for (let bad of ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"]) {
check(bad, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
check(`${bad}.zip`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
check(`${bad}.anything.txt`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
check(`${bad}....a`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
check(`${bad}.`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
}
});
it("should throw on . and ..", function() {
for (let bad of [".", ".."]) {
check(bad, `cannot be named ${bad}`);
}
});
it("should throw on names ending with . or space", function() {
check("a ", "cannot end with . or space");
check("a.", "cannot end with . or space");
});
});
describe("cleanFilename()", function() {
function clean(item, expected) {
assert.equal(lib.cleanFilename(item), expected);
}
it("should allow a basic name", function() {
clean("file", "file");
});
function check(item, msg) {
assert.throws(() => lib.cleanFilename(item), new Error(msg));
}
it("should throw on non-string", function() {
check(undefined, "name must be a string");
check(null, "name must be a string");
check({}, "name must be a string");
check([], "name must be a string");
check(0, "name must be a string");
check(false, "name must be a string");
});
it("should clean empty name", function() {
clean("", "_");
});
it("should clean <>:\"\\/|?* \\x00\\r\\n\\t", function() {
for (let char of '<>:"\\/|?*\x00\r\n\t') {
clean(char, "_");
}
});
it("should clean CON, PRN, AUX, NUL, COM1, LPT1", function() {
for (let bad of ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"]) {
clean(bad, `${bad}_`);
clean(`${bad}.zip`, `${bad}_.zip`);
clean(`${bad}.anything.txt`, `${bad}_.anything.txt`);
clean(`${bad}....a`, `${bad}_....a`);
clean(`${bad}.`, `${bad}__`);
}
});
it("should clean . and ..", function() {
for (let bad of [".", ".."]) {
clean(bad, `${bad}_`);
}
});
it("should clean names ending with . or space", function() {
clean("a ", "a_");
clean("a.", "a_");
});
});
describe("downloadFile()", function () {
const baseUrl = new URL("http://localhost/");
const downloadDir = path.join("temp", "test", "downloadFile");
const fileContent = "mock file content";
let onStream;
let server;
before(async function () {
await fs.rm(downloadDir, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(downloadDir, { recursive: true });
server = http.createServer();
server.unref();
server.on("request", (req, res) => {
if (req.url === "/simple-file") {
res.writeHead(200, {
"Content-Length": Buffer.byteLength(fileContent),
"Content-Type": "text/plain",
}).end(fileContent);
} else if (req.url === "/empty-file") {
res.writeHead(200, {
"Content-Length": 0,
"Content-Type": "text/plain",
}).end();
} else if (req.url === "/no-content") {
res.writeHead(204).end();
} else if (req.url === "/stream") {
res.writeHead(200, {
"Content-Type": "text/plain",
});
res.write("streaming\n");
onStream(res);
} else {
const body = "not found";
res.writeHead(404, {
"Content-Length": Buffer.byteLength(body),
"Content-Type": "text/plain",
}).end(body);
}
});
await util.promisify(server.listen.bind(server))();
baseUrl.port = server.address().port;
});
after(function() {
server.close();
server.closeAllConnections();
});
it("should download content from URL to the specified file path", async function () {
for (const mode of ["overwrite", "rename", "error"]) {
const downloadUrl = new URL("/simple-file", baseUrl);
const downloadPath = path.join(downloadDir, `simple-file-${mode}.txt`);
const newDownloadPath = await lib.downloadFile(downloadUrl, downloadPath, mode);
const writtenContent = await fs.readFile(downloadPath, "utf8");
assert.equal(writtenContent, fileContent);
assert.equal(downloadPath, newDownloadPath);
}
});
it("should throw if fetch response is not ok", async function () {
const downloadPath = path.join(downloadDir, "not-found.txt");
await assert.rejects(
lib.downloadFile(new URL("/not-found", baseUrl), downloadPath, "overwrite"),
/Failed to download .* 404 Not Found/
);
await assert.rejects(
fs.readFile(downloadPath),
{ code: "ENOENT" },
);
});
it("should throw if fetch response body is missing", async function () {
const downloadPath = path.join(downloadDir, "not-content.txt");
await assert.rejects(
lib.downloadFile(new URL("/no-content", baseUrl), downloadPath, "overwrite"),
/Failed to download .* 204 No Content/
);
await assert.rejects(
fs.readFile(downloadPath),
{ code: "ENOENT" },
);
});
it("should throw if file exists and overwriteMode is error", async function () {
const downloadPath = path.join(downloadDir, "exists.txt");
await fs.writeFile(downloadPath, "");
await assert.rejects(
lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "error"),
{ code: "EEXIST" },
);
});
it("should throw if target directory does not exist", async function () {
const downloadPath = path.join(downloadDir, "does-not-exist", "simple.txt");
for (const mode of ["overwrite", "rename", "error"]) {
await assert.rejects(
lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, mode),
{ code: "ENOENT" },
);
}
});
it("should write a new file if it exists and overwriteMode is rename", async function () {
const downloadPath = path.join(downloadDir, "exists.txt");
await fs.writeFile(downloadPath, "");
const newDownloadPath = await lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "rename");
assert.equal(newDownloadPath, path.join(downloadDir, "exists-2.txt"));
const writtenContent = await fs.readFile(newDownloadPath, "utf8");
assert.equal(writtenContent, fileContent);
});
it("should write a new file if many files exists and overwriteMode is rename", async function () {
const downloadPath = path.join(downloadDir, "contended.txt");
const additionalPaths = [
path.join(downloadDir, "contended-2.txt"),
path.join(downloadDir, "contended-3.txt"),
path.join(downloadDir, "contended-4.txt"),
];
await fs.writeFile(downloadPath, "");
for (const additionalPath of additionalPaths) {
await fs.writeFile(additionalPath, "");
}
const newDownloadPath = await lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "rename");
assert.equal(newDownloadPath, path.join(downloadDir, "contended-5.txt"));
const writtenContent = await fs.readFile(newDownloadPath, "utf8");
assert.equal(writtenContent, fileContent);
});
it("should overwrite existing file if overwriteMode is overwrite", async function () {
const downloadPath = path.join(downloadDir, "overwrite.txt");
await fs.writeFile(downloadPath, "original content");
const newDownloadPath = await lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "overwrite");
assert.equal(newDownloadPath, downloadPath);
const writtenContent = await fs.readFile(newDownloadPath, "utf8");
assert.equal(writtenContent, fileContent);
});
it("should not overwrite existing temp files", async function () {
const tempPath = path.join(downloadDir, "temp-exists.tmp.txt");
await fs.writeFile(tempPath, "original content");
const downloadPath = path.join(downloadDir, "temp-exists.txt");
await lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "overwrite");
const writtenContent = await fs.readFile(tempPath, "utf8");
assert.equal(writtenContent, "original content");
});
it("should throw if overwriteMode is rename and directory disappeared", async function () {
if (process.platform === "win32") {
// Can't remove the directory while the stream is running on Windows.
this.skip();
}
function stageWaiter() {
let stage;
let waitForStage = new Promise(resolve => { stage = resolve; });
return [stage, waitForStage];
}
const downloadSubdir = path.join(downloadDir, "subdir");
const downloadPath = path.join(downloadSubdir, "test.txt");
await fs.mkdir(downloadSubdir, { recursive: true });
const [stage1, waitForStage1] = stageWaiter();
const [stage2, waitForStage2] = stageWaiter();
onStream = async (res) => {
stage1();
await waitForStage2;
res.end("done");
};
const download = lib.downloadFile(new URL("/stream", baseUrl), downloadPath, "rename");
download.catch(() => {});
await waitForStage1;
await fs.rm(downloadSubdir, { recursive: true, maxRetries: 10 });
stage2();
await assert.rejects(
download,
{ code: "ENOENT" },
);
});
});
});