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/README.md
CHANGED
|
@@ -9,3 +9,60 @@ If using an npm package, you can install `factorio-test-cli` to your dev depende
|
|
|
9
9
|
```bash
|
|
10
10
|
npm install --save-dev factorio-test-cli
|
|
11
11
|
```
|
|
12
|
+
|
|
13
|
+
## Configuration Architecture
|
|
14
|
+
|
|
15
|
+
### Config Categories
|
|
16
|
+
|
|
17
|
+
| Category | Casing | Location | Example Fields |
|
|
18
|
+
|----------|--------|----------|----------------|
|
|
19
|
+
| CLI-only | camelCase | `cli-config.ts` | `config`, `graphics`, `watch` |
|
|
20
|
+
| File+CLI | camelCase | `cli-config.ts` | `modPath`, `factorioPath`, `verbose`, `forbidOnly` |
|
|
21
|
+
| Test | snake_case | `types/config.d.ts` | `test_pattern`, `game_speed`, `bail` |
|
|
22
|
+
|
|
23
|
+
- **CLI-only**: Options only available via command line, not in config files
|
|
24
|
+
- **File+CLI**: Options that can be set in `factorio-test.json` or via command line (CLI overrides file)
|
|
25
|
+
- **Test**: Runner configuration passed to the Factorio mod; uses snake_case for Lua compatibility
|
|
26
|
+
|
|
27
|
+
### Type Hierarchy
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
types/config.d.ts
|
|
31
|
+
└── TestRunnerConfig # CLI-passable fields (source of truth)
|
|
32
|
+
│
|
|
33
|
+
├── Extended by: FactorioTest.Config (types/index.d.ts)
|
|
34
|
+
│ └── Adds mod-only fields: default_ticks_between_tests,
|
|
35
|
+
│ before_test_run, after_test_run, sound_effects
|
|
36
|
+
│
|
|
37
|
+
└── Validated by: testRunnerConfigSchema (cli/config/test-config.ts)
|
|
38
|
+
└── Zod schema for runtime validation
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Data Flow
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
CLI args ─────────────────┐
|
|
45
|
+
▼
|
|
46
|
+
factorio-test.json ──► loadConfig() ──► mergeCliConfig() ──► RunOptions
|
|
47
|
+
│
|
|
48
|
+
▼
|
|
49
|
+
buildTestConfig() ──► TestRunnerConfig ──► Factorio mod
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
1. `loadConfig()` reads `factorio-test.json` (or `package.json["factorio-test"]`)
|
|
53
|
+
2. `mergeCliConfig()` merges file config with CLI options (CLI wins)
|
|
54
|
+
3. `buildTestConfig()` extracts test runner options, combining patterns with OR logic
|
|
55
|
+
|
|
56
|
+
### File Organization
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
types/
|
|
60
|
+
├── config.d.ts # TestRunnerConfig interface (source of truth for CLI-passable test options)
|
|
61
|
+
└── index.d.ts # FactorioTest.Config extends TestRunnerConfig with mod-only fields
|
|
62
|
+
|
|
63
|
+
cli/config/
|
|
64
|
+
├── index.ts # Re-exports all public APIs
|
|
65
|
+
├── test-config.ts # Zod schema validating TestRunnerConfig + CLI registration
|
|
66
|
+
├── cli-config.ts # CliConfig + CliOnlyOptions schemas + CLI registration
|
|
67
|
+
└── loader.ts # Config loading, path resolution, merging, RunOptions type
|
|
68
|
+
```
|
package/cli-error.js
ADDED
package/cli.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { program } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { CliError } from "./cli-error.js";
|
|
3
5
|
import "./run.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
try {
|
|
7
|
+
await program
|
|
8
|
+
.name("factorio-test")
|
|
9
|
+
.description("cli for factorio testing")
|
|
10
|
+
.helpCommand(true)
|
|
11
|
+
.showHelpAfterError()
|
|
12
|
+
.showSuggestionAfterError()
|
|
13
|
+
.parseAsync();
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error instanceof CliError) {
|
|
17
|
+
console.error(chalk.red("Error:"), error.message);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { testRunnerConfigSchema, registerTestConfigOptions } from "./test-config.js";
|
|
3
|
+
export const DEFAULT_DATA_DIRECTORY = "./factorio-test-data-dir";
|
|
4
|
+
const cliConfigFields = {
|
|
5
|
+
modPath: {
|
|
6
|
+
schema: z.string().optional(),
|
|
7
|
+
cli: {
|
|
8
|
+
flags: "-p --mod-path <path>",
|
|
9
|
+
description: "[one required] Path to the mod folder (containing info.json). Will create a symlink from mods folder to here.",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
modName: {
|
|
13
|
+
schema: z.string().optional(),
|
|
14
|
+
cli: {
|
|
15
|
+
flags: "--mod-name <name>",
|
|
16
|
+
description: "[one required] Name of a mod already in the configured data directory.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
factorioPath: {
|
|
20
|
+
schema: z.string().optional(),
|
|
21
|
+
cli: {
|
|
22
|
+
flags: "--factorio-path <path>",
|
|
23
|
+
description: "Path to the Factorio binary. If not specified, will attempt to be auto-detected.",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
dataDirectory: {
|
|
27
|
+
schema: z.string().optional(),
|
|
28
|
+
cli: {
|
|
29
|
+
flags: "-d --data-directory <path>",
|
|
30
|
+
description: "Factorio data directory, where mods, saves, config etc. will be.",
|
|
31
|
+
default: DEFAULT_DATA_DIRECTORY,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
save: {
|
|
35
|
+
schema: z.string().optional(),
|
|
36
|
+
cli: {
|
|
37
|
+
flags: "--save <path>",
|
|
38
|
+
description: "Path to save file. Default: uses a bundled save with empty lab-tile world.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
mods: {
|
|
42
|
+
schema: z.array(z.string()).optional(),
|
|
43
|
+
cli: {
|
|
44
|
+
flags: "--mods <mods...>",
|
|
45
|
+
description: "Additional mods to enable besides the mod under test (e.g., --mods mod1 mod2=1.2.3).",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
factorioArgs: {
|
|
49
|
+
schema: z.array(z.string()).optional(),
|
|
50
|
+
cli: {
|
|
51
|
+
flags: "--factorio-args <args...>",
|
|
52
|
+
description: "Additional arguments to pass to Factorio process.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
verbose: {
|
|
56
|
+
schema: z.boolean().optional(),
|
|
57
|
+
cli: {
|
|
58
|
+
flags: "-v --verbose",
|
|
59
|
+
description: "Enable verbose logging; pipe Factorio output to stdout.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
quiet: {
|
|
63
|
+
schema: z.boolean().optional(),
|
|
64
|
+
cli: {
|
|
65
|
+
flags: "-q --quiet",
|
|
66
|
+
description: "Suppress per-test output, show only final result.",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
outputFile: {
|
|
70
|
+
schema: z.string().optional(),
|
|
71
|
+
cli: {
|
|
72
|
+
flags: "--output-file <path>",
|
|
73
|
+
description: "Path for test results JSON file. Used to reorder failed tests first on subsequent runs.",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
forbidOnly: {
|
|
77
|
+
schema: z.boolean().optional(),
|
|
78
|
+
cli: {
|
|
79
|
+
flags: "--forbid-only",
|
|
80
|
+
description: "Fail if .only tests are present (default: enabled). Useful for CI.",
|
|
81
|
+
negatable: true,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
watchPatterns: {
|
|
85
|
+
schema: z.array(z.string()).optional(),
|
|
86
|
+
cli: {
|
|
87
|
+
flags: "--watch-patterns <patterns...>",
|
|
88
|
+
description: "Glob patterns to watch (default: info.json, **/*.lua).",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
udpPort: {
|
|
92
|
+
schema: z.number().int().positive().optional(),
|
|
93
|
+
cli: {
|
|
94
|
+
flags: "--udp-port <port>",
|
|
95
|
+
description: "UDP port to use for --graphics --watch mode reload trigger (default: 14434).",
|
|
96
|
+
parseArg: (v) => parseInt(v, 10),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
export const cliConfigSchema = z.object({
|
|
101
|
+
...Object.fromEntries(Object.entries(cliConfigFields).map(([k, v]) => [k, v.schema])),
|
|
102
|
+
test: testRunnerConfigSchema.optional(),
|
|
103
|
+
});
|
|
104
|
+
function addOption(command, cli) {
|
|
105
|
+
if (cli.parseArg) {
|
|
106
|
+
command.option(cli.flags, cli.description, cli.parseArg, cli.default);
|
|
107
|
+
}
|
|
108
|
+
else if ("default" in cli && cli.default !== undefined) {
|
|
109
|
+
command.option(cli.flags, cli.description, cli.default);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
command.option(cli.flags, cli.description);
|
|
113
|
+
}
|
|
114
|
+
if (cli.negatable) {
|
|
115
|
+
const flagName = cli.flags
|
|
116
|
+
.split(" ")[0]
|
|
117
|
+
.replace(/^-+/, "")
|
|
118
|
+
.replace(/^[a-z] --/, "");
|
|
119
|
+
command.option(`--no-${flagName}`, `Disable ${flagName}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function registerAllCliOptions(command) {
|
|
123
|
+
const f = cliConfigFields;
|
|
124
|
+
command.option("-c --config <path>", "Path to config file (default: factorio-test.json, or 'factorio-test' key in package.json)");
|
|
125
|
+
command.option("-g --graphics", "Launch Factorio with graphics (interactive mode) instead of headless");
|
|
126
|
+
command.option("-w --watch", "Watch for file changes and rerun tests");
|
|
127
|
+
addOption(command, f.modPath.cli);
|
|
128
|
+
addOption(command, f.modName.cli);
|
|
129
|
+
addOption(command, f.factorioPath.cli);
|
|
130
|
+
addOption(command, f.dataDirectory.cli);
|
|
131
|
+
addOption(command, f.save.cli);
|
|
132
|
+
addOption(command, f.mods.cli);
|
|
133
|
+
addOption(command, f.factorioArgs.cli);
|
|
134
|
+
registerTestConfigOptions(command);
|
|
135
|
+
addOption(command, f.verbose.cli);
|
|
136
|
+
addOption(command, f.quiet.cli);
|
|
137
|
+
addOption(command, f.outputFile.cli);
|
|
138
|
+
command.option("--no-output-file", "Disable writing test results file");
|
|
139
|
+
addOption(command, f.forbidOnly.cli);
|
|
140
|
+
addOption(command, f.watchPatterns.cli);
|
|
141
|
+
addOption(command, f.udpPort.cli);
|
|
142
|
+
}
|
package/config/index.js
ADDED
package/config/loader.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { CliError } from "../cli-error.js";
|
|
4
|
+
import { cliConfigSchema, DEFAULT_DATA_DIRECTORY } from "./cli-config.js";
|
|
5
|
+
import { parseCliTestOptions } from "./test-config.js";
|
|
6
|
+
function formatZodError(error, filePath) {
|
|
7
|
+
const issues = error.issues.map((issue) => {
|
|
8
|
+
const pathStr = issue.path.join(".");
|
|
9
|
+
return ` - ${pathStr ? `"${pathStr}": ` : ""}${issue.message}`;
|
|
10
|
+
});
|
|
11
|
+
return `Invalid config in ${filePath}:\n${issues.join("\n")}`;
|
|
12
|
+
}
|
|
13
|
+
export function loadConfig(configPath) {
|
|
14
|
+
const paths = configPath
|
|
15
|
+
? [path.resolve(configPath)]
|
|
16
|
+
: [path.resolve("factorio-test.json"), path.resolve("package.json")];
|
|
17
|
+
for (const filePath of paths) {
|
|
18
|
+
if (!fs.existsSync(filePath))
|
|
19
|
+
continue;
|
|
20
|
+
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
21
|
+
const rawConfig = filePath.endsWith("package.json") ? content["factorio-test"] : content;
|
|
22
|
+
if (!rawConfig)
|
|
23
|
+
continue;
|
|
24
|
+
const result = cliConfigSchema.strict().safeParse(rawConfig);
|
|
25
|
+
if (!result.success) {
|
|
26
|
+
throw new CliError(formatZodError(result.error, filePath));
|
|
27
|
+
}
|
|
28
|
+
return resolveConfigPaths(result.data, path.dirname(filePath));
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
function resolveConfigPaths(config, configDir) {
|
|
33
|
+
return {
|
|
34
|
+
...config,
|
|
35
|
+
modPath: config.modPath ? path.resolve(configDir, config.modPath) : undefined,
|
|
36
|
+
factorioPath: config.factorioPath ? path.resolve(configDir, config.factorioPath) : undefined,
|
|
37
|
+
dataDirectory: path.resolve(configDir, config.dataDirectory ?? DEFAULT_DATA_DIRECTORY),
|
|
38
|
+
save: config.save ? path.resolve(configDir, config.save) : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function mergeTestConfig(configFile, cliOptions) {
|
|
42
|
+
return {
|
|
43
|
+
...configFile,
|
|
44
|
+
test_pattern: cliOptions.test_pattern ?? configFile?.test_pattern,
|
|
45
|
+
tag_whitelist: cliOptions.tag_whitelist ?? configFile?.tag_whitelist,
|
|
46
|
+
tag_blacklist: cliOptions.tag_blacklist ?? configFile?.tag_blacklist,
|
|
47
|
+
default_timeout: cliOptions.default_timeout ?? configFile?.default_timeout,
|
|
48
|
+
game_speed: cliOptions.game_speed ?? configFile?.game_speed,
|
|
49
|
+
log_passed_tests: cliOptions.log_passed_tests ?? configFile?.log_passed_tests,
|
|
50
|
+
log_skipped_tests: cliOptions.log_skipped_tests ?? configFile?.log_skipped_tests,
|
|
51
|
+
reorder_failed_first: cliOptions.reorder_failed_first ?? configFile?.reorder_failed_first,
|
|
52
|
+
bail: cliOptions.bail ?? configFile?.bail,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function mergeCliConfig(fileConfig, options) {
|
|
56
|
+
options.modPath ??= fileConfig.modPath;
|
|
57
|
+
options.modName ??= fileConfig.modName;
|
|
58
|
+
options.factorioPath ??= fileConfig.factorioPath;
|
|
59
|
+
options.dataDirectory ??= fileConfig.dataDirectory ?? DEFAULT_DATA_DIRECTORY;
|
|
60
|
+
options.mods ??= fileConfig.mods;
|
|
61
|
+
options.factorioArgs ??= fileConfig.factorioArgs;
|
|
62
|
+
options.verbose ??= fileConfig.verbose;
|
|
63
|
+
options.quiet ??= fileConfig.quiet;
|
|
64
|
+
return options;
|
|
65
|
+
}
|
|
66
|
+
export function buildTestConfig(fileConfig, options, patterns) {
|
|
67
|
+
const cliTestOptions = parseCliTestOptions(options);
|
|
68
|
+
const testPattern = patterns.length > 0
|
|
69
|
+
? patterns.map((p) => `(${p})`).join("|")
|
|
70
|
+
: (cliTestOptions.test_pattern ?? fileConfig.test?.test_pattern);
|
|
71
|
+
return mergeTestConfig(fileConfig.test, { ...cliTestOptions, test_pattern: testPattern });
|
|
72
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const testConfigFields = {
|
|
3
|
+
test_pattern: {
|
|
4
|
+
schema: z.string().optional(),
|
|
5
|
+
cli: {
|
|
6
|
+
flags: "--test-pattern <pattern>",
|
|
7
|
+
description: "Filter tests by name pattern.",
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
tag_whitelist: {
|
|
11
|
+
schema: z.array(z.string()).optional(),
|
|
12
|
+
cli: {
|
|
13
|
+
flags: "--tag-whitelist <tags...>",
|
|
14
|
+
description: "Only run tests with these tags.",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
tag_blacklist: {
|
|
18
|
+
schema: z.array(z.string()).optional(),
|
|
19
|
+
cli: {
|
|
20
|
+
flags: "--tag-blacklist <tags...>",
|
|
21
|
+
description: "Skip tests with these tags.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
default_timeout: {
|
|
25
|
+
schema: z.number().int().positive().optional(),
|
|
26
|
+
cli: {
|
|
27
|
+
flags: "--default-timeout <ticks>",
|
|
28
|
+
description: "Default async test timeout in ticks.",
|
|
29
|
+
parseArg: parseInt,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
game_speed: {
|
|
33
|
+
schema: z.number().int().positive().optional(),
|
|
34
|
+
cli: {
|
|
35
|
+
flags: "--game-speed <speed>",
|
|
36
|
+
description: "Game speed multiplier.",
|
|
37
|
+
parseArg: parseInt,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
bail: {
|
|
41
|
+
schema: z.number().int().positive().optional(),
|
|
42
|
+
cli: {
|
|
43
|
+
flags: "-b --bail [count]",
|
|
44
|
+
description: "Stop after n failures (default: 1 when flag present).",
|
|
45
|
+
parseArg: (v) => (v === undefined ? 1 : parseInt(v)),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
reorder_failed_first: {
|
|
49
|
+
schema: z.boolean().optional(),
|
|
50
|
+
cli: {
|
|
51
|
+
flags: "--reorder-failed-first",
|
|
52
|
+
description: "Run previously failed tests first (default: enabled).",
|
|
53
|
+
negatable: true,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
log_passed_tests: {
|
|
57
|
+
schema: z.boolean().optional(),
|
|
58
|
+
cli: {
|
|
59
|
+
flags: "--log-passed-tests",
|
|
60
|
+
description: "Log passed test names (default: enabled).",
|
|
61
|
+
negatable: true,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
log_skipped_tests: {
|
|
65
|
+
schema: z.boolean().optional(),
|
|
66
|
+
cli: {
|
|
67
|
+
flags: "--log-skipped-tests",
|
|
68
|
+
description: "Log skipped test names.",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
export const testRunnerConfigSchema = z.strictObject(Object.fromEntries(Object.entries(testConfigFields).map(([k, v]) => [k, v.schema])));
|
|
73
|
+
export function registerTestConfigOptions(command) {
|
|
74
|
+
for (const field of Object.values(testConfigFields)) {
|
|
75
|
+
const cli = field.cli;
|
|
76
|
+
if (cli.parseArg) {
|
|
77
|
+
command.option(cli.flags, cli.description, cli.parseArg);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
command.option(cli.flags, cli.description);
|
|
81
|
+
}
|
|
82
|
+
if (cli.negatable) {
|
|
83
|
+
const flagName = cli.flags.split(" ")[0].replace("--", "");
|
|
84
|
+
command.option(`--no-${flagName}`, `Disable ${cli.description.toLowerCase().replace(/\.$/, "")}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function snakeToCamel(str) {
|
|
89
|
+
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
90
|
+
}
|
|
91
|
+
export function parseCliTestOptions(opts) {
|
|
92
|
+
const result = {};
|
|
93
|
+
for (const snake of Object.keys(testConfigFields)) {
|
|
94
|
+
const camel = snakeToCamel(snake);
|
|
95
|
+
let value = opts[camel];
|
|
96
|
+
if (value !== undefined) {
|
|
97
|
+
if (snake === "bail" && value === true) {
|
|
98
|
+
value = 1;
|
|
99
|
+
}
|
|
100
|
+
result[snake] = value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
package/config.test.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, assertType } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { loadConfig, mergeTestConfig, buildTestConfig } from "./config/index.js";
|
|
5
|
+
const testDir = path.join(import.meta.dirname, "__test_fixtures__");
|
|
6
|
+
describe("loadConfig", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
it("returns empty object when no config exists", () => {
|
|
14
|
+
expect(loadConfig(path.join(testDir, "nonexistent.json"))).toEqual({});
|
|
15
|
+
});
|
|
16
|
+
it("loads factorio-test.json with snake_case test config", () => {
|
|
17
|
+
const configPath = path.join(testDir, "factorio-test.json");
|
|
18
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
19
|
+
modPath: "./test",
|
|
20
|
+
test: { game_speed: 100 },
|
|
21
|
+
}));
|
|
22
|
+
expect(loadConfig(configPath)).toMatchObject({
|
|
23
|
+
modPath: path.join(testDir, "test"),
|
|
24
|
+
test: { game_speed: 100 },
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it("throws on invalid keys", () => {
|
|
28
|
+
const configPath = path.join(testDir, "bad.json");
|
|
29
|
+
fs.writeFileSync(configPath, JSON.stringify({ test: { invalid_key: true } }));
|
|
30
|
+
expect(() => loadConfig(configPath)).toThrow();
|
|
31
|
+
});
|
|
32
|
+
it("error message includes file path for invalid top-level key", () => {
|
|
33
|
+
const configPath = path.join(testDir, "bad-toplevel.json");
|
|
34
|
+
fs.writeFileSync(configPath, JSON.stringify({ unknownKey: true }));
|
|
35
|
+
expect(() => loadConfig(configPath)).toThrow(configPath);
|
|
36
|
+
});
|
|
37
|
+
it("error message includes field name for invalid top-level key", () => {
|
|
38
|
+
const configPath = path.join(testDir, "bad-toplevel.json");
|
|
39
|
+
fs.writeFileSync(configPath, JSON.stringify({ unknownKey: true }));
|
|
40
|
+
expect(() => loadConfig(configPath)).toThrow(/unknownKey/);
|
|
41
|
+
});
|
|
42
|
+
it("error message includes field path for invalid nested key", () => {
|
|
43
|
+
const configPath = path.join(testDir, "bad-nested.json");
|
|
44
|
+
fs.writeFileSync(configPath, JSON.stringify({ test: { badNestedKey: true } }));
|
|
45
|
+
expect(() => loadConfig(configPath)).toThrow(/test/);
|
|
46
|
+
});
|
|
47
|
+
it("error message includes field name for type mismatch", () => {
|
|
48
|
+
const configPath = path.join(testDir, "bad-type.json");
|
|
49
|
+
fs.writeFileSync(configPath, JSON.stringify({ test: { game_speed: "fast" } }));
|
|
50
|
+
expect(() => loadConfig(configPath)).toThrow(/game_speed/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("TestRunnerConfig type compatibility", () => {
|
|
54
|
+
it("all TestRunnerConfig keys exist in FactorioTest.Config with compatible types", () => {
|
|
55
|
+
assertType({});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe("mergeTestConfig", () => {
|
|
59
|
+
it("CLI options override config file", () => {
|
|
60
|
+
const result = mergeTestConfig({ game_speed: 100 }, { game_speed: 200 });
|
|
61
|
+
expect(result.game_speed).toBe(200);
|
|
62
|
+
});
|
|
63
|
+
it("CLI test pattern overrides config file", () => {
|
|
64
|
+
const result = mergeTestConfig({ test_pattern: "foo" }, { test_pattern: "bar" });
|
|
65
|
+
expect(result.test_pattern).toBe("bar");
|
|
66
|
+
});
|
|
67
|
+
it("preserves config file values when CLI undefined", () => {
|
|
68
|
+
const result = mergeTestConfig({ game_speed: 100, log_passed_tests: true }, {});
|
|
69
|
+
expect(result.game_speed).toBe(100);
|
|
70
|
+
expect(result.log_passed_tests).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe("buildTestConfig test pattern priority", () => {
|
|
74
|
+
const baseOptions = { dataDirectory: "." };
|
|
75
|
+
it("positional patterns override CLI option and config file", () => {
|
|
76
|
+
const result = buildTestConfig({ test: { test_pattern: "config" } }, { ...baseOptions, testPattern: "cli" }, [
|
|
77
|
+
"pos1",
|
|
78
|
+
"pos2",
|
|
79
|
+
]);
|
|
80
|
+
expect(result.test_pattern).toBe("(pos1)|(pos2)");
|
|
81
|
+
});
|
|
82
|
+
it("CLI option overrides config file when no positional patterns", () => {
|
|
83
|
+
const result = buildTestConfig({ test: { test_pattern: "config" } }, { ...baseOptions, testPattern: "cli" }, []);
|
|
84
|
+
expect(result.test_pattern).toBe("cli");
|
|
85
|
+
});
|
|
86
|
+
it("uses config file when no CLI option or positional patterns", () => {
|
|
87
|
+
const result = buildTestConfig({ test: { test_pattern: "config" } }, baseOptions, []);
|
|
88
|
+
expect(result.test_pattern).toBe("config");
|
|
89
|
+
});
|
|
90
|
+
it("undefined when no patterns specified anywhere", () => {
|
|
91
|
+
const result = buildTestConfig({}, baseOptions, []);
|
|
92
|
+
expect(result.test_pattern).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as os from "os";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
5
|
+
import { CliError } from "./cli-error.js";
|
|
6
|
+
export function getFactorioPlayerDataPath() {
|
|
7
|
+
const platform = os.platform();
|
|
8
|
+
if (platform === "win32") {
|
|
9
|
+
return path.join(process.env.APPDATA, "Factorio", "player-data.json");
|
|
10
|
+
}
|
|
11
|
+
if (platform === "darwin") {
|
|
12
|
+
return path.join(os.homedir(), "Library", "Application Support", "factorio", "player-data.json");
|
|
13
|
+
}
|
|
14
|
+
return path.join(os.homedir(), ".factorio", "player-data.json");
|
|
15
|
+
}
|
|
16
|
+
function factorioIsInPath() {
|
|
17
|
+
const result = spawnSync("factorio", ["--version"], { stdio: "ignore" });
|
|
18
|
+
return result.status === 0;
|
|
19
|
+
}
|
|
20
|
+
export function autoDetectFactorioPath() {
|
|
21
|
+
if (factorioIsInPath()) {
|
|
22
|
+
return "factorio";
|
|
23
|
+
}
|
|
24
|
+
let pathsToTry;
|
|
25
|
+
if (os.platform() === "linux" || os.platform() === "darwin") {
|
|
26
|
+
pathsToTry = [
|
|
27
|
+
"~/.local/share/Steam/steamapps/common/Factorio/bin/x64/factorio",
|
|
28
|
+
"~/Library/Application Support/Steam/steamapps/common/Factorio/factorio.app/Contents/MacOS/factorio",
|
|
29
|
+
"~/.factorio/bin/x64/factorio",
|
|
30
|
+
"/Applications/factorio.app/Contents/MacOS/factorio",
|
|
31
|
+
"/usr/share/factorio/bin/x64/factorio",
|
|
32
|
+
"/usr/share/games/factorio/bin/x64/factorio",
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
else if (os.platform() === "win32") {
|
|
36
|
+
pathsToTry = [
|
|
37
|
+
"factorio.exe",
|
|
38
|
+
process.env["ProgramFiles(x86)"] + "\\Steam\\steamapps\\common\\Factorio\\bin\\x64\\factorio.exe",
|
|
39
|
+
process.env["ProgramFiles"] + "\\Factorio\\bin\\x64\\factorio.exe",
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
throw new CliError(`Cannot auto-detect factorio path on platform ${os.platform()}`);
|
|
44
|
+
}
|
|
45
|
+
pathsToTry = pathsToTry.map((p) => p.replace(/^~\//, os.homedir() + "/"));
|
|
46
|
+
for (const testPath of pathsToTry) {
|
|
47
|
+
if (fs.statSync(testPath, { throwIfNoEntry: false })?.isFile()) {
|
|
48
|
+
return path.resolve(testPath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw new CliError(`Could not auto-detect factorio executable. Tried: ${pathsToTry.join(", ")}. ` +
|
|
52
|
+
"Either add the factorio bin to your path, or specify the path with --factorio-path");
|
|
53
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
vi.mock("child_process", () => ({
|
|
3
|
+
spawnSync: vi.fn(() => ({ status: 1 })),
|
|
4
|
+
}));
|
|
5
|
+
describe("autoDetectFactorioPath", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.resetModules();
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
it("returns 'factorio' if in PATH", async () => {
|
|
13
|
+
const { spawnSync } = await import("child_process");
|
|
14
|
+
vi.mocked(spawnSync).mockReturnValue({ status: 0 });
|
|
15
|
+
const { autoDetectFactorioPath } = await import("./factorio-discovery.js");
|
|
16
|
+
expect(autoDetectFactorioPath()).toBe("factorio");
|
|
17
|
+
});
|
|
18
|
+
it("throws if no path found and factorio not in PATH", async () => {
|
|
19
|
+
const { spawnSync } = await import("child_process");
|
|
20
|
+
vi.mocked(spawnSync).mockReturnValue({ status: 1 });
|
|
21
|
+
const { autoDetectFactorioPath } = await import("./factorio-discovery.js");
|
|
22
|
+
expect(() => autoDetectFactorioPath()).toThrow(/Could not auto-detect/);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
const EVENT_PREFIX = "FACTORIO-TEST-EVENT:";
|
|
3
|
+
export function parseEvent(line) {
|
|
4
|
+
if (!line.startsWith(EVENT_PREFIX)) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(line.slice(EVENT_PREFIX.length));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class FactorioOutputHandler extends EventEmitter {
|
|
15
|
+
inMessage = false;
|
|
16
|
+
handleLine(line) {
|
|
17
|
+
if (line.startsWith("FACTORIO-TEST-RESULT:")) {
|
|
18
|
+
this.emit("result", line.slice("FACTORIO-TEST-RESULT:".length));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (line === "FACTORIO-TEST-MESSAGE-START") {
|
|
22
|
+
this.inMessage = true;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (line === "FACTORIO-TEST-MESSAGE-END") {
|
|
26
|
+
this.inMessage = false;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const event = parseEvent(line);
|
|
30
|
+
if (event) {
|
|
31
|
+
this.emit("event", event);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (this.inMessage) {
|
|
35
|
+
this.emit("message", line);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
this.emit("log", line);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseEvent } from "./factorio-output-parser.js";
|
|
3
|
+
describe("parseEvent", () => {
|
|
4
|
+
it("parses valid testStarted event", () => {
|
|
5
|
+
const line = 'FACTORIO-TEST-EVENT:{"type":"testStarted","test":{"path":"root > mytest"}}';
|
|
6
|
+
const result = parseEvent(line);
|
|
7
|
+
expect(result).toEqual({
|
|
8
|
+
type: "testStarted",
|
|
9
|
+
test: { path: "root > mytest" },
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
it("parses valid testPassed event with duration", () => {
|
|
13
|
+
const line = 'FACTORIO-TEST-EVENT:{"type":"testPassed","test":{"path":"root > test","duration":"1.23 ms"}}';
|
|
14
|
+
const result = parseEvent(line);
|
|
15
|
+
expect(result).toEqual({
|
|
16
|
+
type: "testPassed",
|
|
17
|
+
test: { path: "root > test", duration: "1.23 ms" },
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
it("parses testFailed event with errors", () => {
|
|
21
|
+
const line = 'FACTORIO-TEST-EVENT:{"type":"testFailed","test":{"path":"test"},"errors":["error1","error2"]}';
|
|
22
|
+
const result = parseEvent(line);
|
|
23
|
+
expect(result).toEqual({
|
|
24
|
+
type: "testFailed",
|
|
25
|
+
test: { path: "test" },
|
|
26
|
+
errors: ["error1", "error2"],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
it("returns undefined for non-event lines", () => {
|
|
30
|
+
expect(parseEvent("some random log line")).toBeUndefined();
|
|
31
|
+
expect(parseEvent("FACTORIO-TEST-MESSAGE-START")).toBeUndefined();
|
|
32
|
+
expect(parseEvent("")).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
it("returns undefined for malformed JSON", () => {
|
|
35
|
+
expect(parseEvent("FACTORIO-TEST-EVENT:{not valid json}")).toBeUndefined();
|
|
36
|
+
expect(parseEvent("FACTORIO-TEST-EVENT:")).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|