factorio-test-cli 1.0.0

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 ADDED
@@ -0,0 +1,11 @@
1
+ # Factorio test CLI
2
+
3
+ A CLI for running tests with Factorio Test from the command line.
4
+
5
+ Run `npx factorio-test --help` for usage information.
6
+
7
+ If using an npm package, you can install `factorio-test-cli` to your dev dependencies:
8
+
9
+ ```bash
10
+ npm install --save-dev factorio-test-cli
11
+ ```
@@ -0,0 +1,31 @@
1
+ import { EventEmitter } from "events";
2
+ export default class BufferLineSplitter extends EventEmitter {
3
+ buf;
4
+ constructor(instream) {
5
+ super();
6
+ this.buf = "";
7
+ instream.on("close", () => {
8
+ if (this.buf.length > 0)
9
+ this.emit("line", this.buf);
10
+ this.emit("close");
11
+ });
12
+ instream.on("end", () => {
13
+ if (this.buf.length > 0)
14
+ this.emit("line", this.buf);
15
+ this.emit("end");
16
+ });
17
+ instream.on("data", (chunk) => {
18
+ this.buf += chunk.toString();
19
+ while (this.buf.length > 0) {
20
+ const index = this.buf.indexOf("\n");
21
+ if (index !== -1) {
22
+ this.emit("line", this.buf.slice(0, index));
23
+ this.buf = this.buf.slice(index + 1);
24
+ }
25
+ }
26
+ });
27
+ }
28
+ on(event, listener) {
29
+ return super.on(event, listener);
30
+ }
31
+ }
package/cli.js ADDED
@@ -0,0 +1,9 @@
1
+ import { program } from "commander";
2
+ import "./run.js";
3
+ program
4
+ .name("factorio-test")
5
+ .description("cli for factorio testing")
6
+ .addHelpCommand()
7
+ .showHelpAfterError()
8
+ .showSuggestionAfterError()
9
+ .parse();
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "factorio-test-cli",
3
+ "version": "1.0.0",
4
+ "description": "A CLI to run FactorioTest.",
5
+ "author": "GlassBricks",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "bin": {
9
+ "factorio-test": "./cli.js"
10
+ },
11
+ "engines": {
12
+ "node": ">=18.0.0"
13
+ },
14
+ "files": [
15
+ "*.js"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "lint": "eslint .",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "dependencies": {
23
+ "chalk": "^5.2.0",
24
+ "commander": "^10.0.1",
25
+ "ini": "^4.1.0",
26
+ "factoriomod-debug": "^1.1.28"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^5.0.4",
30
+ "@commander-js/extra-typings": "^10.0.3"
31
+ }
32
+ }
package/run.js ADDED
@@ -0,0 +1,270 @@
1
+ import { program as theProgram } from "commander";
2
+ import * as os from "os";
3
+ import * as fsp from "fs/promises";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { spawn } from "child_process";
7
+ import BufferLineSplitter from "./buffer-line-splitter.js";
8
+ import chalk from "chalk";
9
+ const program = theProgram;
10
+ const thisCommand = program
11
+ .command("run")
12
+ .summary("Runs tests with Factorio test.")
13
+ .description("Runs tests for the specified mod with Factorio test. Exits with code 0 only if all tests pass.\n")
14
+ .argument("[mod-path]", "The path to the mod to test (containing info.json). A symlink will be created in the factorio mods folder, to this folder. Alternatively, you can specify --mod-name for manual test setup.")
15
+ .option("--mod-name <name>", "The name of the mod to test. If specified, the mod must already be present in the mods directory (sse --data-directory below). One of [mod-path] or --mod-name must be specified.")
16
+ .option("--factorio-path <path>", "The path to the factorio binary. If not specified, tries to auto-detect the factorio installation.")
17
+ .option("-d --data-directory <path>", 'The path to the data directory. The "config.ini" file and the "mods" folder will be in this directory.', "./factorio-test-data")
18
+ .option("--show-output", "Prints test output to stdout.", true)
19
+ .option("-v --verbose", "Enables more logging, and prints all outputs of the Factorio process to stdout (not just test output).")
20
+ .addHelpText("after", 'Options passed after "--" are passed as Factorio launch arguments.')
21
+ .action((modPath, options) => runTests(modPath, options));
22
+ async function runTests(modPath, options) {
23
+ if (modPath !== undefined && options.modName !== undefined) {
24
+ throw new Error("Only one of --mod-path or --mod-name can be specified.");
25
+ }
26
+ if (modPath === undefined && options.modName === undefined) {
27
+ throw new Error("One of --mod-path or --mod-name must be specified.");
28
+ }
29
+ const factorioPath = options.factorioPath ?? autoDetectFactorioPath();
30
+ const dataDir = path.resolve(options.dataDirectory);
31
+ const modsDir = path.join(dataDir, "mods");
32
+ await fsp.mkdir(modsDir, { recursive: true });
33
+ const modToTest = await configureModToTest(modsDir, modPath, options.modName);
34
+ await installFactorioTest(modsDir);
35
+ if (options.verbose)
36
+ console.log("Enabling mods", modToTest, "and factorio-test");
37
+ await runScript("fmtk mods adjust", "--modsPath", modsDir, "factorio-test=true", modToTest + "=true");
38
+ await ensureConfigIni(dataDir);
39
+ await setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest);
40
+ let resultMessage;
41
+ try {
42
+ resultMessage = await runFactorioTests(factorioPath, dataDir);
43
+ }
44
+ finally {
45
+ if (options.verbose)
46
+ console.log("Disabling auto-start settings");
47
+ await runScript("fmtk settings set startup factorio-test-auto-start false", "--modsPath", modsDir);
48
+ }
49
+ if (resultMessage) {
50
+ const color = resultMessage == "passed" ? chalk.greenBright : resultMessage == "todo" ? chalk.yellowBright : chalk.redBright;
51
+ console.log("Test run result:", color(resultMessage));
52
+ process.exit(resultMessage === "passed" ? 0 : 1);
53
+ }
54
+ }
55
+ async function configureModToTest(modsDir, modPath, modName) {
56
+ if (modPath) {
57
+ if (thisCommand.opts().verbose)
58
+ console.log("Creating mod symlink", modPath);
59
+ return configureModPath(modPath, modsDir);
60
+ }
61
+ else {
62
+ await configureModName(modsDir, modName);
63
+ return modName;
64
+ }
65
+ }
66
+ async function configureModPath(modPath, modsDir) {
67
+ modPath = path.resolve(modPath);
68
+ const infoJsonFile = path.join(modPath, "info.json");
69
+ let infoJson;
70
+ try {
71
+ infoJson = JSON.parse(await fsp.readFile(infoJsonFile, "utf8"));
72
+ }
73
+ catch (e) {
74
+ throw new Error(`Could not read info.json file from ${modPath}`, { cause: e });
75
+ }
76
+ const modName = infoJson.name;
77
+ if (typeof modName !== "string") {
78
+ throw new Error(`info.json file at ${infoJsonFile} does not contain a string property "name".`);
79
+ }
80
+ // make symlink modsDir/modName -> modPath
81
+ // delete if exists
82
+ const resultPath = path.join(modsDir, modName);
83
+ const stat = await fsp.stat(resultPath).catch(() => undefined);
84
+ if (stat)
85
+ await fsp.rm(resultPath, { recursive: true });
86
+ await fsp.symlink(modPath, resultPath, "junction");
87
+ return modName;
88
+ }
89
+ async function configureModName(modsDir, modName) {
90
+ // check if modName is in modsDir
91
+ const alreadyExists = await checkModExists(modsDir, modName);
92
+ if (!alreadyExists) {
93
+ throw new Error(`Mod ${modName} not found in ${modsDir}.`);
94
+ }
95
+ return modName;
96
+ }
97
+ async function checkModExists(modsDir, modName) {
98
+ const alreadyExists = (await fsp.stat(modsDir).catch(() => undefined))?.isDirectory() &&
99
+ (await fsp.readdir(modsDir))?.find((f) => {
100
+ const stat = fs.statSync(path.join(modsDir, f), { throwIfNoEntry: false });
101
+ if (stat?.isDirectory()) {
102
+ return f === modName || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+$`));
103
+ }
104
+ if (stat?.isFile()) {
105
+ return f === modName + ".zip" || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+\\.zip$`));
106
+ }
107
+ });
108
+ return !!alreadyExists;
109
+ }
110
+ async function installFactorioTest(modsDir) {
111
+ await fsp.mkdir(modsDir, { recursive: true });
112
+ // const testModName = "testorio"
113
+ const testModName = "factorio-test";
114
+ // if not found, install it
115
+ const alreadyExists = await checkModExists(modsDir, testModName);
116
+ if (!alreadyExists) {
117
+ console.log(`Downloading ${testModName} from mod portal...`);
118
+ await runScript("fmtk mods install", "--modsPath", modsDir, testModName);
119
+ }
120
+ }
121
+ async function ensureConfigIni(dataDir) {
122
+ const filePath = path.join(dataDir, "config.ini");
123
+ if (!fs.existsSync(filePath)) {
124
+ console.log("Creating config.ini file");
125
+ await fsp.writeFile(filePath, `
126
+ ; This file was auto-generated by factorio-test cli
127
+
128
+ [path]
129
+ read-data=__PATH__executable__/../../data
130
+ write-data=${dataDir}
131
+
132
+ [general]
133
+ locale=
134
+ `);
135
+ }
136
+ }
137
+ async function setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest) {
138
+ // touch modsDir/mod-settings.dat
139
+ const settingsDat = path.join(modsDir, "mod-settings.dat");
140
+ if (!fs.existsSync(settingsDat)) {
141
+ if (thisCommand.opts().verbose)
142
+ console.log("Creating mod-settings.dat file by running factorio");
143
+ // run factorio once to create it
144
+ const dummySaveFile = path.join(dataDir, "____dummy_save_file.zip");
145
+ await runProcess(false, factorioPath, "--create", dummySaveFile, "--mod-directory", modsDir, "-c", path.join(dataDir, "config.ini"));
146
+ if (fs.existsSync(dummySaveFile)) {
147
+ await fsp.rm(dummySaveFile);
148
+ }
149
+ }
150
+ if (thisCommand.opts().verbose)
151
+ console.log("Setting autorun settings");
152
+ await runScript("fmtk settings set startup factorio-test-auto-start true", "--modsPath", modsDir);
153
+ await runScript("fmtk settings set runtime-global factorio-test-mod-to-test", modToTest, "--modsPath", modsDir);
154
+ }
155
+ async function runFactorioTests(factorioPath, dataDir) {
156
+ const args = process.argv;
157
+ const index = args.indexOf("--");
158
+ const additionalArgs = index >= 0 ? args.slice(index + 1) : [];
159
+ const actualArgs = [
160
+ "--load-scenario",
161
+ "factorio-test/Test",
162
+ "--mod-directory",
163
+ path.join(dataDir, "mods"),
164
+ "-c",
165
+ path.join(dataDir, "config.ini"),
166
+ "--graphics-quality",
167
+ "low",
168
+ ...additionalArgs,
169
+ ];
170
+ console.log("Running tests...");
171
+ const factorioProcess = spawn(factorioPath, actualArgs, {
172
+ stdio: ["inherit", "pipe", "inherit"],
173
+ });
174
+ const verbose = thisCommand.opts().verbose;
175
+ const showOutput = thisCommand.opts().showOutput;
176
+ let resultMessage = undefined;
177
+ let isMessage = false;
178
+ let isMessageFirstLine = true;
179
+ new BufferLineSplitter(factorioProcess.stdout).on("line", (line) => {
180
+ if (line.startsWith("FACTORIO-TEST-RESULT:")) {
181
+ resultMessage = line.slice("FACTORIO-TEST-RESULT:".length);
182
+ factorioProcess.kill();
183
+ }
184
+ else if (line === "FACTORIO-TEST-MESSAGE-START") {
185
+ isMessage = true;
186
+ isMessageFirstLine = true;
187
+ }
188
+ else if (line === "FACTORIO-TEST-MESSAGE-END") {
189
+ isMessage = false;
190
+ }
191
+ else if (verbose) {
192
+ console.log(line);
193
+ }
194
+ else if (isMessage && showOutput) {
195
+ if (isMessageFirstLine) {
196
+ console.log(line.slice(line.indexOf(": ") + 2));
197
+ isMessageFirstLine = false;
198
+ }
199
+ else {
200
+ // print line with tab
201
+ console.log(" " + line);
202
+ }
203
+ }
204
+ });
205
+ await new Promise((resolve, reject) => {
206
+ factorioProcess.on("exit", (code) => {
207
+ if (code === 0 || resultMessage !== undefined) {
208
+ resolve();
209
+ }
210
+ else {
211
+ reject(new Error(`Factorio exited with code ${code}`));
212
+ }
213
+ });
214
+ });
215
+ return resultMessage;
216
+ }
217
+ function runScript(...command) {
218
+ return runProcess(true, "npx", ...command);
219
+ }
220
+ function runProcess(inheritStdio, command, ...args) {
221
+ if (thisCommand.opts().verbose)
222
+ console.log("Running:", command, ...args);
223
+ // run another npx command
224
+ const process = spawn(command, args, {
225
+ stdio: inheritStdio ? "inherit" : "ignore",
226
+ shell: true,
227
+ });
228
+ return new Promise((resolve, reject) => {
229
+ process.on("exit", (code) => {
230
+ if (code === 0) {
231
+ resolve();
232
+ }
233
+ else {
234
+ reject(new Error(`Command exited with code ${code}`));
235
+ }
236
+ });
237
+ });
238
+ }
239
+ function autoDetectFactorioPath() {
240
+ let pathsToTry;
241
+ // check if is linux
242
+ if (os.platform() === "linux" || os.platform() === "darwin") {
243
+ pathsToTry = [
244
+ "factorio",
245
+ "~/.local/share/Steam/steamapps/common/Factorio/bin/x64/factorio",
246
+ "~/Library/Application Support/Steam/steamapps/common/Factorio/factorio.app/Contents/MacOS/factorio",
247
+ "~/.factorio/bin/x64/factorio",
248
+ "/Applications/factorio.app/Contents/MacOS/factorio",
249
+ "/usr/share/factorio/bin/x64/factorio",
250
+ "/usr/share/games/factorio/bin/x64/factorio",
251
+ ];
252
+ }
253
+ else if (os.platform() === "win32") {
254
+ pathsToTry = [
255
+ "factorio.exe",
256
+ process.env["ProgramFiles(x86)"] + "\\Steam\\steamapps\\common\\Factorio\\bin\\x64\\factorio.exe",
257
+ process.env["ProgramFiles"] + "\\Factorio\\bin\\x64\\factorio.exe",
258
+ ];
259
+ }
260
+ else {
261
+ throw new Error(`Can not auto-detect factorio path on platform ${os.platform()}`);
262
+ }
263
+ pathsToTry = pathsToTry.map((p) => p.replace(/^~\//, os.homedir() + "/"));
264
+ for (const testPath of pathsToTry) {
265
+ if (fs.statSync(testPath, { throwIfNoEntry: false })?.isFile()) {
266
+ return path.resolve(testPath);
267
+ }
268
+ }
269
+ throw new Error(`Could not auto-detect factorio executable. Tried: ${pathsToTry.join(", ")}. Either add the factorio bin to your path, or specify the path with --factorio-path`);
270
+ }