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
package/run.js
CHANGED
|
@@ -1,405 +1,216 @@
|
|
|
1
1
|
import { program } from "commander";
|
|
2
|
-
import * as os from "os";
|
|
3
2
|
import * as fsp from "fs/promises";
|
|
4
|
-
import * as fs from "fs";
|
|
5
3
|
import * as path from "path";
|
|
6
|
-
import
|
|
7
|
-
import BufferLineSplitter from "./buffer-line-splitter.js";
|
|
4
|
+
import * as dgram from "dgram";
|
|
8
5
|
import chalk from "chalk";
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
|
|
6
|
+
import { loadConfig, mergeCliConfig, buildTestConfig, registerAllCliOptions, } from "./config/index.js";
|
|
7
|
+
import { setVerbose, runScript } from "./process-utils.js";
|
|
8
|
+
import { autoDetectFactorioPath } from "./factorio-discovery.js";
|
|
9
|
+
import { CliError } from "./cli-error.js";
|
|
10
|
+
import { configureModToTest, installFactorioTest, installModDependencies, ensureConfigIni, setSettingsForAutorun, resetAutorunSettings, resolveModWatchTarget, } from "./mod-setup.js";
|
|
11
|
+
import { writeResultsFile, readPreviousFailedTests, getDefaultOutputPath } from "./results-writer.js";
|
|
12
|
+
import { getHeadlessSavePath, runFactorioTestsHeadless, runFactorioTestsGraphics, } from "./factorio-process.js";
|
|
13
|
+
import { watchDirectory, watchFile } from "./file-watcher.js";
|
|
13
14
|
const thisCommand = program
|
|
14
15
|
.command("run")
|
|
15
16
|
.summary("Runs tests with Factorio test.")
|
|
16
|
-
.description(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
17
|
+
.description(`Runs tests for the specified mod with Factorio test. Exits with code 0 only if all tests pass.
|
|
18
|
+
|
|
19
|
+
One of --mod-path or --mod-name is required.
|
|
20
|
+
Test execution options (--test-pattern, --tag-*, --bail, etc.) override in-mod config.
|
|
21
|
+
|
|
22
|
+
When using variadic options (--mods, --factorio-args, etc.) with filter patterns,
|
|
23
|
+
use -- to separate them:
|
|
24
|
+
factorio-test run -p ./my-mod --mods quality space-age -- "inventory"
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
factorio-test run -p ./my-mod Run all tests
|
|
28
|
+
factorio-test run -p ./my-mod -v Run with verbose output
|
|
29
|
+
factorio-test run -p ./my-mod -gw Run with graphics in watch mode
|
|
30
|
+
factorio-test run -p ./my-mod -b Bail on first failure
|
|
31
|
+
factorio-test run -p ./my-mod "inventory" Run tests matching "inventory"
|
|
32
|
+
`)
|
|
33
|
+
.argument("[filter...]", "Test patterns to filter (OR logic)");
|
|
34
|
+
registerAllCliOptions(thisCommand);
|
|
35
|
+
thisCommand.action((patterns, options) => {
|
|
36
|
+
runTests(patterns, options);
|
|
37
|
+
});
|
|
38
|
+
async function setupTestRun(patterns, options) {
|
|
39
|
+
const fileConfig = loadConfig(options.config);
|
|
40
|
+
mergeCliConfig(fileConfig, options);
|
|
41
|
+
setVerbose(!!options.verbose);
|
|
42
|
+
if (options.modPath !== undefined && options.modName !== undefined) {
|
|
43
|
+
throw new CliError("Only one of --mod-path or --mod-name can be specified.");
|
|
44
|
+
}
|
|
45
|
+
if (options.modPath === undefined && options.modName === undefined) {
|
|
46
|
+
throw new CliError("One of --mod-path or --mod-name must be specified.");
|
|
34
47
|
}
|
|
35
48
|
const factorioPath = options.factorioPath ?? autoDetectFactorioPath();
|
|
36
49
|
const dataDir = path.resolve(options.dataDirectory);
|
|
37
50
|
const modsDir = path.join(dataDir, "mods");
|
|
38
51
|
await fsp.mkdir(modsDir, { recursive: true });
|
|
39
|
-
const modToTest = await configureModToTest(modsDir, modPath, options.modName);
|
|
52
|
+
const modToTest = await configureModToTest(modsDir, options.modPath, options.modName, options.verbose);
|
|
53
|
+
const modDependencies = options.modPath
|
|
54
|
+
? await installModDependencies(modsDir, path.resolve(options.modPath), options.verbose)
|
|
55
|
+
: [];
|
|
40
56
|
await installFactorioTest(modsDir);
|
|
41
57
|
const enableModsOptions = [
|
|
42
58
|
"factorio-test=true",
|
|
43
59
|
`${modToTest}=true`,
|
|
60
|
+
...modDependencies.map((m) => `${m}=true`),
|
|
44
61
|
...(options.mods?.map((m) => (m.includes("=") ? m : `${m}=true`)) ?? []),
|
|
45
62
|
];
|
|
46
63
|
if (options.verbose)
|
|
47
64
|
console.log("Adjusting mods");
|
|
48
|
-
await runScript("fmtk mods adjust", "--modsPath", modsDir, "--disableExtra", ...enableModsOptions);
|
|
65
|
+
await runScript("fmtk", "mods", "adjust", "--modsPath", modsDir, "--disableExtra", ...enableModsOptions);
|
|
49
66
|
await ensureConfigIni(dataDir);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
catch (e) {
|
|
85
|
-
throw new Error(`Could not read info.json file from ${modPath}`, { cause: e });
|
|
86
|
-
}
|
|
87
|
-
const modName = infoJson.name;
|
|
88
|
-
if (typeof modName !== "string") {
|
|
89
|
-
throw new Error(`info.json file at ${infoJsonFile} does not contain a string property "name".`);
|
|
90
|
-
}
|
|
91
|
-
// make symlink modsDir/modName -> modPath
|
|
92
|
-
// delete if exists
|
|
93
|
-
const resultPath = path.join(modsDir, modName);
|
|
94
|
-
const stat = await fsp.stat(resultPath).catch(() => undefined);
|
|
95
|
-
if (stat)
|
|
96
|
-
await fsp.rm(resultPath, { recursive: true });
|
|
97
|
-
await fsp.symlink(modPath, resultPath, "junction");
|
|
98
|
-
return modName;
|
|
99
|
-
}
|
|
100
|
-
async function configureModName(modsDir, modName) {
|
|
101
|
-
// check if modName is in modsDir
|
|
102
|
-
const alreadyExists = await checkModExists(modsDir, modName);
|
|
103
|
-
if (!alreadyExists) {
|
|
104
|
-
throw new Error(`Mod ${modName} not found in ${modsDir}.`);
|
|
105
|
-
}
|
|
106
|
-
return modName;
|
|
107
|
-
}
|
|
108
|
-
async function checkModExists(modsDir, modName) {
|
|
109
|
-
const alreadyExists = (await fsp.stat(modsDir).catch(() => undefined))?.isDirectory() &&
|
|
110
|
-
(await fsp.readdir(modsDir))?.find((f) => {
|
|
111
|
-
const stat = fs.statSync(path.join(modsDir, f), { throwIfNoEntry: false });
|
|
112
|
-
if (stat?.isDirectory()) {
|
|
113
|
-
return f === modName || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+$`));
|
|
114
|
-
}
|
|
115
|
-
if (stat?.isFile()) {
|
|
116
|
-
return f === modName + ".zip" || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+\\.zip$`));
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
return !!alreadyExists;
|
|
120
|
-
}
|
|
121
|
-
async function installFactorioTest(modsDir) {
|
|
122
|
-
await fsp.mkdir(modsDir, { recursive: true });
|
|
123
|
-
const modName = "factorio-test";
|
|
124
|
-
const version = FACTORIO_TEST_MOD_VERSION;
|
|
125
|
-
const expectedZipName = `${modName}_${version}.zip`;
|
|
126
|
-
const expectedZipPath = path.join(modsDir, expectedZipName);
|
|
127
|
-
if (fs.existsSync(expectedZipPath)) {
|
|
128
|
-
if (thisCommand.opts().verbose)
|
|
129
|
-
console.log(`${modName} version ${version} already installed`);
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
console.log(`Downloading ${modName} version ${version} from mod portal...`);
|
|
133
|
-
await downloadModVersion(modName, version, expectedZipPath);
|
|
134
|
-
}
|
|
135
|
-
async function downloadModVersion(modName, version, destPath) {
|
|
136
|
-
const modInfo = await fetchJson(`https://mods.factorio.com/api/mods/${modName}`);
|
|
137
|
-
const release = modInfo.releases.find((r) => r.version === version);
|
|
138
|
-
if (!release) {
|
|
139
|
-
const availableVersions = modInfo.releases.map((r) => r.version).join(", ");
|
|
140
|
-
throw new Error(`Version ${version} not found for mod ${modName}. Available: ${availableVersions}`);
|
|
141
|
-
}
|
|
142
|
-
const credentials = await getFactorioCredentials();
|
|
143
|
-
const downloadUrl = `https://mods.factorio.com${release.download_url}?username=${encodeURIComponent(credentials.username)}&token=${encodeURIComponent(credentials.token)}`;
|
|
144
|
-
await downloadFile(downloadUrl, destPath);
|
|
145
|
-
}
|
|
146
|
-
async function fetchJson(url) {
|
|
147
|
-
return new Promise((resolve, reject) => {
|
|
148
|
-
https.get(url, (res) => {
|
|
149
|
-
if (res.statusCode !== 200) {
|
|
150
|
-
reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
let data = "";
|
|
154
|
-
res.on("data", (chunk) => (data += chunk));
|
|
155
|
-
res.on("end", () => {
|
|
156
|
-
try {
|
|
157
|
-
resolve(JSON.parse(data));
|
|
158
|
-
}
|
|
159
|
-
catch (e) {
|
|
160
|
-
reject(new Error(`Failed to parse JSON from ${url}`, { cause: e }));
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
res.on("error", reject);
|
|
164
|
-
}).on("error", reject);
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
async function downloadFile(url, destPath) {
|
|
168
|
-
return new Promise((resolve, reject) => {
|
|
169
|
-
https.get(url, (res) => {
|
|
170
|
-
if (res.statusCode === 302 || res.statusCode === 301) {
|
|
171
|
-
const redirectUrl = res.headers.location;
|
|
172
|
-
if (!redirectUrl) {
|
|
173
|
-
reject(new Error("Redirect without location header"));
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
downloadFile(redirectUrl, destPath).then(resolve, reject);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
if (res.statusCode !== 200) {
|
|
180
|
-
reject(new Error(`HTTP ${res.statusCode} downloading mod`));
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
const fileStream = fs.createWriteStream(destPath);
|
|
184
|
-
res.pipe(fileStream);
|
|
185
|
-
fileStream.on("close", () => resolve());
|
|
186
|
-
fileStream.on("error", (err) => {
|
|
187
|
-
fs.unlink(destPath, () => { });
|
|
188
|
-
reject(err);
|
|
189
|
-
});
|
|
190
|
-
}).on("error", reject);
|
|
67
|
+
const mode = options.graphics ? "graphics" : "headless";
|
|
68
|
+
const savePath = getHeadlessSavePath(options.save ?? fileConfig.save);
|
|
69
|
+
const outputPath = options.outputFile === false
|
|
70
|
+
? undefined
|
|
71
|
+
: (options.outputFile ?? fileConfig.outputFile ?? getDefaultOutputPath(dataDir));
|
|
72
|
+
const testConfig = buildTestConfig(fileConfig, options, patterns);
|
|
73
|
+
const udpPort = options.watch && options.graphics ? (options.udpPort ?? fileConfig.udpPort ?? 14434) : undefined;
|
|
74
|
+
const factorioArgs = [...(options.factorioArgs ?? [])];
|
|
75
|
+
if (udpPort !== undefined) {
|
|
76
|
+
factorioArgs.push(`--enable-lua-udp=${udpPort}`);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
factorioPath,
|
|
80
|
+
dataDir,
|
|
81
|
+
modsDir,
|
|
82
|
+
modToTest,
|
|
83
|
+
mode,
|
|
84
|
+
savePath,
|
|
85
|
+
outputPath,
|
|
86
|
+
factorioArgs,
|
|
87
|
+
testConfig,
|
|
88
|
+
options,
|
|
89
|
+
fileConfig,
|
|
90
|
+
udpPort,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async function executeTestRun(ctx, execOptions) {
|
|
94
|
+
const { factorioPath, dataDir, modsDir, modToTest, mode, savePath, outputPath, factorioArgs, testConfig, options } = ctx;
|
|
95
|
+
const { signal, skipResetAutorun, resolveOnResult } = execOptions ?? {};
|
|
96
|
+
const reorderEnabled = options.reorderFailedFirst ?? ctx.fileConfig.test?.reorder_failed_first ?? true;
|
|
97
|
+
const lastFailedTests = reorderEnabled && outputPath ? await readPreviousFailedTests(outputPath) : [];
|
|
98
|
+
await setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest, mode, {
|
|
99
|
+
verbose: options.verbose,
|
|
100
|
+
lastFailedTests,
|
|
191
101
|
});
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const playerDataPath = getPlayerDataPath();
|
|
195
|
-
if (playerDataPath && fs.existsSync(playerDataPath)) {
|
|
196
|
-
try {
|
|
197
|
-
const playerData = JSON.parse(await fsp.readFile(playerDataPath, "utf8"));
|
|
198
|
-
if (playerData["service-username"] && playerData["service-token"]) {
|
|
199
|
-
return {
|
|
200
|
-
username: playerData["service-username"],
|
|
201
|
-
token: playerData["service-token"],
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
// Fall through to prompt
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
console.log("Factorio credentials required for mod portal download.");
|
|
210
|
-
return promptForCredentials();
|
|
211
|
-
}
|
|
212
|
-
function getPlayerDataPath() {
|
|
213
|
-
const platform = os.platform();
|
|
214
|
-
if (platform === "linux") {
|
|
215
|
-
return path.join(os.homedir(), ".factorio", "player-data.json");
|
|
102
|
+
if (Object.keys(testConfig).length > 0) {
|
|
103
|
+
await runScript("fmtk", "settings", "set", "runtime-global", "factorio-test-config", JSON.stringify(testConfig), "--modsPath", modsDir);
|
|
216
104
|
}
|
|
217
|
-
|
|
218
|
-
return path.join(os.homedir(), "Library", "Application Support", "factorio", "player-data.json");
|
|
219
|
-
}
|
|
220
|
-
else if (platform === "win32") {
|
|
221
|
-
return path.join(os.homedir(), "AppData", "Roaming", "Factorio", "player-data.json");
|
|
222
|
-
}
|
|
223
|
-
return undefined;
|
|
224
|
-
}
|
|
225
|
-
async function promptForCredentials() {
|
|
226
|
-
const rl = readline.createInterface({
|
|
227
|
-
input: process.stdin,
|
|
228
|
-
output: process.stdout,
|
|
229
|
-
});
|
|
230
|
-
const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
|
|
105
|
+
let result;
|
|
231
106
|
try {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
107
|
+
result =
|
|
108
|
+
mode === "headless"
|
|
109
|
+
? await runFactorioTestsHeadless(factorioPath, dataDir, savePath, factorioArgs, {
|
|
110
|
+
verbose: options.verbose,
|
|
111
|
+
quiet: options.quiet,
|
|
112
|
+
signal,
|
|
113
|
+
})
|
|
114
|
+
: await runFactorioTestsGraphics(factorioPath, dataDir, savePath, factorioArgs, {
|
|
115
|
+
verbose: options.verbose,
|
|
116
|
+
quiet: options.quiet,
|
|
117
|
+
resolveOnResult,
|
|
118
|
+
});
|
|
235
119
|
}
|
|
236
120
|
finally {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
async function ensureConfigIni(dataDir) {
|
|
241
|
-
const filePath = path.join(dataDir, "config.ini");
|
|
242
|
-
if (!fs.existsSync(filePath)) {
|
|
243
|
-
console.log("Creating config.ini file");
|
|
244
|
-
await fsp.writeFile(filePath, `
|
|
245
|
-
; This file was auto-generated by factorio-test cli
|
|
246
|
-
|
|
247
|
-
[path]
|
|
248
|
-
read-data=__PATH__executable__/../../data
|
|
249
|
-
write-data=${dataDir}
|
|
250
|
-
|
|
251
|
-
[general]
|
|
252
|
-
locale=
|
|
253
|
-
`);
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
// edit "^write-data=.*" to be dataDir
|
|
257
|
-
const content = await fsp.readFile(filePath, "utf8");
|
|
258
|
-
const newContent = content.replace(/^write-data=.*$/m, `write-data=${dataDir}`);
|
|
259
|
-
if (content !== newContent) {
|
|
260
|
-
await fsp.writeFile(filePath, newContent);
|
|
121
|
+
if (!skipResetAutorun) {
|
|
122
|
+
await resetAutorunSettings(modsDir, options.verbose);
|
|
123
|
+
await runScript("fmtk", "settings", "set", "runtime-global", "factorio-test-config", "{}", "--modsPath", modsDir);
|
|
261
124
|
}
|
|
262
125
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// touch modsDir/mod-settings.dat
|
|
266
|
-
const settingsDat = path.join(modsDir, "mod-settings.dat");
|
|
267
|
-
if (!fs.existsSync(settingsDat)) {
|
|
268
|
-
if (thisCommand.opts().verbose)
|
|
269
|
-
console.log("Creating mod-settings.dat file by running factorio");
|
|
270
|
-
// run factorio once to create it
|
|
271
|
-
const dummySaveFile = path.join(dataDir, "____dummy_save_file.zip");
|
|
272
|
-
await runProcess(false, `"${factorioPath}"`, "--create", dummySaveFile, "--mod-directory", modsDir, "-c", path.join(dataDir, "config.ini"));
|
|
273
|
-
if (fs.existsSync(dummySaveFile)) {
|
|
274
|
-
await fsp.rm(dummySaveFile);
|
|
275
|
-
}
|
|
126
|
+
if (result.status === "cancelled") {
|
|
127
|
+
return { exitCode: 0, status: "cancelled" };
|
|
276
128
|
}
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
else {
|
|
328
|
-
// print line with tab
|
|
329
|
-
console.log(" " + line);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
await new Promise((resolve, reject) => {
|
|
334
|
-
factorioProcess.on("exit", (code, signal) => {
|
|
335
|
-
if (code === 0 && resultMessage !== undefined) {
|
|
336
|
-
resolve();
|
|
129
|
+
if (outputPath && result.data) {
|
|
130
|
+
await writeResultsFile(outputPath, modToTest, result.data);
|
|
131
|
+
if (options.verbose)
|
|
132
|
+
console.log(`Results written to ${outputPath}`);
|
|
133
|
+
}
|
|
134
|
+
let resultStatus = result.status;
|
|
135
|
+
if (resultStatus === "bailed") {
|
|
136
|
+
console.log(chalk.yellow(`Bailed out after ${testConfig.bail} failure(s)`));
|
|
137
|
+
resultStatus = "failed";
|
|
138
|
+
}
|
|
139
|
+
const color = resultStatus == "passed" ? chalk.greenBright : resultStatus == "todo" ? chalk.yellowBright : chalk.redBright;
|
|
140
|
+
console.log("Test run result:", color(resultStatus));
|
|
141
|
+
const forbidOnly = options.forbidOnly ?? ctx.fileConfig.forbidOnly ?? true;
|
|
142
|
+
if (result.hasFocusedTests && forbidOnly) {
|
|
143
|
+
console.log(chalk.redBright("Error: .only tests are present but --forbid-only is enabled"));
|
|
144
|
+
return { exitCode: 1, status: resultStatus };
|
|
145
|
+
}
|
|
146
|
+
return { exitCode: resultStatus === "passed" ? 0 : 1, status: resultStatus };
|
|
147
|
+
}
|
|
148
|
+
const DEFAULT_WATCH_PATTERNS = ["info.json", "**/*.lua"];
|
|
149
|
+
async function runTests(patterns, options) {
|
|
150
|
+
const ctx = await setupTestRun(patterns, options);
|
|
151
|
+
if (options.watch && options.graphics) {
|
|
152
|
+
const watchPatterns = options.watchPatterns ?? ctx.fileConfig.watchPatterns ?? DEFAULT_WATCH_PATTERNS;
|
|
153
|
+
const target = await resolveModWatchTarget(ctx.modsDir, options.modPath, options.modName);
|
|
154
|
+
console.log(chalk.gray(`Watching ${target.path} for patterns: ${watchPatterns.join(", ")}`));
|
|
155
|
+
await executeTestRun(ctx, { skipResetAutorun: true, resolveOnResult: true });
|
|
156
|
+
const udpClient = dgram.createSocket("udp4");
|
|
157
|
+
const onFileChange = () => {
|
|
158
|
+
console.log(chalk.cyan("File change detected, triggering rerun..."));
|
|
159
|
+
udpClient.send("rerun", ctx.udpPort, "127.0.0.1");
|
|
160
|
+
};
|
|
161
|
+
const watcher = target.type === "directory"
|
|
162
|
+
? watchDirectory(target.path, onFileChange, { patterns: watchPatterns })
|
|
163
|
+
: watchFile(target.path, onFileChange);
|
|
164
|
+
process.on("SIGINT", () => {
|
|
165
|
+
watcher.close();
|
|
166
|
+
udpClient.close();
|
|
167
|
+
process.exit(0);
|
|
168
|
+
});
|
|
169
|
+
await new Promise(() => { });
|
|
170
|
+
}
|
|
171
|
+
else if (options.watch) {
|
|
172
|
+
const watchPatterns = options.watchPatterns ?? ctx.fileConfig.watchPatterns ?? DEFAULT_WATCH_PATTERNS;
|
|
173
|
+
const target = await resolveModWatchTarget(ctx.modsDir, options.modPath, options.modName);
|
|
174
|
+
let abortController;
|
|
175
|
+
let isRunning = false;
|
|
176
|
+
const runOnce = async () => {
|
|
177
|
+
if (isRunning) {
|
|
178
|
+
abortController?.abort();
|
|
337
179
|
}
|
|
338
|
-
|
|
339
|
-
|
|
180
|
+
abortController = new AbortController();
|
|
181
|
+
isRunning = true;
|
|
182
|
+
console.log("\n" + "─".repeat(60));
|
|
183
|
+
try {
|
|
184
|
+
await executeTestRun(ctx, { signal: abortController.signal });
|
|
340
185
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
function runProcess(inheritStdio, command, ...args) {
|
|
349
|
-
if (thisCommand.opts().verbose)
|
|
350
|
-
console.log("Running:", command, ...args);
|
|
351
|
-
// run another npx command
|
|
352
|
-
const process = spawn(command, args, {
|
|
353
|
-
stdio: inheritStdio ? "inherit" : "ignore",
|
|
354
|
-
shell: true,
|
|
355
|
-
});
|
|
356
|
-
return new Promise((resolve, reject) => {
|
|
357
|
-
process.on("error", reject);
|
|
358
|
-
process.on("exit", (code) => {
|
|
359
|
-
if (code === 0) {
|
|
360
|
-
resolve();
|
|
186
|
+
catch (e) {
|
|
187
|
+
if (e instanceof CliError) {
|
|
188
|
+
console.error(chalk.red(e.message));
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
361
193
|
}
|
|
362
|
-
|
|
363
|
-
|
|
194
|
+
finally {
|
|
195
|
+
isRunning = false;
|
|
364
196
|
}
|
|
197
|
+
};
|
|
198
|
+
await runOnce();
|
|
199
|
+
const onFileChange = () => {
|
|
200
|
+
console.log(chalk.cyan("File change detected, rerunning tests..."));
|
|
201
|
+
runOnce();
|
|
202
|
+
};
|
|
203
|
+
const watcher = target.type === "directory"
|
|
204
|
+
? watchDirectory(target.path, onFileChange, { patterns: watchPatterns })
|
|
205
|
+
: watchFile(target.path, onFileChange);
|
|
206
|
+
process.on("SIGINT", () => {
|
|
207
|
+
watcher.close();
|
|
208
|
+
process.exit(0);
|
|
365
209
|
});
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
function factorioIsInPath() {
|
|
369
|
-
const result = spawnSync("factorio", ["--version"], { stdio: "ignore" });
|
|
370
|
-
return result.status === 0;
|
|
371
|
-
}
|
|
372
|
-
function autoDetectFactorioPath() {
|
|
373
|
-
if (factorioIsInPath()) {
|
|
374
|
-
return "factorio";
|
|
375
|
-
}
|
|
376
|
-
let pathsToTry;
|
|
377
|
-
// check if is linux
|
|
378
|
-
if (os.platform() === "linux" || os.platform() === "darwin") {
|
|
379
|
-
pathsToTry = [
|
|
380
|
-
"~/.local/share/Steam/steamapps/common/Factorio/bin/x64/factorio",
|
|
381
|
-
"~/Library/Application Support/Steam/steamapps/common/Factorio/factorio.app/Contents/MacOS/factorio",
|
|
382
|
-
"~/.factorio/bin/x64/factorio",
|
|
383
|
-
"/Applications/factorio.app/Contents/MacOS/factorio",
|
|
384
|
-
"/usr/share/factorio/bin/x64/factorio",
|
|
385
|
-
"/usr/share/games/factorio/bin/x64/factorio",
|
|
386
|
-
];
|
|
387
|
-
}
|
|
388
|
-
else if (os.platform() === "win32") {
|
|
389
|
-
pathsToTry = [
|
|
390
|
-
"factorio.exe",
|
|
391
|
-
process.env["ProgramFiles(x86)"] + "\\Steam\\steamapps\\common\\Factorio\\bin\\x64\\factorio.exe",
|
|
392
|
-
process.env["ProgramFiles"] + "\\Factorio\\bin\\x64\\factorio.exe",
|
|
393
|
-
];
|
|
210
|
+
await new Promise(() => { });
|
|
394
211
|
}
|
|
395
212
|
else {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
pathsToTry = pathsToTry.map((p) => p.replace(/^~\//, os.homedir() + "/"));
|
|
399
|
-
for (const testPath of pathsToTry) {
|
|
400
|
-
if (fs.statSync(testPath, { throwIfNoEntry: false })?.isFile()) {
|
|
401
|
-
return path.resolve(testPath);
|
|
402
|
-
}
|
|
213
|
+
const result = await executeTestRun(ctx);
|
|
214
|
+
process.exit(result.exitCode);
|
|
403
215
|
}
|
|
404
|
-
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`);
|
|
405
216
|
}
|
package/schema.test.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { testRunnerConfigSchema, cliConfigSchema, parseCliTestOptions } from "./config/index.js";
|
|
3
|
+
describe("testRunnerConfigSchema", () => {
|
|
4
|
+
it("parses valid config with snake_case keys", () => {
|
|
5
|
+
const config = {
|
|
6
|
+
test_pattern: "foo",
|
|
7
|
+
game_speed: 100,
|
|
8
|
+
log_passed_tests: true,
|
|
9
|
+
};
|
|
10
|
+
expect(testRunnerConfigSchema.parse(config)).toEqual(config);
|
|
11
|
+
});
|
|
12
|
+
it("rejects invalid types", () => {
|
|
13
|
+
expect(() => testRunnerConfigSchema.parse({ game_speed: "fast" })).toThrow();
|
|
14
|
+
});
|
|
15
|
+
it("allows empty config", () => {
|
|
16
|
+
expect(testRunnerConfigSchema.parse({})).toEqual({});
|
|
17
|
+
});
|
|
18
|
+
it("rejects unknown keys", () => {
|
|
19
|
+
expect(() => testRunnerConfigSchema.parse({ unknown_key: true })).toThrow();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe("cliConfigSchema", () => {
|
|
23
|
+
it("parses config file with snake_case test keys", () => {
|
|
24
|
+
const config = {
|
|
25
|
+
modPath: "./my-mod",
|
|
26
|
+
test: { game_speed: 50, log_passed_tests: true },
|
|
27
|
+
};
|
|
28
|
+
expect(cliConfigSchema.parse(config)).toEqual(config);
|
|
29
|
+
});
|
|
30
|
+
it("rejects unknown keys in strict mode", () => {
|
|
31
|
+
expect(() => cliConfigSchema.strict().parse({ unknownKey: true })).toThrow();
|
|
32
|
+
});
|
|
33
|
+
it("accepts forbidOnly boolean", () => {
|
|
34
|
+
const config = { forbidOnly: false };
|
|
35
|
+
expect(cliConfigSchema.parse(config)).toEqual(config);
|
|
36
|
+
});
|
|
37
|
+
it("defaults forbidOnly to undefined", () => {
|
|
38
|
+
expect(cliConfigSchema.parse({}).forbidOnly).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("parseCliTestOptions", () => {
|
|
42
|
+
it("converts Commander camelCase output to snake_case", () => {
|
|
43
|
+
const commanderOpts = {
|
|
44
|
+
testPattern: "foo",
|
|
45
|
+
gameSpeed: 100,
|
|
46
|
+
logPassedTests: true,
|
|
47
|
+
};
|
|
48
|
+
expect(parseCliTestOptions(commanderOpts)).toEqual({
|
|
49
|
+
test_pattern: "foo",
|
|
50
|
+
game_speed: 100,
|
|
51
|
+
log_passed_tests: true,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it("omits undefined values", () => {
|
|
55
|
+
expect(parseCliTestOptions({ gameSpeed: 100 })).toEqual({ game_speed: 100 });
|
|
56
|
+
});
|
|
57
|
+
it("returns empty object for empty input", () => {
|
|
58
|
+
expect(parseCliTestOptions({})).toEqual({});
|
|
59
|
+
});
|
|
60
|
+
it("passes through bail option", () => {
|
|
61
|
+
expect(parseCliTestOptions({ bail: 1 })).toEqual({ bail: 1 });
|
|
62
|
+
expect(parseCliTestOptions({ bail: 3 })).toEqual({ bail: 3 });
|
|
63
|
+
});
|
|
64
|
+
it("converts bail=true to bail=1 (commander behavior for --bail without value)", () => {
|
|
65
|
+
expect(parseCliTestOptions({ bail: true })).toEqual({ bail: 1 });
|
|
66
|
+
});
|
|
67
|
+
});
|