factorio-test-cli 2.0.0 → 3.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/run.js CHANGED
@@ -1,293 +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 { spawn, spawnSync } from "child_process";
7
- import BufferLineSplitter from "./buffer-line-splitter.js";
4
+ import * as dgram from "dgram";
8
5
  import chalk from "chalk";
9
- import * as process from "node:process";
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";
10
14
  const thisCommand = program
11
15
  .command("run")
12
16
  .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 (folder containing info.json). A symlink will be created in the mods folder to this folder. Either this or --mod-name must be specified.")
15
- .option("--mod-name <name>", "The name of the mod to test. To use this option, the mod must already be present in the mods directory (see --data-directory). Either this or [mod-path] must be specified.")
16
- .option("--factorio-path <path>", "The path to the factorio binary. If not specified, attempts to auto-detect the path.")
17
- .option("-d --data-directory <path>", 'The path to the factorio data directory that the testing instance will use. The "config.ini" file and the "mods" folder will be in this directory.', "./factorio-test-data-dir")
18
- .option("--mods <mods...>", 'Adjust mods. By default, only the mod to test and "factorio-test" are enabled, and all others are disabled! ' +
19
- 'Same format as "fmtk mods adjust". Example: "--mods mod1 mod2=1.2.3" will enable mod1 any version, and mod2 version 1.2.3.')
20
- .option("--show-output", "Print test output to stdout.", true)
21
- .option("-v --verbose", "Enables more logging, and pipes the Factorio process output to stdout.")
22
- .addHelpText("after", 'Arguments after "--" are passed to the Factorio process.')
23
- .addHelpText("after", 'Suggested factorio arguments: "--cache-sprite-atlas", "--disable-audio"')
24
- .action((modPath, options) => runTests(modPath, options));
25
- async function runTests(modPath, options) {
26
- if (modPath !== undefined && options.modName !== undefined) {
27
- throw new Error("Only one of --mod-path or --mod-name can be specified.");
28
- }
29
- if (modPath === undefined && options.modName === undefined) {
30
- throw new Error("One of --mod-path or --mod-name must be specified.");
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.");
31
47
  }
32
48
  const factorioPath = options.factorioPath ?? autoDetectFactorioPath();
33
49
  const dataDir = path.resolve(options.dataDirectory);
34
50
  const modsDir = path.join(dataDir, "mods");
35
51
  await fsp.mkdir(modsDir, { recursive: true });
36
- 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
+ : [];
37
56
  await installFactorioTest(modsDir);
38
57
  const enableModsOptions = [
39
58
  "factorio-test=true",
40
59
  `${modToTest}=true`,
60
+ ...modDependencies.map((m) => `${m}=true`),
41
61
  ...(options.mods?.map((m) => (m.includes("=") ? m : `${m}=true`)) ?? []),
42
62
  ];
43
63
  if (options.verbose)
44
64
  console.log("Adjusting mods");
45
- await runScript("fmtk mods adjust", "--modsPath", modsDir, "--disableExtra", ...enableModsOptions);
65
+ await runScript("fmtk", "mods", "adjust", "--modsPath", modsDir, "--disableExtra", ...enableModsOptions);
46
66
  await ensureConfigIni(dataDir);
47
- await setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest);
48
- let resultMessage;
49
- try {
50
- resultMessage = await runFactorioTests(factorioPath, dataDir);
51
- }
52
- finally {
53
- if (options.verbose)
54
- console.log("Disabling auto-start settings");
55
- await runScript("fmtk settings set startup factorio-test-auto-start false", "--modsPath", modsDir);
56
- }
57
- if (resultMessage) {
58
- const color = resultMessage == "passed" ? chalk.greenBright : resultMessage == "todo" ? chalk.yellowBright : chalk.redBright;
59
- console.log("Test run result:", color(resultMessage));
60
- process.exit(resultMessage === "passed" ? 0 : 1);
61
- }
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
+ };
62
92
  }
63
- async function configureModToTest(modsDir, modPath, modName) {
64
- if (modPath) {
65
- if (thisCommand.opts().verbose)
66
- console.log("Creating mod symlink", modPath);
67
- return configureModPath(modPath, modsDir);
68
- }
69
- else {
70
- await configureModName(modsDir, modName);
71
- return modName;
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,
101
+ });
102
+ if (Object.keys(testConfig).length > 0) {
103
+ await runScript("fmtk", "settings", "set", "runtime-global", "factorio-test-config", JSON.stringify(testConfig), "--modsPath", modsDir);
72
104
  }
