factorio-test-cli 2.0.1 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/cli-error.js +6 -0
- package/cli.js +18 -7
- package/config/cli-config.js +142 -0
- package/config/index.js +3 -0
- package/config/loader.js +72 -0
- package/config/test-config.js +104 -0
- package/config.test.js +94 -0
- package/factorio-discovery.js +53 -0
- package/factorio-discovery.test.js +24 -0
- package/factorio-output-parser.js +41 -0
- package/factorio-output-parser.test.js +38 -0
- package/factorio-process.js +165 -0
- package/factorio-process.test.js +15 -0
- package/file-watcher.js +36 -0
- package/file-watcher.test.js +29 -0
- package/headless-save.zip +0 -0
- package/mod-setup.js +246 -0
- package/mod-setup.test.js +30 -0
- package/output-formatter.js +94 -0
- package/output-formatter.test.js +93 -0
- package/package.json +19 -7
- package/process-utils.js +27 -0
- package/progress-renderer.js +70 -0
- package/progress-renderer.test.js +88 -0
- package/results-writer.js +30 -0
- package/results-writer.test.js +89 -0
- package/run.js +178 -367
- package/schema.test.js +67 -0
- package/test-run-collector.js +92 -0
- package/test-run-collector.test.js +101 -0
- package/vitest.config.js +7 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import BufferLineSplitter from "./buffer-line-splitter.js";
|
|
5
|
+
import { FactorioOutputHandler } from "./factorio-output-parser.js";
|
|
6
|
+
import { OutputPrinter } from "./output-formatter.js";
|
|
7
|
+
import { ProgressRenderer } from "./progress-renderer.js";
|
|
8
|
+
import { TestRunCollector } from "./test-run-collector.js";
|
|
9
|
+
import { CliError } from "./cli-error.js";
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
export function getHeadlessSavePath(overridePath) {
|
|
12
|
+
if (overridePath) {
|
|
13
|
+
return path.resolve(overridePath);
|
|
14
|
+
}
|
|
15
|
+
return path.join(__dirname, "headless-save.zip");
|
|
16
|
+
}
|
|
17
|
+
export function parseResultMessage(message) {
|
|
18
|
+
if (message.endsWith(":focused")) {
|
|
19
|
+
return {
|
|
20
|
+
status: message.slice(0, -":focused".length),
|
|
21
|
+
hasFocusedTests: true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
status: message,
|
|
26
|
+
hasFocusedTests: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function createOutputComponents(options) {
|
|
30
|
+
const handler = new FactorioOutputHandler();
|
|
31
|
+
const collector = new TestRunCollector();
|
|
32
|
+
const printer = new OutputPrinter({
|
|
33
|
+
verbose: options.verbose,
|
|
34
|
+
quiet: options.quiet,
|
|
35
|
+
});
|
|
36
|
+
const progress = new ProgressRenderer();
|
|
37
|
+
handler.on("event", (event) => {
|
|
38
|
+
collector.handleEvent(event);
|
|
39
|
+
progress.handleEvent(event);
|
|
40
|
+
if (options.verbose) {
|
|
41
|
+
progress.withPermanentOutput(() => console.log(JSON.stringify(event)));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
handler.on("log", (line) => {
|
|
45
|
+
collector.captureLog(line);
|
|
46
|
+
progress.withPermanentOutput(() => printer.printVerbose(line));
|
|
47
|
+
});
|
|
48
|
+
handler.on("message", (line) => {
|
|
49
|
+
progress.withPermanentOutput(() => printer.printMessage(line));
|
|
50
|
+
});
|
|
51
|
+
collector.on("testFinished", (test) => {
|
|
52
|
+
progress.handleTestFinished(test);
|
|
53
|
+
progress.withPermanentOutput(() => printer.printTestResult(test));
|
|
54
|
+
});
|
|
55
|
+
return { handler, collector, printer, progress };
|
|
56
|
+
}
|
|
57
|
+
export async function runFactorioTestsHeadless(factorioPath, dataDir, savePath, additionalArgs, options) {
|
|
58
|
+
const args = [
|
|
59
|
+
"--benchmark",
|
|
60
|
+
savePath,
|
|
61
|
+
"--benchmark-ticks",
|
|
62
|
+
"1000000000",
|
|
63
|
+
"--mod-directory",
|
|
64
|
+
path.join(dataDir, "mods"),
|
|
65
|
+
"-c",
|
|
66
|
+
path.join(dataDir, "config.ini"),
|
|
67
|
+
...additionalArgs,
|
|
68
|
+
];
|
|
69
|
+
console.log("Running tests (headless)...");
|
|
70
|
+
const factorioProcess = spawn(factorioPath, args, {
|
|
71
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
const { handler, collector, printer, progress } = createOutputComponents(options);
|
|
74
|
+
let resultMessage;
|
|
75
|
+
let testRunStarted = false;
|
|
76
|
+
let startupTimedOut = false;
|
|
77
|
+
let wasCancelled = false;
|
|
78
|
+
handler.on("result", (msg) => {
|
|
79
|
+
resultMessage = msg;
|
|
80
|
+
progress.finish();
|
|
81
|
+
printer.resetMessage();
|
|
82
|
+
});
|
|
83
|
+
handler.on("event", (event) => {
|
|
84
|
+
if (event.type === "testRunStarted") {
|
|
85
|
+
testRunStarted = true;
|
|
86
|
+
clearTimeout(startupTimeout);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
const startupTimeout = setTimeout(() => {
|
|
90
|
+
if (!testRunStarted) {
|
|
91
|
+
startupTimedOut = true;
|
|
92
|
+
factorioProcess.kill();
|
|
93
|
+
}
|
|
94
|
+
}, 10_000);
|
|
95
|
+
const abortHandler = () => {
|
|
96
|
+
wasCancelled = true;
|
|
97
|
+
factorioProcess.kill();
|
|
98
|
+
};
|
|
99
|
+
options.signal?.addEventListener("abort", abortHandler);
|
|
100
|
+
new BufferLineSplitter(factorioProcess.stdout).on("line", (line) => handler.handleLine(line));
|
|
101
|
+
new BufferLineSplitter(factorioProcess.stderr).on("line", (line) => handler.handleLine(line));
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
factorioProcess.on("exit", (code, signal) => {
|
|
104
|
+
clearTimeout(startupTimeout);
|
|
105
|
+
options.signal?.removeEventListener("abort", abortHandler);
|
|
106
|
+
if (wasCancelled) {
|
|
107
|
+
resolve();
|
|
108
|
+
}
|
|
109
|
+
else if (startupTimedOut) {
|
|
110
|
+
reject(new CliError("Factorio unresponsive: no test run started within 10 seconds"));
|
|
111
|
+
}
|
|
112
|
+
else if (resultMessage !== undefined) {
|
|
113
|
+
resolve();
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
reject(new CliError(`Factorio exited with code ${code}, signal ${signal}, no result received`));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
if (wasCancelled) {
|
|
121
|
+
return { status: "cancelled", hasFocusedTests: false };
|
|
122
|
+
}
|
|
123
|
+
const parsed = parseResultMessage(resultMessage);
|
|
124
|
+
return { ...parsed, message: resultMessage, data: collector.getData() };
|
|
125
|
+
}
|
|
126
|
+
export async function runFactorioTestsGraphics(factorioPath, dataDir, savePath, additionalArgs, options) {
|
|
127
|
+
const args = [
|
|
128
|
+
"--load-game",
|
|
129
|
+
savePath,
|
|
130
|
+
"--mod-directory",
|
|
131
|
+
path.join(dataDir, "mods"),
|
|
132
|
+
"-c",
|
|
133
|
+
path.join(dataDir, "config.ini"),
|
|
134
|
+
...additionalArgs,
|
|
135
|
+
];
|
|
136
|
+
console.log("Running tests (graphics)...");
|
|
137
|
+
const factorioProcess = spawn(factorioPath, args, {
|
|
138
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
139
|
+
});
|
|
140
|
+
const { handler, collector, printer, progress } = createOutputComponents(options);
|
|
141
|
+
let resultMessage;
|
|
142
|
+
let resolvePromise;
|
|
143
|
+
handler.on("result", (msg) => {
|
|
144
|
+
resultMessage = msg;
|
|
145
|
+
progress.finish();
|
|
146
|
+
printer.resetMessage();
|
|
147
|
+
if (options.resolveOnResult && resolvePromise) {
|
|
148
|
+
resolvePromise();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
new BufferLineSplitter(factorioProcess.stdout).on("line", (line) => handler.handleLine(line));
|
|
152
|
+
await new Promise((resolve, reject) => {
|
|
153
|
+
resolvePromise = resolve;
|
|
154
|
+
factorioProcess.on("exit", (code, signal) => {
|
|
155
|
+
if (resultMessage !== undefined) {
|
|
156
|
+
resolve();
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
reject(new CliError(`Factorio exited with code ${code}, signal ${signal}`));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
const parsed = parseResultMessage(resultMessage);
|
|
164
|
+
return { ...parsed, message: resultMessage, data: collector.getData() };
|
|
165
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseResultMessage } from "./factorio-process.js";
|
|
3
|
+
describe("parseResultMessage", () => {
|
|
4
|
+
it.each([
|
|
5
|
+
["passed", { status: "passed", hasFocusedTests: false }],
|
|
6
|
+
["failed", { status: "failed", hasFocusedTests: false }],
|
|
7
|
+
["todo", { status: "todo", hasFocusedTests: false }],
|
|
8
|
+
["loadError", { status: "loadError", hasFocusedTests: false }],
|
|
9
|
+
["passed:focused", { status: "passed", hasFocusedTests: true }],
|
|
10
|
+
["failed:focused", { status: "failed", hasFocusedTests: true }],
|
|
11
|
+
["todo:focused", { status: "todo", hasFocusedTests: true }],
|
|
12
|
+
])("parses %s", (input, expected) => {
|
|
13
|
+
expect(parseResultMessage(input)).toEqual(expected);
|
|
14
|
+
});
|
|
15
|
+
});
|
package/file-watcher.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { minimatch } from "minimatch";
|
|
3
|
+
export function matchesPattern(filename, patterns) {
|
|
4
|
+
const normalizedFilename = filename.replace(/\\/g, "/");
|
|
5
|
+
return patterns.some((pattern) => minimatch(normalizedFilename, pattern));
|
|
6
|
+
}
|
|
7
|
+
export function watchDirectory(dir, onChange, options) {
|
|
8
|
+
const debounceMs = options.debounceMs ?? 300;
|
|
9
|
+
let timeout;
|
|
10
|
+
const debouncedOnChange = () => {
|
|
11
|
+
if (timeout)
|
|
12
|
+
clearTimeout(timeout);
|
|
13
|
+
timeout = setTimeout(onChange, debounceMs);
|
|
14
|
+
};
|
|
15
|
+
const watcher = fs.watch(dir, { recursive: true }, (_, filename) => {
|
|
16
|
+
if (!filename)
|
|
17
|
+
return;
|
|
18
|
+
if (matchesPattern(filename, options.patterns)) {
|
|
19
|
+
debouncedOnChange();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return watcher;
|
|
23
|
+
}
|
|
24
|
+
export function watchFile(filePath, onChange, options) {
|
|
25
|
+
const debounceMs = options?.debounceMs ?? 300;
|
|
26
|
+
let timeout;
|
|
27
|
+
const debouncedOnChange = () => {
|
|
28
|
+
if (timeout)
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
timeout = setTimeout(onChange, debounceMs);
|
|
31
|
+
};
|
|
32
|
+
const watcher = fs.watch(filePath, () => {
|
|
33
|
+
debouncedOnChange();
|
|
34
|
+
});
|
|
35
|
+
return watcher;
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { matchesPattern } from "./file-watcher.js";
|
|
3
|
+
describe("matchesPattern", () => {
|
|
4
|
+
const defaultPatterns = ["info.json", "**/*.lua"];
|
|
5
|
+
it.each([
|
|
6
|
+
["info.json", defaultPatterns, true],
|
|
7
|
+
["control.lua", defaultPatterns, true],
|
|
8
|
+
["nested/file.lua", defaultPatterns, true],
|
|
9
|
+
["deeply/nested/module.lua", defaultPatterns, true],
|
|
10
|
+
["data.ts", defaultPatterns, false],
|
|
11
|
+
["settings.json", defaultPatterns, false],
|
|
12
|
+
["info.json.bak", defaultPatterns, false],
|
|
13
|
+
["some/info.json", defaultPatterns, false],
|
|
14
|
+
])("matchesPattern(%j, %j) => %j", (filename, patterns, expected) => {
|
|
15
|
+
expect(matchesPattern(filename, patterns)).toBe(expected);
|
|
16
|
+
});
|
|
17
|
+
it("handles custom patterns", () => {
|
|
18
|
+
expect(matchesPattern("src/main.ts", ["**/*.ts"])).toBe(true);
|
|
19
|
+
expect(matchesPattern("main.ts", ["**/*.ts"])).toBe(true);
|
|
20
|
+
expect(matchesPattern("main.js", ["**/*.ts"])).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
it("handles single wildcard", () => {
|
|
23
|
+
expect(matchesPattern("file.lua", ["*.lua"])).toBe(true);
|
|
24
|
+
expect(matchesPattern("nested/file.lua", ["*.lua"])).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
it("handles backslash paths (Windows)", () => {
|
|
27
|
+
expect(matchesPattern("nested\\file.lua", defaultPatterns)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
Binary file
|
package/mod-setup.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import * as fsp from "fs/promises";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { runScript, runProcess } from "./process-utils.js";
|
|
5
|
+
import { getFactorioPlayerDataPath } from "./factorio-discovery.js";
|
|
6
|
+
import { CliError } from "./cli-error.js";
|
|
7
|
+
const MIN_FACTORIO_TEST_VERSION = "3.0.0";
|
|
8
|
+
function parseVersion(version) {
|
|
9
|
+
const parts = version.split(".").map(Number);
|
|
10
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
11
|
+
}
|
|
12
|
+
function compareVersions(a, b) {
|
|
13
|
+
const [aMajor, aMinor, aPatch] = parseVersion(a);
|
|
14
|
+
const [bMajor, bMinor, bPatch] = parseVersion(b);
|
|
15
|
+
if (aMajor !== bMajor)
|
|
16
|
+
return aMajor - bMajor;
|
|
17
|
+
if (aMinor !== bMinor)
|
|
18
|
+
return aMinor - bMinor;
|
|
19
|
+
return aPatch - bPatch;
|
|
20
|
+
}
|
|
21
|
+
export async function configureModToTest(modsDir, modPath, modName, verbose) {
|
|
22
|
+
if (modPath) {
|
|
23
|
+
if (verbose)
|
|
24
|
+
console.log("Creating mod symlink", modPath);
|
|
25
|
+
return configureModPath(modPath, modsDir);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
await configureModName(modsDir, modName);
|
|
29
|
+
return modName;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function configureModPath(modPath, modsDir) {
|
|
33
|
+
modPath = path.resolve(modPath);
|
|
34
|
+
const infoJsonFile = path.join(modPath, "info.json");
|
|
35
|
+
let infoJson;
|
|
36
|
+
try {
|
|
37
|
+
infoJson = JSON.parse(await fsp.readFile(infoJsonFile, "utf8"));
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
throw new CliError(`Could not read info.json file from ${modPath}`, { cause: e });
|
|
41
|
+
}
|
|
42
|
+
const modName = infoJson.name;
|
|
43
|
+
if (typeof modName !== "string") {
|
|
44
|
+
throw new CliError(`info.json file at ${infoJsonFile} does not contain a string property "name".`);
|
|
45
|
+
}
|
|
46
|
+
const resultPath = path.join(modsDir, modName);
|
|
47
|
+
const stat = await fsp.stat(resultPath).catch(() => undefined);
|
|
48
|
+
if (stat)
|
|
49
|
+
await fsp.rm(resultPath, { recursive: true });
|
|
50
|
+
await fsp.symlink(modPath, resultPath, "junction");
|
|
51
|
+
return modName;
|
|
52
|
+
}
|
|
53
|
+
async function configureModName(modsDir, modName) {
|
|
54
|
+
const exists = await checkModExists(modsDir, modName);
|
|
55
|
+
if (!exists) {
|
|
56
|
+
throw new CliError(`Mod ${modName} not found in ${modsDir}.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function checkModExists(modsDir, modName) {
|
|
60
|
+
const stat = await fsp.stat(modsDir).catch(() => undefined);
|
|
61
|
+
if (!stat?.isDirectory())
|
|
62
|
+
return false;
|
|
63
|
+
const files = await fsp.readdir(modsDir);
|
|
64
|
+
return files.some((f) => {
|
|
65
|
+
const fileStat = fs.statSync(path.join(modsDir, f), { throwIfNoEntry: false });
|
|
66
|
+
if (fileStat?.isDirectory()) {
|
|
67
|
+
return f === modName || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+$`));
|
|
68
|
+
}
|
|
69
|
+
if (fileStat?.isFile()) {
|
|
70
|
+
return f === modName + ".zip" || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+\\.zip$`));
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function getInstalledModVersion(modsDir, modName) {
|
|
76
|
+
const stat = await fsp.stat(modsDir).catch(() => undefined);
|
|
77
|
+
if (!stat?.isDirectory())
|
|
78
|
+
return undefined;
|
|
79
|
+
const files = await fsp.readdir(modsDir);
|
|
80
|
+
for (const f of files) {
|
|
81
|
+
const fullPath = path.join(modsDir, f);
|
|
82
|
+
const fileStat = fs.statSync(fullPath, { throwIfNoEntry: false });
|
|
83
|
+
if (fileStat?.isDirectory()) {
|
|
84
|
+
if (f === modName) {
|
|
85
|
+
const infoPath = path.join(fullPath, "info.json");
|
|
86
|
+
try {
|
|
87
|
+
const info = JSON.parse(await fsp.readFile(infoPath, "utf8"));
|
|
88
|
+
if (info.version)
|
|
89
|
+
return info.version;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const versionedMatch = f.match(new RegExp(`^${modName}_(\\d+\\.\\d+\\.\\d+)$`));
|
|
96
|
+
if (versionedMatch) {
|
|
97
|
+
const infoPath = path.join(fullPath, "info.json");
|
|
98
|
+
try {
|
|
99
|
+
const info = JSON.parse(await fsp.readFile(infoPath, "utf8"));
|
|
100
|
+
if (info.version)
|
|
101
|
+
return info.version;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return versionedMatch[1];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (fileStat?.isFile()) {
|
|
109
|
+
const zipMatch = f.match(new RegExp(`^${modName}_(\\d+\\.\\d+\\.\\d+)\\.zip$`));
|
|
110
|
+
if (zipMatch)
|
|
111
|
+
return zipMatch[1];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
export async function installFactorioTest(modsDir) {
|
|
117
|
+
await fsp.mkdir(modsDir, { recursive: true });
|
|
118
|
+
const playerDataPath = getFactorioPlayerDataPath();
|
|
119
|
+
let version = await getInstalledModVersion(modsDir, "factorio-test");
|
|
120
|
+
if (!version) {
|
|
121
|
+
console.log("Downloading factorio-test from mod portal using fmtk.");
|
|
122
|
+
await runScript("fmtk", "mods", "install", "--modsPath", modsDir, "--playerData", playerDataPath, "factorio-test");
|
|
123
|
+
version = await getInstalledModVersion(modsDir, "factorio-test");
|
|
124
|
+
}
|
|
125
|
+
else if (compareVersions(version, MIN_FACTORIO_TEST_VERSION) < 0) {
|
|
126
|
+
console.log(`factorio-test ${version} is outdated, downloading latest version.`);
|
|
127
|
+
await runScript("fmtk", "mods", "install", "--force", "--modsPath", modsDir, "--playerData", playerDataPath, "factorio-test");
|
|
128
|
+
version = await getInstalledModVersion(modsDir, "factorio-test");
|
|
129
|
+
}
|
|
130
|
+
if (!version || compareVersions(version, MIN_FACTORIO_TEST_VERSION) < 0) {
|
|
131
|
+
throw new CliError(`factorio-test mod version ${version ?? "unknown"} is below minimum required ${MIN_FACTORIO_TEST_VERSION}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export async function ensureConfigIni(dataDir) {
|
|
135
|
+
const filePath = path.join(dataDir, "config.ini");
|
|
136
|
+
if (!fs.existsSync(filePath)) {
|
|
137
|
+
console.log("Creating config.ini file");
|
|
138
|
+
await fsp.writeFile(filePath, `; This file was auto-generated by factorio-test cli
|
|
139
|
+
|
|
140
|
+
[path]
|
|
141
|
+
read-data=__PATH__executable__/../../data
|
|
142
|
+
write-data=${dataDir}
|
|
143
|
+
|
|
144
|
+
[general]
|
|
145
|
+
locale=
|
|
146
|
+
`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const content = await fsp.readFile(filePath, "utf8");
|
|
150
|
+
const newContent = content.replace(/^write-data=.*$/m, `write-data=${dataDir}`);
|
|
151
|
+
if (content !== newContent) {
|
|
152
|
+
await fsp.writeFile(filePath, newContent);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export async function setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest, mode, options) {
|
|
157
|
+
const settingsDat = path.join(modsDir, "mod-settings.dat");
|
|
158
|
+
if (!fs.existsSync(settingsDat)) {
|
|
159
|
+
if (options?.verbose)
|
|
160
|
+
console.log("Creating mod-settings.dat file by running factorio");
|
|
161
|
+
const dummySaveFile = path.join(dataDir, "____dummy_save_file.zip");
|
|
162
|
+
await runProcess(false, factorioPath, "--create", dummySaveFile, "--mod-directory", modsDir, "-c", path.join(dataDir, "config.ini"));
|
|
163
|
+
if (fs.existsSync(dummySaveFile)) {
|
|
164
|
+
await fsp.rm(dummySaveFile);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (options?.verbose)
|
|
168
|
+
console.log("Setting autorun settings");
|
|
169
|
+
const autoStartConfig = JSON.stringify({
|
|
170
|
+
mod: modToTest,
|
|
171
|
+
headless: mode === "headless",
|
|
172
|
+
...(options?.lastFailedTests?.length && { last_failed_tests: options.lastFailedTests }),
|
|
173
|
+
});
|
|
174
|
+
await runScript("fmtk", "settings", "set", "startup", "factorio-test-auto-start-config", autoStartConfig, "--modsPath", modsDir);
|
|
175
|
+
await runScript("fmtk", "settings", "unset", "startup", "factorio-test-auto-start", "--modsPath", modsDir);
|
|
176
|
+
}
|
|
177
|
+
export async function resetAutorunSettings(modsDir, verbose) {
|
|
178
|
+
if (verbose)
|
|
179
|
+
console.log("Disabling auto-start settings");
|
|
180
|
+
await runScript("fmtk", "settings", "set", "startup", "factorio-test-auto-start-config", "{}", "--modsPath", modsDir);
|
|
181
|
+
}
|
|
182
|
+
export function parseRequiredDependencies(dependencies) {
|
|
183
|
+
const result = [];
|
|
184
|
+
for (const dep of dependencies) {
|
|
185
|
+
const trimmed = dep.trim();
|
|
186
|
+
if (trimmed.startsWith("?") || trimmed.startsWith("!") || trimmed.startsWith("(?)")) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const withoutPrefix = trimmed.startsWith("~") ? trimmed.slice(1).trim() : trimmed;
|
|
190
|
+
const modName = withoutPrefix.split(/\s/)[0];
|
|
191
|
+
if (modName && modName !== "base") {
|
|
192
|
+
result.push(modName);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
export async function installModDependencies(modsDir, modPath, verbose) {
|
|
198
|
+
const infoJsonPath = path.join(modPath, "info.json");
|
|
199
|
+
let infoJson;
|
|
200
|
+
try {
|
|
201
|
+
infoJson = JSON.parse(await fsp.readFile(infoJsonPath, "utf8"));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
const dependencies = infoJson.dependencies;
|
|
207
|
+
if (!Array.isArray(dependencies))
|
|
208
|
+
return [];
|
|
209
|
+
const required = parseRequiredDependencies(dependencies);
|
|
210
|
+
const playerDataPath = getFactorioPlayerDataPath();
|
|
211
|
+
for (const modName of required) {
|
|
212
|
+
const exists = await checkModExists(modsDir, modName);
|
|
213
|
+
if (exists)
|
|
214
|
+
continue;
|
|
215
|
+
if (verbose)
|
|
216
|
+
console.log(`Installing dependency: ${modName}`);
|
|
217
|
+
await runScript("fmtk", "mods", "install", "--modsPath", modsDir, "--playerData", playerDataPath, modName);
|
|
218
|
+
}
|
|
219
|
+
return required;
|
|
220
|
+
}
|
|
221
|
+
export async function resolveModWatchTarget(modsDir, modPath, modName) {
|
|
222
|
+
if (modPath) {
|
|
223
|
+
return { type: "directory", path: path.resolve(modPath) };
|
|
224
|
+
}
|
|
225
|
+
const files = await fsp.readdir(modsDir);
|
|
226
|
+
for (const file of files) {
|
|
227
|
+
const fullPath = path.join(modsDir, file);
|
|
228
|
+
const fileStat = await fsp.lstat(fullPath).catch(() => undefined);
|
|
229
|
+
if (!fileStat)
|
|
230
|
+
continue;
|
|
231
|
+
const isDirectoryMatch = fileStat.isDirectory() || fileStat.isSymbolicLink()
|
|
232
|
+
? file === modName || file.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+$`))
|
|
233
|
+
: false;
|
|
234
|
+
if (isDirectoryMatch) {
|
|
235
|
+
const targetPath = fileStat.isSymbolicLink() ? await fsp.realpath(fullPath) : fullPath;
|
|
236
|
+
return { type: "directory", path: targetPath };
|
|
237
|
+
}
|
|
238
|
+
const isFileMatch = fileStat.isFile()
|
|
239
|
+
? file === modName + ".zip" || file.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+\\.zip$`))
|
|
240
|
+
: false;
|
|
241
|
+
if (isFileMatch) {
|
|
242
|
+
return { type: "file", path: fullPath };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
throw new CliError(`Could not find mod ${modName} in ${modsDir} for watching`);
|
|
246
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseRequiredDependencies } from "./mod-setup.js";
|
|
3
|
+
describe("parseRequiredDependencies", () => {
|
|
4
|
+
it.each([
|
|
5
|
+
[[], []],
|
|
6
|
+
[["some-mod"], ["some-mod"]],
|
|
7
|
+
[["some-mod >= 1.0.0"], ["some-mod"]],
|
|
8
|
+
[["~ some-mod >= 1.0.0"], ["some-mod"]],
|
|
9
|
+
[["? optional-mod >= 1.0.0"], []],
|
|
10
|
+
[["! incompatible-mod"], []],
|
|
11
|
+
[["(?) hidden-optional >= 1.0.0"], []],
|
|
12
|
+
[["base >= 1.1.0"], []],
|
|
13
|
+
[[" mod-name >= 1.0.0 "], ["mod-name"]],
|
|
14
|
+
[["~ soft-mod"], ["soft-mod"]],
|
|
15
|
+
[
|
|
16
|
+
[
|
|
17
|
+
"base >= 1.1.0",
|
|
18
|
+
"required-mod >= 2.0.0",
|
|
19
|
+
"? optional-mod",
|
|
20
|
+
"! incompatible-mod",
|
|
21
|
+
"(?) hidden-optional",
|
|
22
|
+
"~ soft-required >= 1.0.0",
|
|
23
|
+
"another-required",
|
|
24
|
+
],
|
|
25
|
+
["required-mod", "soft-required", "another-required"],
|
|
26
|
+
],
|
|
27
|
+
])("parseRequiredDependencies(%j) => %j", (input, expected) => {
|
|
28
|
+
expect(parseRequiredDependencies(input)).toEqual(expected);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
function formatDuration(ms) {
|
|
3
|
+
if (ms >= 1000) {
|
|
4
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
5
|
+
}
|
|
6
|
+
return `${ms.toFixed(1)}ms`;
|
|
7
|
+
}
|
|
8
|
+
export class OutputFormatter {
|
|
9
|
+
options;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
}
|
|
13
|
+
formatTestResult(test) {
|
|
14
|
+
if (this.options.quiet)
|
|
15
|
+
return;
|
|
16
|
+
const showLogs = test.result === "failed" || this.options.showPassedLogs;
|
|
17
|
+
if (showLogs && test.logs.length > 0) {
|
|
18
|
+
for (const log of test.logs) {
|
|
19
|
+
console.log(" " + log);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const prefix = this.getPrefix(test.result);
|
|
23
|
+
const duration = test.durationMs !== undefined ? ` (${formatDuration(test.durationMs)})` : "";
|
|
24
|
+
console.log(`${prefix} ${test.path}${duration}`);
|
|
25
|
+
if (test.result === "failed") {
|
|
26
|
+
for (const error of test.errors) {
|
|
27
|
+
console.log(" " + error);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
formatSummary(data) {
|
|
32
|
+
if (!data.summary)
|
|
33
|
+
return;
|
|
34
|
+
const { status } = data.summary;
|
|
35
|
+
const color = status === "passed" ? chalk.greenBright : status === "todo" ? chalk.yellowBright : chalk.redBright;
|
|
36
|
+
console.log("Test run result:", color(status));
|
|
37
|
+
}
|
|
38
|
+
getPrefix(result) {
|
|
39
|
+
switch (result) {
|
|
40
|
+
case "passed":
|
|
41
|
+
return chalk.green("PASS");
|
|
42
|
+
case "failed":
|
|
43
|
+
return chalk.red("FAIL");
|
|
44
|
+
case "skipped":
|
|
45
|
+
return chalk.yellow("SKIP");
|
|
46
|
+
case "todo":
|
|
47
|
+
return chalk.magenta("TODO");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export class OutputPrinter {
|
|
52
|
+
options;
|
|
53
|
+
formatter;
|
|
54
|
+
isMessageFirstLine = true;
|
|
55
|
+
constructor(options) {
|
|
56
|
+
this.options = options;
|
|
57
|
+
this.formatter = new OutputFormatter({
|
|
58
|
+
verbose: options.verbose,
|
|
59
|
+
quiet: options.quiet,
|
|
60
|
+
showPassedLogs: options.verbose,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
printTestResult(test) {
|
|
64
|
+
if (this.options.quiet || this.options.useProgressBar)
|
|
65
|
+
return;
|
|
66
|
+
this.formatter.formatTestResult(test);
|
|
67
|
+
}
|
|
68
|
+
printFailures(tests) {
|
|
69
|
+
for (const test of tests) {
|
|
70
|
+
if (test.result === "failed") {
|
|
71
|
+
this.formatter.formatTestResult(test);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
printMessage(line) {
|
|
76
|
+
if (this.options.quiet)
|
|
77
|
+
return;
|
|
78
|
+
if (this.isMessageFirstLine) {
|
|
79
|
+
console.log(line.slice(line.indexOf(": ") + 2));
|
|
80
|
+
this.isMessageFirstLine = false;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log(" " + line);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
resetMessage() {
|
|
87
|
+
this.isMessageFirstLine = true;
|
|
88
|
+
}
|
|
89
|
+
printVerbose(line) {
|
|
90
|
+
if (this.options.verbose) {
|
|
91
|
+
console.log(line);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|