73
- }
74
- async function configureModPath(modPath, modsDir) {
75
- modPath = path.resolve(modPath);
76
- const infoJsonFile = path.join(modPath, "info.json");
77
- let infoJson;
105
+ let result;
78
106
  try {
79
- infoJson = JSON.parse(await fsp.readFile(infoJsonFile, "utf8"));
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
+ });
80
119
  }
81
- catch (e) {
82
- throw new Error(`Could not read info.json file from ${modPath}`, { cause: e });
83
- }
84
- const modName = infoJson.name;
85
- if (typeof modName !== "string") {
86
- throw new Error(`info.json file at ${infoJsonFile} does not contain a string property "name".`);
87
- }
88
- // make symlink modsDir/modName -> modPath
89
- // delete if exists
90
- const resultPath = path.join(modsDir, modName);
91
- const stat = await fsp.stat(resultPath).catch(() => undefined);
92
- if (stat)
93
- await fsp.rm(resultPath, { recursive: true });
94
- await fsp.symlink(modPath, resultPath, "junction");
95
- return modName;
96
- }
97
- async function configureModName(modsDir, modName) {
98
- // check if modName is in modsDir
99
- const alreadyExists = await checkModExists(modsDir, modName);
100
- if (!alreadyExists) {
101
- throw new Error(`Mod ${modName} not found in ${modsDir}.`);
120
+ finally {
121
+ if (!skipResetAutorun) {
122
+ await resetAutorunSettings(modsDir, options.verbose);
123
+ await runScript("fmtk", "settings", "set", "runtime-global", "factorio-test-config", "{}", "--modsPath", modsDir);
124
+ }
102
125
  }
103
- return modName;
104
- }
105
- async function checkModExists(modsDir, modName) {
106
- const alreadyExists = (await fsp.stat(modsDir).catch(() => undefined))?.isDirectory() &&
107
- (await fsp.readdir(modsDir))?.find((f) => {
108
- const stat = fs.statSync(path.join(modsDir, f), { throwIfNoEntry: false });
109
- if (stat?.isDirectory()) {
110
- return f === modName || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+$`));
111
- }
112
- if (stat?.isFile()) {
113
- return f === modName + ".zip" || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+\\.zip$`));
114
- }
115
- });
116
- return !!alreadyExists;
117
- }
118
- async function installFactorioTest(modsDir) {
119
- await fsp.mkdir(modsDir, { recursive: true });
120
- const testModName = "factorio-test";
121
- // if not found, install it
122
- const alreadyExists = await checkModExists(modsDir, testModName);
123
- if (!alreadyExists) {
124
- console.log(`Downloading ${testModName} from mod portal using fmtk. This may ask for factorio login credentials.`);
125
- await runScript("fmtk mods install", "--modsPath", modsDir, testModName);
126
+ if (result.status === "cancelled") {
127
+ return { exitCode: 0, status: "cancelled" };
126
128
  }
127
- }
128
- async function ensureConfigIni(dataDir) {
129
- const filePath = path.join(dataDir, "config.ini");
130
- if (!fs.existsSync(filePath)) {
131
- console.log("Creating config.ini file");
132
- await fsp.writeFile(filePath, `
133
- ; This file was auto-generated by factorio-test cli
134
-
135
- [path]
136
- read-data=__PATH__executable__/../../data
137
- write-data=${dataDir}
138
-
139
- [general]
140
- locale=
141
- `);
129
+ if (outputPath && result.data) {
130
+ await writeResultsFile(outputPath, modToTest, result.data);
131
+ if (options.verbose)
132
+ console.log(`Results written to ${outputPath}`);
142
133
  }
143
- else {
144
- // edit "^write-data=.*" to be dataDir
145
- const content = await fsp.readFile(filePath, "utf8");
146
- const newContent = content.replace(/^write-data=.*$/m, `write-data=${dataDir}`);
147
- if (content !== newContent) {
148
- await fsp.writeFile(filePath, newContent);
149
- }
134
+ let resultStatus = result.status;
135
+ if (resultStatus === "bailed") {
136
+ console.log(chalk.yellow(`Bailed out after ${testConfig.bail} failure(s)`));
137
+ resultStatus = "failed";
150
138
  }
151
- }
152
- async function setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest) {
153
- // touch modsDir/mod-settings.dat
154
- const settingsDat = path.join(modsDir, "mod-settings.dat");
155
- if (!fs.existsSync(settingsDat)) {
156
- if (thisCommand.opts().verbose)
157
- console.log("Creating mod-settings.dat file by running factorio");
158
- // run factorio once to create it
159
- const dummySaveFile = path.join(dataDir, "____dummy_save_file.zip");
160
- await runProcess(false, `"${factorioPath}"`, "--create", dummySaveFile, "--mod-directory", modsDir, "-c", path.join(dataDir, "config.ini"));
161
- if (fs.existsSync(dummySaveFile)) {
162
- await fsp.rm(dummySaveFile);
163
- }
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 };
164
145
  }
165
- if (thisCommand.opts().verbose)
166
- console.log("Setting autorun settings");
167
- await runScript("fmtk settings set startup factorio-test-auto-start true", "--modsPath", modsDir);
168
- await runScript("fmtk settings set runtime-global factorio-test-mod-to-test", modToTest, "--modsPath", modsDir);
146
+ return { exitCode: resultStatus === "passed" ? 0 : 1, status: resultStatus };
169
147
  }
170
- async function runFactorioTests(factorioPath, dataDir) {
171
- const args = process.argv;
172
- const index = args.indexOf("--");
173
- const additionalArgs = index >= 0 ? args.slice(index + 1) : [];
174
- const actualArgs = [
175
- "--load-scenario",
176
- "factorio-test/Test",
177
- "--disable-migration-window",
178
- "--mod-directory",
179
- path.join(dataDir, "mods"),
180
- "-c",
181
- path.join(dataDir, "config.ini"),
182
- "--graphics-quality",
183
- "low",
184
- ...additionalArgs,
185
- ];
186
- console.log("Running tests...");
187
- const factorioProcess = spawn(factorioPath, actualArgs, {
188
- stdio: ["inherit", "pipe", "inherit"],
189
- });
190
- const verbose = thisCommand.opts().verbose;
191
- const showOutput = thisCommand.opts().showOutput;
192
- let resultMessage = undefined;
193
- let isMessage = false;
194
- let isMessageFirstLine = true;
195
- new BufferLineSplitter(factorioProcess.stdout).on("line", (line) => {
196
- if (line.startsWith("FACTORIO-TEST-RESULT:")) {
197
- resultMessage = line.slice("FACTORIO-TEST-RESULT:".length);
198
- factorioProcess.kill();
199
- }
200
- else if (line === "FACTORIO-TEST-MESSAGE-START") {
201
- isMessage = true;
202
- isMessageFirstLine = true;
203
- }
204
- else if (line === "FACTORIO-TEST-MESSAGE-END") {
205
- isMessage = false;
206
- }
207
- else if (verbose) {
208
- console.log(line);
209
- }
210
- else if (isMessage && showOutput) {
211
- if (isMessageFirstLine) {
212
- console.log(line.slice(line.indexOf(": ") + 2));
213
- isMessageFirstLine = false;
214
- }
215
- else {
216
- // print line with tab
217
- console.log(" " + line);
218
- }
219
- }
220
- });
221
- await new Promise((resolve, reject) => {
222
- factorioProcess.on("exit", (code, signal) => {
223
- if (code === 0 && resultMessage !== undefined) {
224
- resolve();
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();
225
179
  }
226
- else {
227
- reject(new Error(`Factorio exited with code ${code}, signal ${signal}`));
180
+ abortController = new AbortController();
181
+ isRunning = true;
182
+ console.log("\n" + "─".repeat(60));
183
+ try {
184
+ await executeTestRun(ctx, { signal: abortController.signal });
228
185
  }
229
- });
230
- });
231
- return resultMessage;
232
- }
233
- function runScript(...command) {
234
- return runProcess(true, "npx", ...command);
235
- }
236
- function runProcess(inheritStdio, command, ...args) {
237
- if (thisCommand.opts().verbose)
238
- console.log("Running:", command, ...args);
239
- // run another npx command
240
- const process = spawn(command, args, {
241
- stdio: inheritStdio ? "inherit" : "ignore",
242
- shell: true,
243
- });
244
- return new Promise((resolve, reject) => {
245
- process.on("error", reject);
246
- process.on("exit", (code) => {
247
- if (code === 0) {
248
- resolve();
186
+ catch (e) {
187
+ if (e instanceof CliError) {
188
+ console.error(chalk.red(e.message));
189
+ }
190
+ else {
191
+ throw e;
192
+ }
249
193
  }
250
- else {
251
- reject(new Error(`Command exited with code ${code}: ${command} ${args.join(" ")}`));
194
+ finally {
195
+ isRunning = false;
252
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);
253
209
  });
254
- });
255
- }
256
- function factorioIsInPath() {
257
- const result = spawnSync("factorio", ["--version"], { stdio: "ignore" });
258
- return result.status === 0;
259
- }
260
- function autoDetectFactorioPath() {
261
- if (factorioIsInPath()) {
262
- return "factorio";
263
- }
264
- let pathsToTry;
265
- // check if is linux
266
- if (os.platform() === "linux" || os.platform() === "darwin") {
267
- pathsToTry = [
268
- "~/.local/share/Steam/steamapps/common/Factorio/bin/x64/factorio",
269
- "~/Library/Application Support/Steam/steamapps/common/Factorio/factorio.app/Contents/MacOS/factorio",
270
- "~/.factorio/bin/x64/factorio",
271
- "/Applications/factorio.app/Contents/MacOS/factorio",
272
- "/usr/share/factorio/bin/x64/factorio",
273
- "/usr/share/games/factorio/bin/x64/factorio",
274
- ];
275
- }
276
- else if (os.platform() === "win32") {
277
- pathsToTry = [
278
- "factorio.exe",
279
- process.env["ProgramFiles(x86)"] + "\\Steam\\steamapps\\common\\Factorio\\bin\\x64\\factorio.exe",
280
- process.env["ProgramFiles"] + "\\Factorio\\bin\\x64\\factorio.exe",
281
- ];
210
+ await new Promise(() => { });
282
211
  }
283
212
  else {
284
- throw new Error(`Can not auto-detect factorio path on platform ${os.platform()}`);
285
- }
286
- pathsToTry = pathsToTry.map((p) => p.replace(/^~\//, os.homedir() + "/"));
287
- for (const testPath of pathsToTry) {
288
- if (fs.statSync(testPath, { throwIfNoEntry: false })?.isFile()) {
289
- return path.resolve(testPath);
290
- }
213
+ const result = await executeTestRun(ctx);
214
+ process.exit(result.exitCode);
291
215
  }
292
- 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`);
293
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
+ });
@@ -0,0 +1,92 @@
1
+ import { EventEmitter } from "events";
2
+ export class TestRunCollector extends EventEmitter {
3
+ data = { tests: [] };
4
+ currentTest;
5
+ currentLogs = [];
6
+ testStartTime;
7
+ handleEvent(event) {
8
+ switch (event.type) {
9
+ case "testStarted":
10
+ this.flushCurrentTest();
11
+ this.testStartTime = performance.now();
12
+ this.currentTest = {
13
+ path: event.test.path,
14
+ source: event.test.source,
15
+ result: "passed",
16
+ errors: [],
17
+ logs: [],
18
+ };
19
+ this.currentLogs = [];
20
+ break;
21
+ case "testPassed":
22
+ if (this.currentTest) {
23
+ this.currentTest.result = "passed";
24
+ if (this.testStartTime !== undefined) {
25
+ this.currentTest.durationMs = performance.now() - this.testStartTime;
26
+ }
27
+ this.currentTest.logs = [...this.currentLogs];
28
+ }
29
+ this.flushCurrentTest();
30
+ break;
31
+ case "testFailed":
32
+ if (this.currentTest) {
33
+ this.currentTest.result = "failed";
34
+ this.currentTest.errors = event.errors;
35
+ if (this.testStartTime !== undefined) {
36
+ this.currentTest.durationMs = performance.now() - this.testStartTime;
37
+ }
38
+ this.currentTest.logs = [...this.currentLogs];
39
+ }
40
+ this.flushCurrentTest();
41
+ break;
42
+ case "testSkipped":
43
+ this.flushCurrentTest();
44
+ this.finishTest({
45
+ path: event.test.path,
46
+ source: event.test.source,
47
+ result: "skipped",
48
+ errors: [],
49
+ logs: [],
50
+ });
51
+ break;
52
+ case "testTodo":
53
+ this.flushCurrentTest();
54
+ this.finishTest({
55
+ path: event.test.path,
56
+ source: event.test.source,
57
+ result: "todo",
58
+ errors: [],
59
+ logs: [],
60
+ });
61
+ break;
62
+ case "testRunFinished":
63
+ this.flushCurrentTest();
64
+ this.data.summary = event.results;
65
+ this.emit("runFinished", this.data);
66
+ break;
67
+ case "testRunCancelled":
68
+ this.flushCurrentTest();
69
+ break;
70
+ }
71
+ }
72
+ captureLog(line) {
73
+ if (this.currentTest) {
74
+ this.currentLogs.push(line);
75
+ }
76
+ }
77
+ getData() {
78
+ return this.data;
79
+ }
80
+ flushCurrentTest() {
81
+ if (this.currentTest) {
82
+ this.finishTest(this.currentTest);
83
+ this.currentTest = undefined;
84
+ this.currentLogs = [];
85
+ this.testStartTime = undefined;
86
+ }
87
+ }
88
+ finishTest(test) {
89
+ this.data.tests.push(test);
90
+ this.emit("testFinished", test);
91
+ }
92
+ }